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

1
VERSION Normal file
View File

@@ -0,0 +1 @@
v11-1777031516

View File

@@ -14,6 +14,8 @@ import LoginLocal from "./pages/LoginLocal";
import MesSolutions from "./pages/MesSolutions"; import MesSolutions from "./pages/MesSolutions";
import SolutionsLogicielles from "./pages/SolutionsLogicielles"; import SolutionsLogicielles from "./pages/SolutionsLogicielles";
import Statistiques from "./pages/Statistiques"; import Statistiques from "./pages/Statistiques";
import MesEchanges from "./pages/MesEchanges";
import MiseEnRelation from "./pages/MiseEnRelation";
function Router() { function Router() {
return ( return (
@@ -31,6 +33,8 @@ function Router() {
<Route path="/mes-solutions" component={MesSolutions} /> <Route path="/mes-solutions" component={MesSolutions} />
<Route path="/solutions" component={SolutionsLogicielles} /> <Route path="/solutions" component={SolutionsLogicielles} />
<Route path="/statistiques" component={Statistiques} /> <Route path="/statistiques" component={Statistiques} />
<Route path="/mes-echanges" component={MesEchanges} />
<Route path="/mise-en-relation" component={MiseEnRelation} />
{/* Fallback */} {/* Fallback */}
<Route path="/404" component={NotFound} /> <Route path="/404" component={NotFound} />

View File

@@ -1,5 +1,5 @@
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { Building2, Mail, Send, X } from "lucide-react"; import { Building2, Mail, MessageSquare, Send, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -9,87 +9,158 @@ interface ContactModalProps {
onClose: () => void; onClose: () => void;
} }
type Mode = "choix" | "email" | "canal";
export default function ContactModal({ etablissementId, etablissementNom, onClose }: ContactModalProps) { export default function ContactModal({ etablissementId, etablissementNom, onClose }: ContactModalProps) {
const [mode, setMode] = useState<Mode>("choix");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const sendMutation = trpc.contact.envoyer.useMutation({ const sendEmailMutation = trpc.contact.envoyer.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success("Votre demande de contact a été envoyée avec succès."); toast.success("Votre demande a été transmise au référent numérique par email.");
onClose(); onClose();
}, },
onError: (err) => { onError: (err) => toast.error("Erreur lors de l'envoi : " + err.message),
toast.error("Erreur lors de l'envoi : " + err.message);
},
}); });
const handleSubmit = (e: React.FormEvent) => { const createCanalMutation = trpc.canaux.create.useMutation({
onSuccess: () => {
toast.success("Un canal de discussion a été ouvert. Retrouvez-le dans « Mes Échanges ».");
onClose();
},
onError: (err) => toast.error("Erreur : " + err.message),
});
const etabQuery = trpc.etablissements.byId.useQuery({ id: etablissementId });
const referentId = etabQuery.data?.referentId;
const handleSubmitEmail = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!message.trim()) return; if (!message.trim()) return;
sendMutation.mutate({ etablissementCibleId: etablissementId, message }); sendEmailMutation.mutate({ etablissementCibleId: etablissementId, message });
};
const handleSubmitCanal = (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
const membreIds = referentId ? [referentId] : [];
createCanalMutation.mutate({
titre: `Contact — ${etablissementNom}`,
description: message,
type: "contact_referent",
visibilite: "prive",
etablissementId,
membreIds,
});
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card rounded-2xl shadow-2xl max-w-lg w-full border border-border"> <div className="bg-card rounded-2xl shadow-2xl max-w-lg w-full border border-border">
{/* En-tête */}
<div className="flex items-start justify-between px-6 py-5 border-b border-border"> <div className="flex items-start justify-between px-6 py-5 border-b border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Mail size={20} className="text-primary" /> <Mail size={20} className="text-primary" />
</div> </div>
<div> <div>
<h2 className="text-base font-bold text-foreground">Demande de contact</h2> <h2 className="text-base font-bold text-foreground">Contacter le référent numérique</h2>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-0.5"> <div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-0.5">
<Building2 size={13} /> <Building2 size={13} />
<span>{etablissementNom}</span> <span>{etablissementNom}</span>
</div> </div>
</div> </div>
</div> </div>
<button <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground">
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
{/* Formulaire */} <div className="p-6">
<form onSubmit={handleSubmit} className="p-6"> {mode === "choix" && (
<div className="mb-5"> <div className="space-y-3">
<label className="block text-sm font-medium text-foreground mb-2"> <p className="text-sm text-muted-foreground mb-4">
Votre message <span className="text-destructive">*</span> Comment souhaitez-vous entrer en contact avec le référent numérique de cet établissement ?
</label> </p>
<textarea <button
value={message} onClick={() => setMode("email")}
onChange={(e) => setMessage(e.target.value)} className="w-full flex items-start gap-4 p-4 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-all text-left group"
placeholder="Décrivez votre besoin ou votre question au référent numérique de cet établissement..." >
rows={5} <div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0 group-hover:bg-blue-500/20 transition-colors">
className="w-full px-4 py-3 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all resize-none" <Mail size={20} className="text-blue-600" />
required </div>
/> <div>
<p className="text-xs text-muted-foreground mt-1.5"> <p className="text-sm font-semibold text-foreground">Par email (hors plateforme)</p>
Le référent numérique de l'établissement et les gestionnaires SONUM recevront votre message. <p className="text-xs text-muted-foreground mt-0.5">
</p> Le référent reçoit un email avec vos coordonnées. Les échanges se poursuivent par email.
</div> </p>
</div>
</button>
<button
onClick={() => setMode("canal")}
className="w-full flex items-start gap-4 p-4 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-all text-left group"
>
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center shrink-0 group-hover:bg-green-500/20 transition-colors">
<MessageSquare size={20} className="text-green-600" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">Canal de discussion SONUM</p>
<p className="text-xs text-muted-foreground mt-0.5">
Un espace de discussion privé est ouvert dans SONUM. Le référent est notifié et peut répondre directement sur la plateforme.
</p>
</div>
</button>
</div>
)}
<div className="flex gap-3"> {(mode === "email" || mode === "canal") && (
<button <form onSubmit={mode === "email" ? handleSubmitEmail : handleSubmitCanal}>
type="button" <div className={`flex items-center gap-2 px-3 py-2 rounded-lg mb-4 text-xs font-medium ${
onClick={onClose} mode === "email" ? "bg-blue-500/10 text-blue-700" : "bg-green-500/10 text-green-700"
className="flex-1 py-2.5 px-4 rounded-lg text-sm font-medium border border-border text-foreground hover:bg-muted transition-colors" }`}>
> {mode === "email" ? <Mail size={14} /> : <MessageSquare size={14} />}
Annuler {mode === "email" ? "Envoi par email" : "Canal de discussion SONUM"}
</button> <button type="button" onClick={() => setMode("choix")} className="ml-auto text-muted-foreground hover:text-foreground underline text-xs">
<button Changer
type="submit" </button>
disabled={!message.trim() || sendMutation.isPending} </div>
className="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm" <div className="mb-5">
> <label className="block text-sm font-medium text-foreground mb-2">
<Send size={15} /> Votre message <span className="text-destructive">*</span>
{sendMutation.isPending ? "Envoi..." : "Envoyer"} </label>
</button> <textarea
</div> value={message}
</form> onChange={(e) => setMessage(e.target.value)}
placeholder={
mode === "email"
? "Décrivez votre besoin ou votre question. Vos coordonnées seront transmises au référent."
: "Décrivez votre besoin. Ce message sera le premier message du canal de discussion."
}
rows={5}
className="w-full px-4 py-3 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all resize-none"
required
/>
<p className="text-xs text-muted-foreground mt-1.5">
{mode === "email"
? "Le référent numérique et les gestionnaires SONUM recevront votre message par email."
: "Un canal de discussion privé sera créé. Le référent et les gestionnaires SONUM y auront accès."}
</p>
</div>
<div className="flex gap-3">
<button type="button" onClick={() => setMode("choix")} className="flex-1 py-2.5 px-4 rounded-lg text-sm font-medium border border-border text-foreground hover:bg-muted transition-colors">
Retour
</button>
<button
type="submit"
disabled={!message.trim() || sendEmailMutation.isPending || createCanalMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
<Send size={15} />
{sendEmailMutation.isPending || createCanalMutation.isPending ? "Envoi..." : "Envoyer"}
</button>
</div>
</form>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -21,15 +21,21 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { getLoginUrl } from "@/const"; import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile"; import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react"; import { LayoutDashboard, LogOut, PanelLeft, Users, Building2, BookOpen, BarChart3, MessageSquare, UserPlus, Settings, FileText } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react"; import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
import { Button } from "./ui/button"; import { Button } from "./ui/button";
const menuItems = [ const menuItems = [
{ icon: LayoutDashboard, label: "Page 1", path: "/" }, { icon: LayoutDashboard, label: "Accueil", path: "/", adminOnly: false },
{ icon: Users, label: "Page 2", path: "/some-path" }, { icon: Building2, label: "Mes Établissements", path: "/mes-etablissements", adminOnly: false },
{ icon: BookOpen, label: "Solutions Logicielles", path: "/solutions", adminOnly: false },
{ icon: FileText, label: "Mes Solutions", path: "/mes-solutions", adminOnly: false },
{ icon: MessageSquare, label: "Mes Échanges", path: "/mes-echanges", adminOnly: false },
{ icon: UserPlus, label: "Mise en relation", path: "/mise-en-relation", adminOnly: false },
{ icon: BarChart3, label: "Statistiques", path: "/statistiques", adminOnly: false },
{ icon: Settings, label: "Administration", path: "/admin", adminOnly: true },
]; ];
const SIDEBAR_WIDTH_KEY = "sidebar-width"; const SIDEBAR_WIDTH_KEY = "sidebar-width";
@@ -112,7 +118,9 @@ function DashboardLayoutContent({
const isCollapsed = state === "collapsed"; const isCollapsed = state === "collapsed";
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null); const sidebarRef = useRef<HTMLDivElement>(null);
const activeMenuItem = menuItems.find(item => item.path === location); const isAdmin = user?.role === "admin" || user?.sonumRole === "gestionnaire";
const visibleMenuItems = menuItems.filter(item => !item.adminOnly || isAdmin);
const activeMenuItem = visibleMenuItems.find(item => item.path === location);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
useEffect(() => { useEffect(() => {
@@ -180,7 +188,7 @@ function DashboardLayoutContent({
<SidebarContent className="gap-0"> <SidebarContent className="gap-0">
<SidebarMenu className="px-2 py-1"> <SidebarMenu className="px-2 py-1">
{menuItems.map(item => { {visibleMenuItems.map(item => {
const isActive = location === item.path; const isActive = location === item.path;
return ( return (
<SidebarMenuItem key={item.path}> <SidebarMenuItem key={item.path}>

View File

@@ -13,6 +13,7 @@ import {
Server, Server,
Tag, Tag,
Users, Users,
UserPlus,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
@@ -144,6 +145,14 @@ export default function FicheEtablissement({ params }: Props) {
Prendre contact Prendre contact
</button> </button>
)} )}
{/* Bouton demande de mise en relation */}
<button
onClick={() => navigate("/mise-en-relation")}
className="flex items-center gap-2 px-4 py-2 bg-muted border border-border text-foreground rounded-lg text-sm font-medium hover:bg-muted/80 transition-colors"
>
<UserPlus size={15} />
Demande de mise en relation
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,279 @@
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { useState, useRef, useEffect } from "react";
import { Link } from "wouter";
import {
MessageSquare, Send, ArrowLeft, Users, Lock, Globe,
Building2, Clock, ChevronRight, Inbox
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
type Canal = {
id: number;
titre: string;
description?: string | null;
type: string;
visibilite: string;
etablissementId?: number | null;
etablissementNom?: string | null;
createdAt: Date;
nbMessages?: number;
};
type Message = {
id: number;
auteurId: number;
auteurNom: string;
contenu: string;
createdAt: Date;
};
// ─── Composant principal ──────────────────────────────────────────────────────
export default function MesEchanges() {
const { user } = useAuth();
const [selectedCanalId, setSelectedCanalId] = useState<number | null>(null);
const [newMessage, setNewMessage] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const canauxQuery = trpc.canaux.list.useQuery();
const messagesQuery = trpc.canaux.messages.useQuery(
{ canalId: selectedCanalId! },
{ enabled: !!selectedCanalId, refetchInterval: 5000 }
);
const utils = trpc.useUtils();
const sendMutation = trpc.canaux.sendMessage.useMutation({
onSuccess: () => {
setNewMessage("");
utils.canaux.messages.invalidate({ canalId: selectedCanalId! });
},
});
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messagesQuery.data]);
const selectedCanal = canauxQuery.data?.find((c: Canal) => c.id === selectedCanalId);
const handleSend = (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || !selectedCanalId) return;
sendMutation.mutate({ canalId: selectedCanalId, contenu: newMessage });
};
const typeLabel = (type: string) => {
switch (type) {
case "contact_referent": return "Contact référent";
case "mise_en_relation_public": return "Mise en relation publique";
case "mise_en_relation_prive": return "Mise en relation privée";
default: return type;
}
};
const typeColor = (type: string) => {
switch (type) {
case "contact_referent": return "bg-blue-500/10 text-blue-700";
case "mise_en_relation_public": return "bg-green-500/10 text-green-700";
case "mise_en_relation_prive": return "bg-purple-500/10 text-purple-700";
default: return "bg-muted text-muted-foreground";
}
};
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-4">
<Link href="/" className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground">
<ArrowLeft size={18} />
</Link>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<MessageSquare size={18} className="text-primary" />
</div>
<div>
<h1 className="text-base font-bold text-foreground">Mes Échanges</h1>
<p className="text-xs text-muted-foreground">Canaux de discussion SONUM</p>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-6">
<div className="flex gap-6 h-[calc(100vh-140px)]">
{/* Liste des canaux */}
<div className="w-80 shrink-0 flex flex-col gap-3">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">
{canauxQuery.data?.length ?? 0} canal{(canauxQuery.data?.length ?? 0) > 1 ? "x" : ""}
</p>
<div className="flex flex-col gap-2 overflow-y-auto">
{canauxQuery.isLoading && (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 rounded-xl bg-muted animate-pulse" />
))}
</div>
)}
{!canauxQuery.isLoading && (!canauxQuery.data || canauxQuery.data.length === 0) && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-12 h-12 rounded-2xl bg-muted flex items-center justify-center mb-3">
<Inbox size={22} className="text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground">Aucun échange</p>
<p className="text-xs text-muted-foreground mt-1">
Contactez un référent depuis la fiche d'un établissement pour démarrer un échange.
</p>
</div>
)}
{canauxQuery.data?.map((canal: Canal) => (
<button
key={canal.id}
onClick={() => setSelectedCanalId(canal.id)}
className={`w-full text-left p-4 rounded-xl border transition-all ${
selectedCanalId === canal.id
? "border-primary bg-primary/5 shadow-sm"
: "border-border hover:border-primary/50 hover:bg-muted/50"
}`}
>
<div className="flex items-start justify-between gap-2 mb-2">
<p className="text-sm font-semibold text-foreground line-clamp-1 flex-1">{canal.titre}</p>
<div className="flex items-center gap-1 shrink-0">
{canal.visibilite === "prive" ? (
<Lock size={12} className="text-muted-foreground" />
) : (
<Globe size={12} className="text-muted-foreground" />
)}
</div>
</div>
{canal.etablissementNom && (
<div className="flex items-center gap-1 mb-2">
<Building2 size={11} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">{canal.etablissementNom}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeColor(canal.type)}`}>
{typeLabel(canal.type)}
</span>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock size={10} />
<span>{new Date(canal.createdAt).toLocaleDateString("fr-FR")}</span>
</div>
</div>
</button>
))}
</div>
</div>
{/* Zone de messagerie */}
<div className="flex-1 flex flex-col border border-border rounded-2xl overflow-hidden bg-card">
{!selectedCanalId ? (
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<MessageSquare size={28} className="text-primary" />
</div>
<h3 className="text-base font-bold text-foreground mb-2">Sélectionnez un canal</h3>
<p className="text-sm text-muted-foreground max-w-xs">
Choisissez un canal dans la liste à gauche pour consulter et participer à la discussion.
</p>
</div>
) : (
<>
{/* En-tête du canal */}
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 className="text-sm font-bold text-foreground">{selectedCanal?.titre}</h2>
{selectedCanal?.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{selectedCanal.description}</p>
)}
</div>
<div className="flex items-center gap-2">
{selectedCanal && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeColor(selectedCanal.type)}`}>
{typeLabel(selectedCanal.type)}
</span>
)}
{selectedCanal?.visibilite === "prive" ? (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Lock size={12} />
<span>Privé</span>
</div>
) : (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Globe size={12} />
<span>Public</span>
</div>
)}
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{messagesQuery.isLoading && (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className={`flex ${i % 2 === 0 ? "justify-end" : ""}`}>
<div className="h-14 w-64 rounded-2xl bg-muted animate-pulse" />
</div>
))}
</div>
)}
{!messagesQuery.isLoading && messagesQuery.data?.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center">
<MessageSquare size={32} className="text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">Aucun message pour l'instant. Soyez le premier à écrire !</p>
</div>
)}
{messagesQuery.data?.map((msg: Message) => {
const isMe = msg.auteurId === user?.id;
return (
<div key={msg.id} className={`flex ${isMe ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[70%] ${isMe ? "items-end" : "items-start"} flex flex-col gap-1`}>
{!isMe && (
<span className="text-xs font-medium text-muted-foreground px-1">{msg.auteurNom}</span>
)}
<div className={`px-4 py-3 rounded-2xl text-sm ${
isMe
? "bg-primary text-white rounded-br-sm"
: "bg-muted text-foreground rounded-bl-sm"
}`}>
{msg.contenu}
</div>
<span className="text-xs text-muted-foreground px-1">
{new Date(msg.createdAt).toLocaleString("fr-FR", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "short" })}
</span>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
{/* Zone de saisie */}
<div className="px-6 py-4 border-t border-border">
<form onSubmit={handleSend} className="flex gap-3">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Écrivez votre message..."
className="flex-1 px-4 py-2.5 text-sm bg-background border border-border rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all"
/>
<button
type="submit"
disabled={!newMessage.trim() || sendMutation.isPending}
className="px-4 py-2.5 rounded-xl bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm font-medium"
>
<Send size={15} />
Envoyer
</button>
</form>
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,417 @@
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { useState } from "react";
import { Link } from "wouter";
import {
ArrowLeft, Users, Send, Plus, CheckCircle, Clock,
MessageSquare, Building2, Globe, Lock, ChevronDown, ChevronUp, X
} from "lucide-react";
import { toast } from "sonner";
// ─── Types ────────────────────────────────────────────────────────────────────
type Demande = {
id: number;
demandeurId: number;
demandeurNom: string;
demandeurEmail?: string | null;
etablissementDemandeurNom?: string | null;
sujet: string;
message: string;
statut: string;
createdAt: Date;
canalId?: number | null;
};
type UserItem = {
id: number;
name: string | null;
firstName?: string | null;
lastName?: string | null;
email?: string | null;
};
// ─── Modal de création de canal ───────────────────────────────────────────────
function CreateCanalModal({
demande,
onClose,
onSuccess,
}: {
demande: Demande;
onClose: () => void;
onSuccess: () => void;
}) {
const [titre, setTitre] = useState(`Mise en relation — ${demande.sujet}`);
const [visibilite, setVisibilite] = useState<"public" | "prive">("prive");
const [type, setType] = useState<"mise_en_relation_public" | "mise_en_relation_prive">("mise_en_relation_prive");
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([demande.demandeurId]);
const usersQuery = trpc.admin.users.useQuery();
const utils = trpc.useUtils();
const createMutation = trpc.canaux.create.useMutation({
onSuccess: () => {
toast.success("Canal de discussion créé avec succès.");
utils.miseEnRelation.all.invalidate();
onSuccess();
onClose();
},
onError: (err) => toast.error("Erreur : " + err.message),
});
const toggleUser = (id: number) => {
setSelectedUserIds((prev) =>
prev.includes(id) ? prev.filter((u) => u !== id) : [...prev, id]
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate({
titre,
description: demande.message,
type,
visibilite,
demandeMiseEnRelationId: demande.id,
membreIds: selectedUserIds,
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card rounded-2xl shadow-2xl max-w-lg w-full border border-border max-h-[90vh] overflow-y-auto">
<div className="flex items-start justify-between px-6 py-5 border-b border-border sticky top-0 bg-card">
<div>
<h2 className="text-base font-bold text-foreground">Créer un canal de discussion</h2>
<p className="text-xs text-muted-foreground mt-0.5">Pour la demande : {demande.sujet}</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground">
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Titre */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Titre du canal</label>
<input
type="text"
value={titre}
onChange={(e) => setTitre(e.target.value)}
className="w-full px-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
required
/>
</div>
{/* Type et visibilité */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Type</label>
<select
value={type}
onChange={(e) => {
const v = e.target.value as typeof type;
setType(v);
setVisibilite(v === "mise_en_relation_public" ? "public" : "prive");
}}
className="w-full px-3 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="mise_en_relation_prive">Groupe privé</option>
<option value="mise_en_relation_public">Canal public</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Visibilité</label>
<div className={`flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm border ${
visibilite === "public" ? "bg-green-500/10 border-green-500/30 text-green-700" : "bg-purple-500/10 border-purple-500/30 text-purple-700"
}`}>
{visibilite === "public" ? <Globe size={14} /> : <Lock size={14} />}
{visibilite === "public" ? "Public" : "Privé"}
</div>
</div>
</div>
{/* Membres */}
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">
Membres invités ({selectedUserIds.length} sélectionné{selectedUserIds.length > 1 ? "s" : ""})
</label>
<div className="border border-border rounded-lg overflow-hidden max-h-48 overflow-y-auto">
{usersQuery.isLoading ? (
<div className="p-4 text-sm text-muted-foreground text-center">Chargement...</div>
) : (
usersQuery.data?.map((u: UserItem) => (
<label
key={u.id}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted cursor-pointer border-b border-border last:border-0"
>
<input
type="checkbox"
checked={selectedUserIds.includes(u.id)}
onChange={() => toggleUser(u.id)}
className="rounded"
/>
<div>
<p className="text-sm font-medium text-foreground">{u.name || `${u.firstName} ${u.lastName}`}</p>
{u.email && <p className="text-xs text-muted-foreground">{u.email}</p>}
</div>
{u.id === demande.demandeurId && (
<span className="ml-auto text-xs bg-blue-500/10 text-blue-700 px-2 py-0.5 rounded-full">Demandeur</span>
)}
</label>
))
)}
</div>
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="flex-1 py-2.5 px-4 rounded-lg text-sm font-medium border border-border text-foreground hover:bg-muted transition-colors">
Annuler
</button>
<button
type="submit"
disabled={createMutation.isPending || !titre.trim()}
className="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Plus size={15} />
{createMutation.isPending ? "Création..." : "Créer le canal"}
</button>
</div>
</form>
</div>
</div>
);
}
// ─── Composant principal ──────────────────────────────────────────────────────
export default function MiseEnRelation() {
const { user } = useAuth();
const isGestionnaire = user?.sonumRole === "gestionnaire" || user?.role === "admin";
const [showForm, setShowForm] = useState(false);
const [sujet, setSujet] = useState("");
const [message, setMessage] = useState("");
const [expandedId, setExpandedId] = useState<number | null>(null);
const [createCanalFor, setCreateCanalFor] = useState<Demande | null>(null);
const utils = trpc.useUtils();
const mesDemandesQuery = trpc.miseEnRelation.mesDemandes.useQuery();
const allDemandesQuery = trpc.miseEnRelation.all.useQuery(undefined, { enabled: isGestionnaire });
const soumettresMutation = trpc.miseEnRelation.soumettre.useMutation({
onSuccess: () => {
toast.success("Votre demande de mise en relation a été transmise aux gestionnaires SONUM.");
setSujet("");
setMessage("");
setShowForm(false);
utils.miseEnRelation.mesDemandes.invalidate();
},
onError: (err) => toast.error("Erreur : " + err.message),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!sujet.trim() || !message.trim()) return;
soumettresMutation.mutate({ sujet, message });
};
const statutBadge = (statut: string) => {
switch (statut) {
case "en_attente": return <span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-700 font-medium">En attente</span>;
case "traite": return <span className="text-xs px-2 py-0.5 rounded-full bg-green-500/10 text-green-700 font-medium">Traité</span>;
case "clos": return <span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground font-medium">Clos</span>;
default: return null;
}
};
const demandes: Demande[] = isGestionnaire ? (allDemandesQuery.data ?? []) : (mesDemandesQuery.data ?? []);
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-10">
<div className="max-w-5xl mx-auto px-4 py-4 flex items-center gap-4">
<Link href="/" className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground">
<ArrowLeft size={18} />
</Link>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<Users size={18} className="text-primary" />
</div>
<div>
<h1 className="text-base font-bold text-foreground">Mise en relation</h1>
<p className="text-xs text-muted-foreground">
{isGestionnaire ? "Gestion des demandes de mise en relation" : "Demandez à être mis en relation avec d'autres établissements"}
</p>
</div>
</div>
{!isGestionnaire && (
<button
onClick={() => setShowForm(!showForm)}
className="ml-auto flex items-center gap-2 px-4 py-2 rounded-xl bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus size={15} />
Nouvelle demande
</button>
)}
</div>
</div>
<div className="max-w-5xl mx-auto px-4 py-6 space-y-6">
{/* Formulaire de demande (utilisateurs) */}
{!isGestionnaire && showForm && (
<div className="bg-card border border-border rounded-2xl p-6">
<h2 className="text-sm font-bold text-foreground mb-4">Nouvelle demande de mise en relation</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Sujet <span className="text-destructive">*</span></label>
<input
type="text"
value={sujet}
onChange={(e) => setSujet(e.target.value)}
placeholder="Ex: Partage d'expérience sur la mise en place d'un DPI"
className="w-full px-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Détail de votre besoin <span className="text-destructive">*</span></label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Décrivez votre besoin en détail pour que les gestionnaires SONUM puissent identifier les établissements pertinents..."
rows={4}
className="w-full px-4 py-2.5 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary resize-none"
required
/>
<p className="text-xs text-muted-foreground mt-1">
Les gestionnaires SONUM recevront votre demande et créeront un espace d'échange avec les établissements concernés.
</p>
</div>
<div className="flex gap-3">
<button type="button" onClick={() => setShowForm(false)} className="flex-1 py-2.5 px-4 rounded-lg text-sm font-medium border border-border text-foreground hover:bg-muted transition-colors">
Annuler
</button>
<button
type="submit"
disabled={!sujet.trim() || !message.trim() || soumettresMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Send size={15} />
{soumettresMutation.isPending ? "Envoi..." : "Envoyer la demande"}
</button>
</div>
</form>
</div>
)}
{/* Liste des demandes */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-bold text-foreground">
{isGestionnaire ? "Toutes les demandes" : "Mes demandes"}
<span className="ml-2 text-xs font-normal text-muted-foreground">({demandes.length})</span>
</h2>
</div>
{demandes.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center border border-dashed border-border rounded-2xl">
<div className="w-12 h-12 rounded-2xl bg-muted flex items-center justify-center mb-3">
<Users size={22} className="text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground">Aucune demande</p>
<p className="text-xs text-muted-foreground mt-1">
{isGestionnaire ? "Aucune demande de mise en relation reçue." : "Vous n'avez pas encore soumis de demande."}
</p>
</div>
)}
<div className="space-y-3">
{demandes.map((demande: Demande) => (
<div key={demande.id} className="bg-card border border-border rounded-xl overflow-hidden">
{/* En-tête de la demande */}
<button
onClick={() => setExpandedId(expandedId === demande.id ? null : demande.id)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Users size={15} className="text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground truncate">{demande.sujet}</p>
<div className="flex items-center gap-3 mt-1">
{isGestionnaire && (
<span className="text-xs text-muted-foreground">
De : <span className="font-medium">{demande.demandeurNom}</span>
{demande.etablissementDemandeurNom && ` — ${demande.etablissementDemandeurNom}`}
</span>
)}
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock size={10} />
{new Date(demande.createdAt).toLocaleDateString("fr-FR")}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
{statutBadge(demande.statut)}
{expandedId === demande.id ? <ChevronUp size={16} className="text-muted-foreground" /> : <ChevronDown size={16} className="text-muted-foreground" />}
</div>
</button>
{/* Détail de la demande */}
{expandedId === demande.id && (
<div className="px-5 pb-5 border-t border-border pt-4">
<p className="text-sm text-foreground whitespace-pre-wrap">{demande.message}</p>
{/* Actions gestionnaire */}
{isGestionnaire && demande.statut === "en_attente" && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Actions</p>
<div className="flex gap-3">
<button
onClick={() => setCreateCanalFor(demande)}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors"
>
<MessageSquare size={14} />
Créer un canal de discussion
</button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Créez un canal public ou privé pour mettre en relation les établissements concernés.
</p>
</div>
)}
{/* Lien vers le canal si traité */}
{demande.canalId && (
<div className="mt-4 pt-4 border-t border-border">
<Link
href="/mes-echanges"
className="flex items-center gap-2 text-sm text-primary hover:underline font-medium"
>
<MessageSquare size={14} />
Accéder au canal de discussion
</Link>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
{/* Modal création canal */}
{createCanalFor && (
<CreateCanalModal
demande={createCanalFor}
onClose={() => setCreateCanalFor(null)}
onSuccess={() => utils.miseEnRelation.all.invalidate()}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
CREATE TABLE `canaux_discussion` (
`id` int AUTO_INCREMENT NOT NULL,
`titre` varchar(255) NOT NULL,
`description` text,
`type` enum('contact_referent','mise_en_relation_public','mise_en_relation_prive') NOT NULL,
`visibilite` enum('public','prive') NOT NULL DEFAULT 'prive',
`etablissementId` int,
`demandeContactId` int,
`demandeMiseEnRelationId` int,
`creePar` int NOT NULL,
`statut` enum('ouvert','ferme') NOT NULL DEFAULT 'ouvert',
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `canaux_discussion_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `demandes_mise_en_relation` (
`id` int AUTO_INCREMENT NOT NULL,
`demandeurId` int NOT NULL,
`demandeurNom` varchar(255) NOT NULL,
`demandeurEmail` varchar(320),
`etablissementDemandeurId` int,
`sujet` varchar(500) NOT NULL,
`message` text NOT NULL,
`statut` enum('en_attente','traite','ferme') NOT NULL DEFAULT 'en_attente',
`canalId` int,
`traitePar` int,
`traiteAt` timestamp,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `demandes_mise_en_relation_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `membres_canaux_discussion` (
`id` int AUTO_INCREMENT NOT NULL,
`canalId` int NOT NULL,
`userId` int NOT NULL,
`role` enum('membre','animateur') NOT NULL DEFAULT 'membre',
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `membres_canaux_discussion_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `messages_canaux` (
`id` int AUTO_INCREMENT NOT NULL,
`canalId` int NOT NULL,
`auteurId` int NOT NULL,
`auteurNom` varchar(255) NOT NULL,
`contenu` text NOT NULL,
`lu` boolean NOT NULL DEFAULT false,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `messages_canaux_id` PRIMARY KEY(`id`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,13 @@
"when": 1776767429233, "when": 1776767429233,
"tag": "0003_dry_dragon_man", "tag": "0003_dry_dragon_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1778682305530,
"tag": "0004_empty_energizer",
"breakpoints": true
} }
] ]
} }

View File

@@ -183,3 +183,60 @@ export const demandesContact = mysqlTable("demandes_contact", {
}); });
export type DemandeContact = typeof demandesContact.$inferSelect; export type DemandeContact = typeof demandesContact.$inferSelect;
// ─── Canaux de Discussion ─────────────────────────────────────────────────────
export const canauxDiscussion = mysqlTable("canaux_discussion", {
id: int("id").autoincrement().primaryKey(),
titre: varchar("titre", { length: 255 }).notNull(),
description: text("description"),
type: mysqlEnum("type", ["contact_referent", "mise_en_relation_public", "mise_en_relation_prive"]).notNull(),
visibilite: mysqlEnum("visibilite", ["public", "prive"]).default("prive").notNull(),
etablissementId: int("etablissementId"),
demandeContactId: int("demandeContactId"),
demandeMiseEnRelationId: int("demandeMiseEnRelationId"),
creePar: int("creePar").notNull(),
statut: mysqlEnum("statut", ["ouvert", "ferme"]).default("ouvert").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type CanalDiscussion = typeof canauxDiscussion.$inferSelect;
// ─── Membres des Canaux ───────────────────────────────────────────────────────
export const membresCanauxDiscussion = mysqlTable("membres_canaux_discussion", {
id: int("id").autoincrement().primaryKey(),
canalId: int("canalId").notNull(),
userId: int("userId").notNull(),
role: mysqlEnum("role", ["membre", "animateur"]).default("membre").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type MembreCanalDiscussion = typeof membresCanauxDiscussion.$inferSelect;
// ─── Messages des Canaux ──────────────────────────────────────────────────────
export const messagesCanaux = mysqlTable("messages_canaux", {
id: int("id").autoincrement().primaryKey(),
canalId: int("canalId").notNull(),
auteurId: int("auteurId").notNull(),
auteurNom: varchar("auteurNom", { length: 255 }).notNull(),
contenu: text("contenu").notNull(),
lu: boolean("lu").default(false).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type MessageCanal = typeof messagesCanaux.$inferSelect;
// ─── Demandes de Mise en Relation ─────────────────────────────────────────────
export const demandesMiseEnRelation = mysqlTable("demandes_mise_en_relation", {
id: int("id").autoincrement().primaryKey(),
demandeurId: int("demandeurId").notNull(),
demandeurNom: varchar("demandeurNom", { length: 255 }).notNull(),
demandeurEmail: varchar("demandeurEmail", { length: 320 }),
etablissementDemandeurId: int("etablissementDemandeurId"),
sujet: varchar("sujet", { length: 500 }).notNull(),
message: text("message").notNull(),
statut: mysqlEnum("statut", ["en_attente", "traite", "ferme"]).default("en_attente").notNull(),
canalId: int("canalId"),
traitePar: int("traitePar"),
traiteAt: timestamp("traiteAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type DemandeMiseEnRelation = typeof demandesMiseEnRelation.$inferSelect;

View File

@@ -3,12 +3,17 @@ import { drizzle } from "drizzle-orm/mysql2";
import { import {
InsertUser, InsertUser,
blocsFonctionnels, blocsFonctionnels,
canauxDiscussion,
consultations, consultations,
demandesContact, demandesContact,
demandesMiseEnRelation,
editeurs, editeurs,
etablissements, etablissements,
logicielsEtablissements, logicielsEtablissements,
membresCanauxDiscussion,
messagesCanaux,
solutions, solutions,
userEtablissements,
users, users,
} from "../drizzle/schema"; } from "../drizzle/schema";
import { ENV } from "./_core/env"; import { ENV } from "./_core/env";
@@ -430,7 +435,7 @@ export async function getDemandeById(id: number) {
// ─── Auth locale ────────────────────────────────────────────────────────────── // ─── Auth locale ──────────────────────────────────────────────────────────────
import { localCredentials, userEtablissements } from "../drizzle/schema"; import { localCredentials } from "../drizzle/schema";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { nanoid } from "nanoid"; 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, setReferentForEtablissement,
setAdherentsForEtablissement, setAdherentsForEtablissement,
updateLocalPassword, updateLocalPassword,
createCanal,
getCanauxForUser,
getMessagesCanal,
sendMessageCanal,
isMemberOfCanal,
getCanalById,
getMembresCanal,
addMembresCanal,
createDemandeMiseEnRelation,
getAllDemandesMiseEnRelation,
getDemandesMiseEnRelationByUser,
traiterDemandeMiseEnRelation,
getDemandeMiseEnRelationById,
updateUser, updateUser,
updateUserCgu, updateUserCgu,
updateUserSonumRole, updateUserSonumRole,
@@ -76,10 +89,88 @@ const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
return next({ ctx }); 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({ export const appRouter = router({
system: systemRouter, system: systemRouter,
canaux: canauxRouter,
miseEnRelation: miseEnRelationRouter,
// ─── Auth ────────────────────────────────────────────────────────────────── // ─── Auth ──────────────────────────────────────────────────────────────────
auth: router({ auth: router({