Checkpoint: Implémentation des fonctionnalités de communication : formulaire de contact avec choix email/canal, canaux de discussion intégrés (page Mes Échanges), demandes de mise en relation avec gestion admin des canaux, navigation mise à jour dans DashboardLayout, bouton Demande de mise en relation sur fiche établissement. Migration DB : tables canaux_discussion, membres_canal, messages_canal, demandes_mise_en_relation.

This commit is contained in:
Manus
2026-05-13 10:40:13 -04:00
parent 46321a1eaf
commit 0205859880
13 changed files with 2437 additions and 57 deletions

View File

@@ -3,12 +3,17 @@ import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser,
blocsFonctionnels,
canauxDiscussion,
consultations,
demandesContact,
demandesMiseEnRelation,
editeurs,
etablissements,
logicielsEtablissements,
membresCanauxDiscussion,
messagesCanaux,
solutions,
userEtablissements,
users,
} from "../drizzle/schema";
import { ENV } from "./_core/env";
@@ -430,7 +435,7 @@ export async function getDemandeById(id: number) {
// ─── Auth locale ──────────────────────────────────────────────────────────────
import { localCredentials, userEtablissements } from "../drizzle/schema";
import { localCredentials } from "../drizzle/schema";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
@@ -1031,3 +1036,222 @@ export async function setAdherentsForEtablissement(etablissementId: number, user
);
}
}
// ─── Canaux de Discussion ─────────────────────────────────────────────────────
/**
* Crée un canal de discussion et ajoute les membres initiaux.
*/
export async function createCanal(data: {
titre: string;
description?: string;
type: "contact_referent" | "mise_en_relation_public" | "mise_en_relation_prive";
visibilite: "public" | "prive";
etablissementId?: number;
demandeContactId?: number;
demandeMiseEnRelationId?: number;
creePar: number;
membreIds: number[];
}) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
const [result] = await db.insert(canauxDiscussion).values({
titre: data.titre,
description: data.description,
type: data.type,
visibilite: data.visibilite,
etablissementId: data.etablissementId,
demandeContactId: data.demandeContactId,
demandeMiseEnRelationId: data.demandeMiseEnRelationId,
creePar: data.creePar,
});
const canalId = (result as any).insertId as number;
if (data.membreIds.length > 0) {
await db.insert(membresCanauxDiscussion).values(
data.membreIds.map((uid) => ({ canalId, userId: uid, role: "membre" as const }))
);
}
return canalId;
}
/**
* Récupère les canaux accessibles à un utilisateur (membre ou gestionnaire).
*/
export async function getCanauxForUser(userId: number, isGestionnaire: boolean) {
const db = await getDb();
if (!db) return [];
if (isGestionnaire) {
return db.select().from(canauxDiscussion).orderBy(desc(canauxDiscussion.updatedAt));
}
// Canaux dont l'utilisateur est membre
const memberships = await db
.select({ canalId: membresCanauxDiscussion.canalId })
.from(membresCanauxDiscussion)
.where(eq(membresCanauxDiscussion.userId, userId));
const canalIds = memberships.map((m) => m.canalId);
if (canalIds.length === 0) return [];
return db
.select()
.from(canauxDiscussion)
.where(inArray(canauxDiscussion.id, canalIds))
.orderBy(desc(canauxDiscussion.updatedAt));
}
/**
* Récupère les messages d'un canal.
*/
export async function getMessagesCanal(canalId: number) {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(messagesCanaux)
.where(eq(messagesCanaux.canalId, canalId))
.orderBy(messagesCanaux.createdAt);
}
/**
* Envoie un message dans un canal.
*/
export async function sendMessageCanal(data: {
canalId: number;
auteurId: number;
auteurNom: string;
contenu: string;
}) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
await db.insert(messagesCanaux).values(data);
// Mettre à jour updatedAt du canal
await db
.update(canauxDiscussion)
.set({ updatedAt: new Date() })
.where(eq(canauxDiscussion.id, data.canalId));
}
/**
* Vérifie si un utilisateur est membre d'un canal.
*/
export async function isMemberOfCanal(canalId: number, userId: number): Promise<boolean> {
const db = await getDb();
if (!db) return false;
const rows = await db
.select()
.from(membresCanauxDiscussion)
.where(and(eq(membresCanauxDiscussion.canalId, canalId), eq(membresCanauxDiscussion.userId, userId)));
return rows.length > 0;
}
/**
* Récupère un canal par son ID.
*/
export async function getCanalById(id: number) {
const db = await getDb();
if (!db) return null;
const rows = await db.select().from(canauxDiscussion).where(eq(canauxDiscussion.id, id));
return rows[0] ?? null;
}
/**
* Récupère les membres d'un canal.
*/
export async function getMembresCanal(canalId: number) {
const db = await getDb();
if (!db) return [];
return db
.select({
id: users.id,
name: users.name,
email: users.email,
sonumRole: users.sonumRole,
role: membresCanauxDiscussion.role,
})
.from(membresCanauxDiscussion)
.innerJoin(users, eq(membresCanauxDiscussion.userId, users.id))
.where(eq(membresCanauxDiscussion.canalId, canalId));
}
/**
* Ajoute des membres à un canal existant.
*/
export async function addMembresCanal(canalId: number, userIds: number[]) {
const db = await getDb();
if (!db) return;
const existing = await db
.select({ userId: membresCanauxDiscussion.userId })
.from(membresCanauxDiscussion)
.where(eq(membresCanauxDiscussion.canalId, canalId));
const existingIds = new Set(existing.map((m) => m.userId));
const toAdd = userIds.filter((id) => !existingIds.has(id));
if (toAdd.length > 0) {
await db.insert(membresCanauxDiscussion).values(
toAdd.map((uid) => ({ canalId, userId: uid, role: "membre" as const }))
);
}
}
// ─── Demandes de Mise en Relation ─────────────────────────────────────────────
/**
* Crée une demande de mise en relation.
*/
export async function createDemandeMiseEnRelation(data: {
demandeurId: number;
demandeurNom: string;
demandeurEmail?: string;
etablissementDemandeurId?: number;
sujet: string;
message: string;
}) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
const [result] = await db.insert(demandesMiseEnRelation).values(data);
return (result as any).insertId as number;
}
/**
* Récupère toutes les demandes de mise en relation (pour les gestionnaires).
*/
export async function getAllDemandesMiseEnRelation() {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(demandesMiseEnRelation)
.orderBy(desc(demandesMiseEnRelation.createdAt));
}
/**
* Récupère les demandes de mise en relation d'un utilisateur.
*/
export async function getDemandesMiseEnRelationByUser(userId: number) {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(demandesMiseEnRelation)
.where(eq(demandesMiseEnRelation.demandeurId, userId))
.orderBy(desc(demandesMiseEnRelation.createdAt));
}
/**
* Marque une demande de mise en relation comme traitée et lui associe un canal.
*/
export async function traiterDemandeMiseEnRelation(id: number, traitePar: number, canalId?: number) {
const db = await getDb();
if (!db) return;
await db
.update(demandesMiseEnRelation)
.set({ statut: "traite", traitePar, traiteAt: new Date(), canalId: canalId ?? null })
.where(eq(demandesMiseEnRelation.id, id));
}
/**
* Récupère une demande de mise en relation par son ID.
*/
export async function getDemandeMiseEnRelationById(id: number) {
const db = await getDb();
if (!db) return null;
const rows = await db.select().from(demandesMiseEnRelation).where(eq(demandesMiseEnRelation.id, id));
return rows[0] ?? null;
}

View File

@@ -43,6 +43,19 @@ import {
setReferentForEtablissement,
setAdherentsForEtablissement,
updateLocalPassword,
createCanal,
getCanauxForUser,
getMessagesCanal,
sendMessageCanal,
isMemberOfCanal,
getCanalById,
getMembresCanal,
addMembresCanal,
createDemandeMiseEnRelation,
getAllDemandesMiseEnRelation,
getDemandesMiseEnRelationByUser,
traiterDemandeMiseEnRelation,
getDemandeMiseEnRelationById,
updateUser,
updateUserCgu,
updateUserSonumRole,
@@ -76,10 +89,88 @@ const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
return next({ ctx });
});
// ─── Router principal ─────────────────────────────────────────────────────────
/// ─── Canaux de Discussion ────────────────────────────────────────────────────
const canauxRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
return getCanauxForUser(ctx.user.id, isGestionnaire);
}),
messages: protectedProcedure
.input(z.object({ canalId: z.number().int() }))
.query(async ({ input, ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
if (!isGestionnaire) {
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
}
return getMessagesCanal(input.canalId);
}),
membres: protectedProcedure
.input(z.object({ canalId: z.number().int() }))
.query(async ({ input, ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
if (!isGestionnaire) {
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
}
return getMembresCanal(input.canalId);
}),
sendMessage: writeProcedure
.input(z.object({ canalId: z.number().int(), contenu: z.string().min(1).max(5000) }))
.mutation(async ({ input, ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
if (!isGestionnaire) {
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
}
await sendMessageCanal({ canalId: input.canalId, auteurId: ctx.user.id, auteurNom: ctx.user.name ?? "Inconnu", contenu: input.contenu });
return { success: true };
}),
create: gestionnaireProcedure
.input(z.object({
titre: z.string().min(1).max(255),
description: z.string().optional(),
type: z.enum(["contact_referent", "mise_en_relation_public", "mise_en_relation_prive"]),
visibilite: z.enum(["public", "prive"]),
etablissementId: z.number().int().optional(),
demandeMiseEnRelationId: z.number().int().optional(),
membreIds: z.array(z.number().int()),
}))
.mutation(async ({ input, ctx }) => {
const canalId = await createCanal({ ...input, creePar: ctx.user.id });
if (input.demandeMiseEnRelationId) {
await traiterDemandeMiseEnRelation(input.demandeMiseEnRelationId, ctx.user.id, canalId);
}
return { canalId };
}),
addMembres: gestionnaireProcedure
.input(z.object({ canalId: z.number().int(), userIds: z.array(z.number().int()) }))
.mutation(async ({ input }) => { await addMembresCanal(input.canalId, input.userIds); return { success: true }; }),
});
// ─── Demandes de Mise en Relation ─────────────────────────────────────────────
const miseEnRelationRouter = router({
soumettre: writeProcedure
.input(z.object({ sujet: z.string().min(1).max(500), message: z.string().min(1), etablissementDemandeurId: z.number().int().optional() }))
.mutation(async ({ input, ctx }) => {
const id = await createDemandeMiseEnRelation({
demandeurId: ctx.user.id, demandeurNom: ctx.user.name ?? "Inconnu",
demandeurEmail: ctx.user.email ?? undefined,
etablissementDemandeurId: input.etablissementDemandeurId,
sujet: input.sujet, message: input.message,
});
await notifyOwner({ title: `Nouvelle demande de mise en relation — ${input.sujet}`, content: `${ctx.user.name} (${ctx.user.email}) souhaite une mise en relation.\n\nSujet : ${input.sujet}\n\nMessage : ${input.message}` });
return { id };
}),
all: gestionnaireProcedure.query(() => getAllDemandesMiseEnRelation()),
mesDemandes: protectedProcedure.query(({ ctx }) => getDemandesMiseEnRelationByUser(ctx.user.id)),
});
// ─── Router principal ─────────────────────────────────────────────────────────
export const appRouter = router({
system: systemRouter,
canaux: canauxRouter,
miseEnRelation: miseEnRelationRouter,
// ─── Auth ──────────────────────────────────────────────────────────────────
auth: router({