SPB Git forge

spb/groupe-ka

Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%

feat(communication hub): centre de communication interne — fini les courriels publics

Public : /contact (hub 10 catégories : partenariats, données/API,
investisseurs, carrières, fournisseurs, médias, questions, légal/Loi 25,
sécurité anonyme, autre) + formulaires spécialisés pilotés par le registre
(src/lib/comms/categories.ts), préremplissage KA ID, honeypot + rate-limit
+ uploads contrôlés, référence publique KA-XXX-2026-NNNN, accusé de
réception Resend. contact@/info@/admin@groupe-ka.com retirés de toute
l interface publique (footer, accueil, loi-25, retrait, conditions,
confidentialité, bots, compte, marque, investisseurs, carte Wallet).

Admin : /admin protégé serveur (comptes admin_users distincts des KA ID,
scrypt + JWT 12 h invalidé au changement de mot de passe), RBAC 8 rôles
(super_admin, admin, support, legal, investor_relations, hr, media,
security) appliqué DANS les requêtes SQL + par demande (anti-IDOR, 404),
dashboard KPIs, inbox filtres/recherche/pagination, vue demande complète
(workflow, notes internes, conversation email via Resend, historique,
pièces jointes servies hors public), pipeline candidatures, gestion
équipe + mots de passe. DB data/ka-comms.db (WAL) : contact_submissions,
contact_messages, contact_notes, contact_events, contact_attachments,
contact_counters, admin_users ; priorité intelligente backend ; champs
ai_* prêts pour la classification future.

Testé : 18 scénarios curl (validation, uploads, spam, RBAC, IDOR,
rate-limit, comptes) + parcours Playwright https en prod.
Simon-Pierre Boucher committed 1 mo ago (Aug 26, 2026) parent a6e92da

47 changed files +5,047 −88

added src/app/admin/(panel)/AdminNav.tsx +134 −0
@@ -0,0 +1,134 @@
1 +"use client";
2 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +// Navigation du panel — latérale (desktop) / barre repliable (mobile).
4 +// Le badge de non-lus se rafraîchit par sondage léger (45 s) sur
5 +// /api/admin/badge — l'architecture notifications pourra brancher
6 +// courriel/push/Slack sur le même compteur.
7 +import { useEffect, useState } from "react";
8 +import { usePathname, useRouter } from "next/navigation";
9 +import { ADMIN_ROLES } from "@/lib/comms/categories";
10 +
11 +const LINKS = [
12 + { href: "/admin", label: "Dashboard", exact: true },
13 + { href: "/admin/inbox", label: "Inbox", badge: true },
14 + { href: "/admin/candidatures", label: "Candidatures" },
15 + { href: "/admin/equipe", label: "Équipe & accès" },
16 +];
17 +
18 +export default function AdminNav({
19 + displayName,
20 + username,
21 + roles,
22 + initialUnread,
23 +}: {
24 + displayName: string;
25 + username: string;
26 + roles: string[];
27 + initialUnread: number;
28 +}) {
29 + const pathname = usePathname();
30 + const router = useRouter();
31 + const [unread, setUnread] = useState(initialUnread);
32 + const [open, setOpen] = useState(false);
33 +
34 + useEffect(() => {
35 + setUnread(initialUnread);
36 + }, [initialUnread]);
37 +
38 + useEffect(() => {
39 + const t = setInterval(async () => {
40 + try {
41 + const res = await fetch("/api/admin/badge");
42 + if (res.ok) {
43 + const data = (await res.json()) as { unread: number };
44 + setUnread(data.unread);
45 + }
46 + } catch {
47 + /* réseau : on garde la dernière valeur */
48 + }
49 + }, 45000);
50 + return () => clearInterval(t);
51 + }, []);
52 +
53 + async function logout() {
54 + await fetch("/api/admin/logout", { method: "POST" }).catch(() => {});
55 + router.replace("/admin/connexion");
56 + router.refresh();
57 + }
58 +
59 + const nav = (
60 + <>
61 + <nav className="mt-6 flex flex-col gap-1" aria-label="Navigation admin">
62 + {LINKS.map((l) => {
63 + const active = l.exact ? pathname === l.href : pathname.startsWith(l.href);
64 + return (
65 + <a
66 + key={l.href}
67 + href={l.href}
68 + className={`adm-nav-link ${active ? "adm-nav-link--active" : ""}`}
69 + >
70 + {l.label}
71 + {l.badge && unread > 0 ? (
72 + <span
73 + className={`gk-mono rounded-full px-2 py-[1px] text-[10.5px] font-bold ${
74 + active ? "bg-ink text-lime" : "bg-lime text-ink"
75 + }`}
76 + >
77 + {unread}
78 + </span>
79 + ) : null}
80 + </a>
81 + );
82 + })}
83 + </nav>
84 + <div className="mt-auto border-t border-[rgba(245,243,238,0.14)] pt-4">
85 + <p className="gk-display text-[14px] font-bold text-paper">{displayName}</p>
86 + <p className="gk-mono mt-[2px] text-[10px] tracking-[0.08em] text-[rgba(245,243,238,0.5)] uppercase">
87 + @{username} · {roles.map((r) => ADMIN_ROLES[r] ?? r).join(" · ")}
88 + </p>
89 + <div className="mt-3 flex items-center gap-4">
90 + <a
91 + href="/contact"
92 + target="_blank"
93 + className="gk-mono text-[10px] font-bold tracking-[0.08em] text-[rgba(245,243,238,0.55)] uppercase underline-offset-4 hover:text-lime hover:underline"
94 + >
95 + Voir /contact ↗
96 + </a>
97 + <button
98 + onClick={logout}
99 + className="gk-mono cursor-pointer text-[10px] font-bold tracking-[0.08em] text-[rgba(245,243,238,0.55)] uppercase underline-offset-4 hover:text-lime hover:underline"
100 + >
101 + Déconnexion
102 + </button>
103 + </div>
104 + </div>
105 + </>
106 + );
107 +
108 + return (
109 + <aside className="flex-none bg-ink lg:sticky lg:top-0 lg:flex lg:h-[100dvh] lg:w-[248px] lg:flex-col lg:px-5 lg:py-6">
110 + {/* barre mobile */}
111 + <div className="flex items-center justify-between px-4 py-4 lg:px-0 lg:py-0">
112 + <a href="/admin" className="gk-display text-[19px] font-bold tracking-[-0.03em] text-paper no-underline">
113 + Groupe{" "}
114 + <span className="inline-block -rotate-2 rounded-md bg-lime px-[6px] pb-[2px] text-ink">
115 + KA
116 + </span>{" "}
117 + <span className="gk-mono ml-1 align-middle text-[9px] font-bold tracking-[0.24em] text-[rgba(245,243,238,0.5)] uppercase">
118 + Admin
119 + </span>
120 + </a>
121 + <button
122 + className="gk-mono cursor-pointer rounded-lg border border-[rgba(245,243,238,0.3)] px-3 py-2 text-[10px] font-bold tracking-[0.1em] text-paper uppercase lg:hidden"
123 + onClick={() => setOpen((o) => !o)}
124 + aria-expanded={open}
125 + >
126 + Menu{unread > 0 ? ` · ${unread}` : ""}
127 + </button>
128 + </div>
129 + <div className={`${open ? "flex" : "hidden"} flex-col px-4 pb-6 lg:flex lg:flex-1 lg:px-0 lg:pb-0`}>
130 + {nav}
131 + </div>
132 + </aside>
133 + );
134 +}
added src/app/admin/(panel)/candidatures/page.tsx +99 −0
@@ -0,0 +1,99 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /admin/candidatures — le pipeline RH : les demandes « carrieres » vues
3 +// comme un processus d'embauche (nouvelle → à analyser → entrevue → offre →
4 +// embauchée / refusée), avec compteur par étape et liste filtrable.
5 +import { redirect } from "next/navigation";
6 +import { getAdminSession, canViewCategory } from "@/lib/comms/admin-auth";
7 +import { listSubmissions } from "@/lib/comms/queries";
8 +import { JOB_STATUSES, PRIORITIES } from "@/lib/comms/categories";
9 +
10 +export const dynamic = "force-dynamic";
11 +
12 +function fmtDate(iso: string): string {
13 + return iso.slice(0, 10);
14 +}
15 +
16 +export default async function CandidaturesPage({
17 + searchParams,
18 +}: {
19 + searchParams: Promise<Record<string, string | string[] | undefined>>;
20 +}) {
21 + const session = await getAdminSession();
22 + if (!session) redirect("/admin/connexion");
23 + if (!canViewCategory(session, "carrieres"))
24 + return (
25 + <p className="gk-mono mt-10 text-[12px] text-ink-3">
26 + Votre rôle ne donne pas accès aux candidatures (rôle « Ressources humaines » requis).
27 + </p>
28 + );
29 + const sp = await searchParams;
30 + const stage = typeof sp.stage === "string" && JOB_STATUSES[sp.stage] ? sp.stage : "";
31 +
32 + const { rows: all } = listSubmissions(session, {
33 + category: "carrieres",
34 + view: "actives",
35 + page: 1,
36 + });
37 + const byStage = new Map<string, number>();
38 + for (const r of all) byStage.set(r.status, (byStage.get(r.status) ?? 0) + 1);
39 + const rows = stage ? all.filter((r) => r.status === stage) : all;
40 +
41 + return (
42 + <>
43 + <p className="kicker">Candidatures</p>
44 + <h1 className="gk-display mt-2 text-[clamp(22px,3vw,30px)] font-bold tracking-[-0.03em]">
45 + Pipeline d&apos;embauche · {all.length} candidature{all.length > 1 ? "s" : ""}
46 + </h1>
47 +
48 + <div className="mt-6 flex flex-wrap gap-2">
49 + <a href="/admin/candidatures" className={`adm-chip no-underline ${!stage ? "adm-chip--lime" : ""}`}>
50 + Toutes · {all.length}
51 + </a>
52 + {Object.entries(JOB_STATUSES).map(([k, v]) => (
53 + <a
54 + key={k}
55 + href={`/admin/candidatures?stage=${k}`}
56 + className={`adm-chip no-underline ${stage === k ? "adm-chip--lime" : ""}`}
57 + >
58 + {v} · {byStage.get(k) ?? 0}
59 + </a>
60 + ))}
61 + </div>
62 +
63 + <div className="gk-card mt-6 overflow-hidden !shadow-none">
64 + {rows.length === 0 ? (
65 + <p className="gk-mono p-6 text-[12px] text-ink-3">Aucune candidature à cette étape.</p>
66 + ) : (
67 + rows.map((r) => (
68 + <a
69 + key={r.id}
70 + href={`/admin/demande/${r.reference}`}
71 + className={`adm-row ${r.is_read ? "" : "adm-row--unread"}`}
72 + >
73 + <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
74 + <span className="gk-mono text-[11px] font-bold text-green">{r.reference}</span>
75 + <span className="adm-chip adm-chip--ghost !text-[9px]">
76 + {JOB_STATUSES[r.status] ?? r.status}
77 + </span>
78 + <span className={`adm-chip adm-chip--${r.priority} !text-[9px]`}>
79 + {PRIORITIES[r.priority as keyof typeof PRIORITIES] ?? r.priority}
80 + </span>
81 + <span className="gk-mono ml-auto text-[10.5px] text-ink-3">{fmtDate(r.created_at)}</span>
82 + </div>
83 + <p className="mt-1 truncate text-[13.5px]">
84 + <strong>{[r.first_name, r.last_name].filter(Boolean).join(" ") || r.email}</strong>
85 + {r.job_title ? ` — ${r.job_title}` : ""}
86 + {r.sub_category?.startsWith("Candidature spontanée") ? " · spontanée" : ""}
87 + {r.assignee ? <span className="gk-mono text-[11px] text-ink-3"> · suivi : {r.assignee}</span> : null}
88 + </p>
89 + </a>
90 + ))
91 + )}
92 + </div>
93 + <p className="gk-mono mt-4 text-[10.5px] leading-relaxed text-ink-3">
94 + Les CV et documents personnels sont accessibles depuis chaque fiche,
95 + réservés aux rôles autorisés, et servis hors du dossier public.
96 + </p>
97 + </>
98 + );
99 +}
added src/app/admin/(panel)/demande/[ref]/SubmissionActions.tsx +232 −0
@@ -0,0 +1,232 @@
1 +"use client";
2 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +// Workflow d'une demande (client) : statut, priorité, assignation,
4 +// confidentialité, tags, note interne, réponse au demandeur, archive/spam.
5 +// Chaque action passe par l'API admin (revalidation RBAC serveur) puis
6 +// rafraîchit la page serveur — pas d'état dupliqué.
7 +import { useState } from "react";
8 +import { useRouter } from "next/navigation";
9 +import { PRIORITIES, CONFIDENTIALITY_LEVELS } from "@/lib/comms/categories";
10 +
11 +type Props = {
12 + id: number;
13 + statuses: Record<string, string>;
14 + status: string;
15 + priority: string;
16 + confidentiality: string;
17 + assignedTo: number | null;
18 + tags: string[];
19 + admins: { id: number; name: string }[];
20 + isArchived: boolean;
21 + isSpam: boolean;
22 + hasEmail: boolean;
23 +};
24 +
25 +export default function SubmissionActions(p: Props) {
26 + const router = useRouter();
27 + const [busy, setBusy] = useState(false);
28 + const [error, setError] = useState("");
29 + const [note, setNote] = useState("");
30 + const [reply, setReply] = useState("");
31 + const [tagsText, setTagsText] = useState(p.tags.join(", "));
32 + const [sentInfo, setSentInfo] = useState("");
33 +
34 + async function call(url: string, body: unknown): Promise<boolean> {
35 + setBusy(true);
36 + setError("");
37 + try {
38 + const res = await fetch(url, {
39 + method: url.includes("/notes") || url.includes("/reply") ? "POST" : "PATCH",
40 + headers: { "Content-Type": "application/json" },
41 + body: JSON.stringify(body),
42 + });
43 + const data = (await res.json().catch(() => ({}))) as { error?: string; sent?: boolean };
44 + if (!res.ok) {
45 + setError(data.error ?? "Action refusée.");
46 + setBusy(false);
47 + return false;
48 + }
49 + if (data.sent === false)
50 + setSentInfo("Message enregistré, mais l'envoi courriel a échoué — il reste dans la conversation.");
51 + router.refresh();
52 + setBusy(false);
53 + return true;
54 + } catch {
55 + setError("Réseau indisponible.");
56 + setBusy(false);
57 + return false;
58 + }
59 + }
60 +
61 + const base = `/api/admin/submissions/${p.id}`;
62 + const sel = "field cursor-pointer !py-[8px] !text-[13px]";
63 +
64 + return (
65 + <div className="flex flex-col gap-5">
66 + <div className="grid grid-cols-2 gap-3">
67 + <div>
68 + <label className="klabel mb-[4px] block">Statut</label>
69 + <select
70 + className={sel}
71 + value={p.status}
72 + disabled={busy}
73 + onChange={(e) => call(base, { status: e.target.value })}
74 + >
75 + {Object.entries(p.statuses).map(([k, v]) => (
76 + <option key={k} value={k}>{v}</option>
77 + ))}
78 + </select>
79 + </div>
80 + <div>
81 + <label className="klabel mb-[4px] block">Priorité</label>
82 + <select
83 + className={sel}
84 + value={p.priority}
85 + disabled={busy}
86 + onChange={(e) => call(base, { priority: e.target.value })}
87 + >
88 + {Object.entries(PRIORITIES).map(([k, v]) => (
89 + <option key={k} value={k}>{v}</option>
90 + ))}
91 + </select>
92 + </div>
93 + <div>
94 + <label className="klabel mb-[4px] block">Assignée à</label>
95 + <select
96 + className={sel}
97 + value={p.assignedTo ?? ""}
98 + disabled={busy}
99 + onChange={(e) =>
100 + call(base, { assigned_to: e.target.value ? Number(e.target.value) : null })
101 + }
102 + >
103 + <option value="">Personne</option>
104 + {p.admins.map((a) => (
105 + <option key={a.id} value={a.id}>{a.name}</option>
106 + ))}
107 + </select>
108 + </div>
109 + <div>
110 + <label className="klabel mb-[4px] block">Confidentialité</label>
111 + <select
112 + className={sel}
113 + value={p.confidentiality}
114 + disabled={busy}
115 + onChange={(e) => call(base, { confidentiality: e.target.value })}
116 + >
117 + {Object.entries(CONFIDENTIALITY_LEVELS).map(([k, v]) => (
118 + <option key={k} value={k}>{v}</option>
119 + ))}
120 + </select>
121 + </div>
122 + </div>
123 +
124 + <div>
125 + <label className="klabel mb-[4px] block" htmlFor="w-tags">Tags (séparés par des virgules)</label>
126 + <div className="flex gap-2">
127 + <input
128 + id="w-tags"
129 + className="field !py-[8px] !text-[13px]"
130 + value={tagsText}
131 + onChange={(e) => setTagsText(e.target.value)}
132 + placeholder="ex. à-suivre, q1-2027"
133 + />
134 + <button
135 + className="btn btn-ghost !min-h-[38px] !px-4 !text-[12.5px]"
136 + disabled={busy}
137 + onClick={() =>
138 + call(base, { tags: tagsText.split(",").map((t) => t.trim()).filter(Boolean) })
139 + }
140 + >
141 + OK
142 + </button>
143 + </div>
144 + </div>
145 +
146 + <div className="flex flex-wrap gap-2">
147 + <button
148 + className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"
149 + disabled={busy}
150 + onClick={() => call(base, { is_archived: !p.isArchived })}
151 + >
152 + {p.isArchived ? "Désarchiver" : "Archiver"}
153 + </button>
154 + <button
155 + className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"
156 + disabled={busy}
157 + onClick={() => call(base, { is_spam: !p.isSpam })}
158 + >
159 + {p.isSpam ? "Non-spam" : "Marquer spam"}
160 + </button>
161 + <button
162 + className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"
163 + disabled={busy}
164 + onClick={() => call(base, { is_read: false })}
165 + >
166 + Marquer non lue
167 + </button>
168 + </div>
169 +
170 + <div className="rule-ink pt-4">
171 + <label className="klabel mb-[4px] block" htmlFor="w-note">
172 + Note interne (jamais visible du demandeur)
173 + </label>
174 + <textarea
175 + id="w-note"
176 + rows={3}
177 + className="field resize-y !text-[13px]"
178 + value={note}
179 + onChange={(e) => setNote(e.target.value)}
180 + />
181 + <button
182 + className="btn btn-ghost mt-2 !min-h-[36px] !px-4 !text-[12.5px]"
183 + disabled={busy || !note.trim()}
184 + onClick={async () => {
185 + if (await call(`${base}/notes`, { body: note })) setNote("");
186 + }}
187 + >
188 + Ajouter la note
189 + </button>
190 + </div>
191 +
192 + <div className="rule-ink pt-4">
193 + <label className="klabel mb-[4px] block" htmlFor="w-reply">
194 + Répondre au demandeur {p.hasEmail ? "(courriel via la conversation)" : ""}
195 + </label>
196 + {p.hasEmail ? (
197 + <>
198 + <textarea
199 + id="w-reply"
200 + rows={5}
201 + className="field resize-y !text-[13px]"
202 + value={reply}
203 + onChange={(e) => setReply(e.target.value)}
204 + placeholder="Votre réponse — elle est enregistrée dans la conversation et envoyée par courriel avec la référence."
205 + />
206 + <button
207 + className="btn btn-primary mt-2 !min-h-[38px] !px-5 !text-[13px]"
208 + disabled={busy || !reply.trim()}
209 + onClick={async () => {
210 + if (await call(`${base}/reply`, { body: reply })) setReply("");
211 + }}
212 + >
213 + Envoyer la réponse
214 + </button>
215 + </>
216 + ) : (
217 + <p className="gk-mono text-[11px] leading-relaxed text-ink-3">
218 + Soumission anonyme ou sans courriel — aucune réponse directe
219 + possible. Utilisez une note interne.
220 + </p>
221 + )}
222 + </div>
223 +
224 + {sentInfo ? (
225 + <p className="gk-mono rounded-lg border-[1.5px] border-amber bg-amber-soft px-3 py-2 text-[11px] font-bold text-[#7a4d0d]">{sentInfo}</p>
226 + ) : null}
227 + {error ? (
228 + <p className="gk-mono rounded-lg border-[1.5px] border-danger bg-[var(--danger-soft)] px-3 py-2 text-[11px] font-bold text-danger">{error}</p>
229 + ) : null}
230 + </div>
231 + );
232 +}
added src/app/admin/(panel)/demande/[ref]/page.tsx +287 −0
@@ -0,0 +1,287 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /admin/demande/[ref] — la vue complète d'une demande : identité, contenu,
3 +// métadonnées du formulaire, pièces jointes, workflow, notes internes,
4 +// conversation et historique d'activité. L'ouverture marque la demande lue.
5 +// Contrôle d'accès PAR demande : un admin sans droit reçoit un 404.
6 +import { notFound, redirect } from "next/navigation";
7 +import { getAdminSession, canViewSubmission } from "@/lib/comms/admin-auth";
8 +import {
9 + findSubmissionByRef,
10 + attachmentsOf,
11 + notesOf,
12 + messagesOf,
13 + eventsOf,
14 + listAdmins,
15 + updateSubmission,
16 +} from "@/lib/comms/db";
17 +import {
18 + categoryBySlug,
19 + statusesFor,
20 + PRIORITIES,
21 + CONFIDENTIALITY_LEVELS,
22 +} from "@/lib/comms/categories";
23 +import SubmissionActions from "./SubmissionActions";
24 +
25 +export const dynamic = "force-dynamic";
26 +
27 +function fmtDate(iso: string): string {
28 + return iso.replace("T", " ").slice(0, 16);
29 +}
30 +
31 +function fmtBytes(n: number): string {
32 + if (n < 1024) return `${n} o`;
33 + if (n < 1024 * 1024) return `${Math.round(n / 1024)} Ko`;
34 + return `${(n / 1024 / 1024).toFixed(1)} Mo`;
35 +}
36 +
37 +function Meta({ label, value }: { label: string; value: React.ReactNode }) {
38 + if (value == null || value === "") return null;
39 + return (
40 + <div className="min-w-0">
41 + <p className="klabel">{label}</p>
42 + <p className="mt-[2px] text-[13.5px] break-words">{value}</p>
43 + </div>
44 + );
45 +}
46 +
47 +export default async function SubmissionPage({
48 + params,
49 +}: {
50 + params: Promise<{ ref: string }>;
51 +}) {
52 + const session = await getAdminSession();
53 + if (!session) redirect("/admin/connexion");
54 + const ref = decodeURIComponent((await params).ref).slice(0, 40);
55 + const sub = findSubmissionByRef(ref);
56 + if (!sub || !canViewSubmission(session, sub)) notFound();
57 +
58 + if (!sub.is_read) updateSubmission(sub.id, { is_read: 1 });
59 +
60 + const cat = categoryBySlug(sub.category);
61 + const atts = attachmentsOf(sub.id);
62 + const notes = notesOf(sub.id);
63 + const messages = messagesOf(sub.id);
64 + const events = eventsOf(sub.id);
65 + const admins = listAdmins().filter((a) => a.active);
66 + const metadata = (() => {
67 + try {
68 + return JSON.parse(sub.metadata ?? "{}") as Record<string, unknown>;
69 + } catch {
70 + return {};
71 + }
72 + })();
73 + const tags = (() => {
74 + try {
75 + const t = JSON.parse(sub.tags ?? "[]") as unknown;
76 + return Array.isArray(t) ? t.filter((x): x is string => typeof x === "string") : [];
77 + } catch {
78 + return [];
79 + }
80 + })();
81 + // libellés humains des clés metadata, depuis le registre
82 + const fieldLabel = (key: string): string =>
83 + cat?.fields.find((f) => f.name === key)?.label ?? key;
84 + const displayName = sub.is_anonymous
85 + ? "Anonyme"
86 + : [sub.first_name, sub.last_name].filter(Boolean).join(" ") || sub.email || "—";
87 +
88 + return (
89 + <>
90 + <nav aria-label="Fil d'Ariane">
91 + <a href="/admin/inbox" className="gk-mono text-[11px] font-bold tracking-[0.1em] text-ink-3 uppercase no-underline hover:text-green">
92 + ← Inbox
93 + </a>
94 + </nav>
95 +
96 + <div className="mt-4 flex flex-wrap items-center gap-3">
97 + <h1 className="gk-mono text-[clamp(17px,2.5vw,22px)] font-bold tracking-[0.06em] text-green">
98 + {sub.reference}
99 + </h1>
100 + <span className="adm-chip">{cat?.title ?? sub.category}</span>
101 + <span className={`adm-chip adm-chip--${sub.priority}`}>
102 + {PRIORITIES[sub.priority as keyof typeof PRIORITIES] ?? sub.priority}
103 + </span>
104 + <span className="adm-chip adm-chip--ghost">
105 + {statusesFor(sub.category)[sub.status] ?? sub.status}
106 + </span>
107 + <span className="adm-chip adm-chip--ghost">
108 + Confidentialité : {CONFIDENTIALITY_LEVELS[sub.confidentiality] ?? sub.confidentiality}
109 + </span>
110 + {sub.is_spam ? <span className="adm-chip adm-chip--urgente">Spam</span> : null}
111 + {sub.is_archived ? <span className="adm-chip adm-chip--ghost">Archivée</span> : null}
112 + {tags.map((t) => (
113 + <span key={t} className="adm-chip adm-chip--lime">#{t}</span>
114 + ))}
115 + <span className="gk-mono ml-auto text-[11px] text-ink-3">
116 + Reçue le {fmtDate(sub.created_at)}
117 + {sub.resolved_at ? ` · résolue le ${fmtDate(sub.resolved_at)}` : ""}
118 + </span>
119 + </div>
120 +
121 + <div className="mt-6 grid gap-6 xl:grid-cols-[1fr_360px]">
122 + {/* colonne principale : identité + contenu + conversation + historique */}
123 + <div className="flex min-w-0 flex-col gap-6">
124 + <section className="gk-card p-5 !shadow-none">
125 + <p className="kicker">Identité</p>
126 + <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
127 + <Meta label="Nom" value={displayName} />
128 + <Meta
129 + label="Courriel"
130 + value={sub.email ? <a className="text-green underline underline-offset-2" href={`mailto:${sub.email}`}>{sub.email}</a> : sub.is_anonymous ? "— (anonyme)" : null}
131 + />
132 + <Meta label="Téléphone" value={sub.phone} />
133 + <Meta label="Organisation" value={sub.organization} />
134 + <Meta label="Rôle / poste" value={sub.job_title} />
135 + <Meta
136 + label="KA ID"
137 + value={sub.ka_id ? <span className="gk-mono font-bold">{sub.ka_id}</span> : null}
138 + />
139 + <Meta
140 + label="Site web"
141 + value={sub.website ? <a className="text-green underline underline-offset-2" href={sub.website} target="_blank" rel="noopener noreferrer">{sub.website}</a> : null}
142 + />
143 + <Meta
144 + label="LinkedIn"
145 + value={sub.linkedin_url ? <a className="text-green underline underline-offset-2" href={sub.linkedin_url} target="_blank" rel="noopener noreferrer">{sub.linkedin_url}</a> : null}
146 + />
147 + <Meta
148 + label="GitHub / portfolio"
149 + value={sub.github_url ? <a className="text-green underline underline-offset-2" href={sub.github_url} target="_blank" rel="noopener noreferrer">{sub.github_url}</a> : null}
150 + />
151 + </div>
152 + </section>
153 +
154 + <section className="gk-card p-5 !shadow-none">
155 + <p className="kicker">Contenu de la demande</p>
156 + <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
157 + <Meta label="Sous-catégorie" value={sub.sub_category} />
158 + <Meta label="Site concerné" value={sub.site_concerned} />
159 + <Meta label="Sujet" value={sub.subject} />
160 + <Meta label="Source" value={sub.source} />
161 + </div>
162 + {sub.message ? (
163 + <div className="mt-5 border-l-2 border-green bg-surface-2 px-4 py-3">
164 + <p className="text-[14px] leading-relaxed whitespace-pre-wrap">{sub.message}</p>
165 + </div>
166 + ) : null}
167 + {Object.keys(metadata).length ? (
168 + <div className="mt-5">
169 + <p className="klabel">Métadonnées du formulaire</p>
170 + <div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
171 + {Object.entries(metadata).map(([k, v]) => (
172 + <Meta
173 + key={k}
174 + label={fieldLabel(k)}
175 + value={v === true ? "Oui" : String(v)}
176 + />
177 + ))}
178 + </div>
179 + </div>
180 + ) : null}
181 + {atts.length ? (
182 + <div className="mt-5">
183 + <p className="klabel">Pièces jointes</p>
184 + <ul className="mt-2 flex flex-wrap gap-2">
185 + {atts.map((a) => (
186 + <li key={a.id}>
187 + <a
188 + href={`/api/admin/attachments/${a.id}`}
189 + className="gk-mono inline-flex items-center gap-2 rounded-lg border-[1.5px] border-ink bg-surface px-3 py-2 text-[11.5px] font-bold no-underline hover:bg-lime-soft"
190 + >
191 + ⭳ {a.original_name}
192 + <span className="font-normal text-ink-3">{fmtBytes(a.size)}</span>
193 + </a>
194 + </li>
195 + ))}
196 + </ul>
197 + </div>
198 + ) : null}
199 + </section>
200 +
201 + <section className="gk-card p-5 !shadow-none">
202 + <p className="kicker">Conversation ({messages.length})</p>
203 + {messages.length === 0 ? (
204 + <p className="gk-mono mt-3 text-[11.5px] text-ink-3">
205 + Aucun échange pour l&apos;instant — répondez depuis le panneau de droite.
206 + </p>
207 + ) : (
208 + <ul className="mt-4 flex flex-col gap-3">
209 + {messages.map((m) => (
210 + <li
211 + key={m.id}
212 + className={`max-w-[85%] rounded-xl border-[1.5px] px-4 py-3 ${
213 + m.sender_type === "admin"
214 + ? "self-end border-ink bg-ink text-paper"
215 + : "self-start border-line bg-surface-2"
216 + }`}
217 + >
218 + <p className={`gk-mono text-[9.5px] font-bold tracking-[0.1em] uppercase ${m.sender_type === "admin" ? "text-lime" : "text-ink-3"}`}>
219 + {m.sender_type === "admin" ? (m.author ?? "Équipe KA") : displayName} · {fmtDate(m.created_at)} · {m.channel}
220 + </p>
221 + <p className="mt-1 text-[13.5px] leading-relaxed whitespace-pre-wrap">{m.body}</p>
222 + </li>
223 + ))}
224 + </ul>
225 + )}
226 + </section>
227 +
228 + <section className="gk-card p-5 !shadow-none">
229 + <p className="kicker">Notes internes ({notes.length})</p>
230 + {notes.length === 0 ? (
231 + <p className="gk-mono mt-3 text-[11.5px] text-ink-3">Aucune note.</p>
232 + ) : (
233 + <ul className="mt-4 flex flex-col gap-3">
234 + {notes.map((n) => (
235 + <li key={n.id} className="border-l-2 border-amber bg-amber-soft px-4 py-3">
236 + <p className="gk-mono text-[9.5px] font-bold tracking-[0.1em] text-[#7a4d0d] uppercase">
237 + {n.author} · {fmtDate(n.created_at)} · interne
238 + </p>
239 + <p className="mt-1 text-[13.5px] leading-relaxed whitespace-pre-wrap">{n.body}</p>
240 + </li>
241 + ))}
242 + </ul>
243 + )}
244 + </section>
245 +
246 + <section className="gk-card p-5 !shadow-none">
247 + <p className="kicker">Historique d&apos;activité</p>
248 + <ul className="mt-4 flex flex-col">
249 + {events.map((e) => (
250 + <li key={e.id} className="flex items-baseline gap-3 border-t border-line py-2 first:border-t-0">
251 + <span className="gk-mono flex-none text-[10px] text-ink-3">{fmtDate(e.created_at)}</span>
252 + <span className="adm-chip adm-chip--ghost flex-none !text-[9px]">{e.kind}</span>
253 + <span className="min-w-0 text-[12.5px] text-ink-2">
254 + {e.detail}
255 + {e.author ? <span className="text-ink-3"> — {e.author}</span> : null}
256 + </span>
257 + </li>
258 + ))}
259 + </ul>
260 + </section>
261 + </div>
262 +
263 + {/* colonne workflow */}
264 + <aside className="h-fit xl:sticky xl:top-8">
265 + <div className="gk-card p-5">
266 + <p className="kicker">Workflow</p>
267 + <div className="mt-4">
268 + <SubmissionActions
269 + id={sub.id}
270 + statuses={statusesFor(sub.category)}
271 + status={sub.status}
272 + priority={sub.priority}
273 + confidentiality={sub.confidentiality}
274 + assignedTo={sub.assigned_to}
275 + tags={tags}
276 + admins={admins.map((a) => ({ id: a.id, name: a.display_name }))}
277 + isArchived={!!sub.is_archived}
278 + isSpam={!!sub.is_spam}
279 + hasEmail={!!sub.email}
280 + />
281 + </div>
282 + </div>
283 + </aside>
284 + </div>
285 + </>
286 + );
287 +}
added src/app/admin/(panel)/equipe/TeamClient.tsx +293 −0
@@ -0,0 +1,293 @@
1 +"use client";
2 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +// /admin/equipe — composants interactifs : changement de SON mot de passe,
4 +// fiche d'un membre (rôles, activation, réinitialisation) et création de
5 +// compte. Toutes les règles sont revérifiées par l'API côté serveur.
6 +import { useState } from "react";
7 +import { useRouter } from "next/navigation";
8 +import { ADMIN_ROLES } from "@/lib/comms/categories";
9 +
10 +function useApi() {
11 + const router = useRouter();
12 + const [busy, setBusy] = useState(false);
13 + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
14 + async function call(url: string, method: string, body: unknown): Promise<boolean> {
15 + setBusy(true);
16 + setMsg(null);
17 + try {
18 + const res = await fetch(url, {
19 + method,
20 + headers: { "Content-Type": "application/json" },
21 + body: JSON.stringify(body),
22 + });
23 + const data = (await res.json().catch(() => ({}))) as { error?: string };
24 + if (!res.ok) {
25 + setMsg({ ok: false, text: data.error ?? "Action refusée." });
26 + setBusy(false);
27 + return false;
28 + }
29 + router.refresh();
30 + setBusy(false);
31 + return true;
32 + } catch {
33 + setMsg({ ok: false, text: "Réseau indisponible." });
34 + setBusy(false);
35 + return false;
36 + }
37 + }
38 + return { busy, msg, setMsg, call };
39 +}
40 +
41 +function Feedback({ msg }: { msg: { ok: boolean; text: string } | null }) {
42 + if (!msg) return null;
43 + return (
44 + <p
45 + className={`gk-mono mt-3 rounded-lg border-[1.5px] px-3 py-2 text-[11px] font-bold ${
46 + msg.ok
47 + ? "border-green bg-lime-soft text-green"
48 + : "border-danger bg-[var(--danger-soft)] text-danger"
49 + }`}
50 + >
51 + {msg.text}
52 + </p>
53 + );
54 +}
55 +
56 +/* ---------- mon mot de passe ---------- */
57 +
58 +export function ChangeMyPassword() {
59 + const { busy, msg, setMsg, call } = useApi();
60 + const [current, setCurrent] = useState("");
61 + const [next, setNext] = useState("");
62 + const [confirm, setConfirm] = useState("");
63 + return (
64 + <form
65 + className="mt-4"
66 + onSubmit={async (e) => {
67 + e.preventDefault();
68 + if (next !== confirm) {
69 + setMsg({ ok: false, text: "La confirmation ne correspond pas." });
70 + return;
71 + }
72 + if (await call("/api/admin/password", "POST", { current, next })) {
73 + setMsg({ ok: true, text: "Mot de passe changé — vos autres sessions sont déconnectées." });
74 + setCurrent("");
75 + setNext("");
76 + setConfirm("");
77 + }
78 + }}
79 + >
80 + <label className="klabel mb-[4px] block" htmlFor="pw-cur">Mot de passe actuel</label>
81 + <input id="pw-cur" type="password" className="field" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
82 + <div className="mt-3 grid gap-3 sm:grid-cols-2">
83 + <div>
84 + <label className="klabel mb-[4px] block" htmlFor="pw-next">Nouveau (8 car. min.)</label>
85 + <input id="pw-next" type="password" className="field" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} required minLength={8} />
86 + </div>
87 + <div>
88 + <label className="klabel mb-[4px] block" htmlFor="pw-conf">Confirmation</label>
89 + <input id="pw-conf" type="password" className="field" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required minLength={8} />
90 + </div>
91 + </div>
92 + <button type="submit" className="btn btn-primary mt-4 !min-h-[40px] !px-5 !text-[13px]" disabled={busy}>
93 + Changer mon mot de passe
94 + </button>
95 + <Feedback msg={msg} />
96 + </form>
97 + );
98 +}
99 +
100 +/* ---------- fiche membre (super admin) ---------- */
101 +
102 +export function TeamMemberRow({
103 + admin,
104 + self,
105 +}: {
106 + admin: {
107 + id: number;
108 + username: string;
109 + displayName: string;
110 + email: string | null;
111 + roles: string[];
112 + active: boolean;
113 + lastLogin: string | null;
114 + };
115 + self: boolean;
116 +}) {
117 + const { busy, msg, setMsg, call } = useApi();
118 + const [roles, setRoles] = useState<string[]>(admin.roles);
119 + const [reset, setReset] = useState("");
120 + const [open, setOpen] = useState(false);
121 +
122 + function toggleRole(r: string) {
123 + setRoles((cur) => (cur.includes(r) ? cur.filter((x) => x !== r) : [...cur, r]));
124 + }
125 +
126 + return (
127 + <div className={`gk-card p-5 !shadow-none ${admin.active ? "" : "opacity-60"}`}>
128 + <div className="flex flex-wrap items-center gap-3">
129 + <div className="min-w-0">
130 + <p className="gk-display text-[16px] font-bold">
131 + {admin.displayName}
132 + {self ? <span className="gk-mono ml-2 text-[10px] font-bold text-green uppercase">(vous)</span> : null}
133 + </p>
134 + <p className="gk-mono text-[10.5px] text-ink-3">
135 + @{admin.username}
136 + {admin.email ? ` · ${admin.email}` : ""}
137 + {admin.lastLogin ? ` · vu le ${admin.lastLogin.slice(0, 16).replace("T", " ")}` : " · jamais connecté"}
138 + </p>
139 + </div>
140 + <div className="ml-auto flex flex-wrap gap-1">
141 + {admin.roles.map((r) => (
142 + <span key={r} className="adm-chip !text-[9px]">{ADMIN_ROLES[r] ?? r}</span>
143 + ))}
144 + {!admin.active ? <span className="adm-chip adm-chip--urgente !text-[9px]">Désactivé</span> : null}
145 + </div>
146 + <button
147 + className="gk-mono cursor-pointer text-[10.5px] font-bold tracking-[0.08em] text-green uppercase underline-offset-4 hover:underline"
148 + onClick={() => setOpen((o) => !o)}
149 + >
150 + {open ? "Fermer" : "Gérer"}
151 + </button>
152 + </div>
153 +
154 + {open ? (
155 + <div className="rule-hair mt-4 pt-4">
156 + <p className="klabel">Rôles</p>
157 + <div className="mt-2 flex flex-wrap gap-x-5 gap-y-2">
158 + {Object.entries(ADMIN_ROLES).map(([k, v]) => (
159 + <label key={k} className="flex cursor-pointer items-center gap-2 text-[12.5px]">
160 + <input
161 + type="checkbox"
162 + checked={roles.includes(k)}
163 + onChange={() => toggleRole(k)}
164 + className="h-4 w-4 accent-[var(--green)]"
165 + />
166 + {v}
167 + </label>
168 + ))}
169 + </div>
170 + <div className="mt-4 flex flex-wrap items-end gap-3">
171 + <button
172 + className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"
173 + disabled={busy}
174 + onClick={() => call(`/api/admin/team/${admin.id}`, "PATCH", { roles })}
175 + >
176 + Enregistrer les rôles
177 + </button>
178 + <button
179 + className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"
180 + disabled={busy || self}
181 + onClick={() => call(`/api/admin/team/${admin.id}`, "PATCH", { active: !admin.active })}
182 + >
183 + {admin.active ? "Désactiver" : "Réactiver"}
184 + </button>
185 + <div className="flex items-end gap-2">
186 + <div>
187 + <label className="klabel mb-[4px] block" htmlFor={`rs-${admin.id}`}>
188 + Nouveau mot de passe
189 + </label>
190 + <input
191 + id={`rs-${admin.id}`}
192 + type="password"
193 + className="field !py-[7px] !text-[13px]"
194 + value={reset}
195 + onChange={(e) => setReset(e.target.value)}
196 + minLength={8}
197 + autoComplete="new-password"
198 + />
199 + </div>
200 + <button
201 + className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"
202 + disabled={busy || reset.length < 8}
203 + onClick={async () => {
204 + if (await call(`/api/admin/team/${admin.id}`, "PATCH", { password: reset })) {
205 + setReset("");
206 + setMsg({ ok: true, text: "Mot de passe réinitialisé (sessions du compte invalidées)." });
207 + }
208 + }}
209 + >
210 + Réinitialiser
211 + </button>
212 + </div>
213 + </div>
214 + <Feedback msg={msg} />
215 + </div>
216 + ) : null}
217 + </div>
218 + );
219 +}
220 +
221 +/* ---------- création de compte (super admin) ---------- */
222 +
223 +export function CreateAdminForm() {
224 + const { busy, msg, setMsg, call } = useApi();
225 + const [username, setUsername] = useState("");
226 + const [displayName, setDisplayName] = useState("");
227 + const [email, setEmail] = useState("");
228 + const [password, setPassword] = useState("");
229 + const [roles, setRoles] = useState<string[]>(["support"]);
230 +
231 + return (
232 + <form
233 + onSubmit={async (e) => {
234 + e.preventDefault();
235 + if (
236 + await call("/api/admin/team", "POST", {
237 + username,
238 + display_name: displayName,
239 + email: email || undefined,
240 + password,
241 + roles,
242 + })
243 + ) {
244 + setMsg({ ok: true, text: `Compte « ${username} » créé.` });
245 + setUsername("");
246 + setDisplayName("");
247 + setEmail("");
248 + setPassword("");
249 + setRoles(["support"]);
250 + }
251 + }}
252 + >
253 + <div className="grid gap-3 sm:grid-cols-2">
254 + <div>
255 + <label className="klabel mb-[4px] block" htmlFor="na-user">Identifiant</label>
256 + <input id="na-user" className="field" value={username} onChange={(e) => setUsername(e.target.value)} required pattern="[a-z0-9._-]{3,40}" autoCapitalize="none" />
257 + </div>
258 + <div>
259 + <label className="klabel mb-[4px] block" htmlFor="na-name">Nom d&apos;affichage</label>
260 + <input id="na-name" className="field" value={displayName} onChange={(e) => setDisplayName(e.target.value)} required />
261 + </div>
262 + <div>
263 + <label className="klabel mb-[4px] block" htmlFor="na-email">Courriel (facultatif)</label>
264 + <input id="na-email" type="email" className="field" value={email} onChange={(e) => setEmail(e.target.value)} />
265 + </div>
266 + <div>
267 + <label className="klabel mb-[4px] block" htmlFor="na-pass">Mot de passe initial (8 car. min.)</label>
268 + <input id="na-pass" type="password" className="field" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} autoComplete="new-password" />
269 + </div>
270 + </div>
271 + <p className="klabel mt-4">Rôles</p>
272 + <div className="mt-2 flex flex-wrap gap-x-5 gap-y-2">
273 + {Object.entries(ADMIN_ROLES).map(([k, v]) => (
274 + <label key={k} className="flex cursor-pointer items-center gap-2 text-[12.5px]">
275 + <input
276 + type="checkbox"
277 + checked={roles.includes(k)}
278 + onChange={() =>
279 + setRoles((cur) => (cur.includes(k) ? cur.filter((x) => x !== k) : [...cur, k]))
280 + }
281 + className="h-4 w-4 accent-[var(--green)]"
282 + />
283 + {v}
284 + </label>
285 + ))}
286 + </div>
287 + <button type="submit" className="btn btn-primary mt-5 !min-h-[40px] !px-5 !text-[13px]" disabled={busy}>
288 + Créer le compte
289 + </button>
290 + <Feedback msg={msg} />
291 + </form>
292 + );
293 +}
added src/app/admin/(panel)/equipe/page.tsx +77 −0
@@ -0,0 +1,77 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /admin/equipe — gestion des accès du panel. Tout admin peut changer SON
3 +// mot de passe ; la gestion des comptes (création, rôles, activation,
4 +// réinitialisation) est réservée au super administrateur — et l'API le
5 +// revérifie côté serveur.
6 +import { redirect } from "next/navigation";
7 +import { getAdminSession, isSuperAdmin, parseRoles } from "@/lib/comms/admin-auth";
8 +import { listAdmins } from "@/lib/comms/db";
9 +import { ADMIN_ROLES } from "@/lib/comms/categories";
10 +import { ChangeMyPassword, CreateAdminForm, TeamMemberRow } from "./TeamClient";
11 +
12 +export const dynamic = "force-dynamic";
13 +
14 +export default async function EquipePage() {
15 + const session = await getAdminSession();
16 + if (!session) redirect("/admin/connexion");
17 + const superAdmin = isSuperAdmin(session);
18 + const admins = listAdmins();
19 +
20 + return (
21 + <>
22 + <p className="kicker">Équipe & accès</p>
23 + <h1 className="gk-display mt-2 text-[clamp(22px,3vw,30px)] font-bold tracking-[-0.03em]">
24 + Comptes du panel
25 + </h1>
26 + <p className="mt-3 max-w-2xl text-[13.5px] text-ink-2">
27 + Les rôles limitent ce que chaque personne voit : investisseurs, légal
28 + et sécurité ne sont visibles que des rôles dédiés (et du super
29 + administrateur). Le niveau « restreinte » d&apos;une demande resserre
30 + encore l&apos;accès.
31 + </p>
32 +
33 + <section className="gk-card mt-8 max-w-xl p-6 !shadow-none">
34 + <p className="kicker">Mon mot de passe</p>
35 + <ChangeMyPassword />
36 + </section>
37 +
38 + {superAdmin ? (
39 + <>
40 + <div className="sec-head mt-12">
41 + <p className="kicker">Membres ({admins.length})</p>
42 + <span className="sec-index">super admin requis</span>
43 + </div>
44 + <div className="mt-5 flex flex-col gap-4">
45 + {admins.map((a) => (
46 + <TeamMemberRow
47 + key={a.id}
48 + admin={{
49 + id: a.id,
50 + username: a.username,
51 + displayName: a.display_name,
52 + email: a.email,
53 + roles: parseRoles(a.roles),
54 + active: !!a.active,
55 + lastLogin: a.last_login,
56 + }}
57 + self={a.id === session.id}
58 + />
59 + ))}
60 + </div>
61 +
62 + <div className="sec-head mt-12">
63 + <p className="kicker">Ajouter un compte</p>
64 + <span className="sec-index">{Object.keys(ADMIN_ROLES).length} rôles</span>
65 + </div>
66 + <div className="gk-card mt-5 max-w-2xl p-6 !shadow-none">
67 + <CreateAdminForm />
68 + </div>
69 + </>
70 + ) : (
71 + <p className="gk-mono mt-10 text-[11.5px] text-ink-3">
72 + La gestion des comptes est réservée au super administrateur.
73 + </p>
74 + )}
75 + </>
76 + );
77 +}
added src/app/admin/(panel)/inbox/page.tsx +255 −0
@@ -0,0 +1,255 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /admin/inbox — toutes les demandes, centralisées. Filtres (catégorie,
3 +// sous-catégorie, statut, priorité, site, assignation, KA ID, vue) +
4 +// recherche globale + pagination. Le filtrage RBAC est fait dans la requête
5 +// SQL : un admin ne voit jamais une ligne interdite.
6 +import { redirect } from "next/navigation";
7 +import { getAdminSession } from "@/lib/comms/admin-auth";
8 +import { listSubmissions, type InboxFilters } from "@/lib/comms/queries";
9 +import { listAdmins } from "@/lib/comms/db";
10 +import {
11 + CATEGORIES,
12 + categoryBySlug,
13 + PRIORITIES,
14 + SITES_KA,
15 + STATUSES,
16 + JOB_STATUSES,
17 + statusesFor,
18 +} from "@/lib/comms/categories";
19 +
20 +export const dynamic = "force-dynamic";
21 +
22 +const QUICK = [
23 + { label: "Investisseurs", category: "investisseurs" },
24 + { label: "Candidatures", category: "carrieres" },
25 + { label: "Légal", category: "legal" },
26 + { label: "Sécurité", category: "securite" },
27 + { label: "Médias", category: "medias" },
28 +] as const;
29 +
30 +function fmtDate(iso: string): string {
31 + return iso.replace("T", " ").slice(0, 16);
32 +}
33 +
34 +export default async function InboxPage({
35 + searchParams,
36 +}: {
37 + searchParams: Promise<Record<string, string | string[] | undefined>>;
38 +}) {
39 + const session = await getAdminSession();
40 + if (!session) redirect("/admin/connexion");
41 + const sp = await searchParams;
42 + const one = (k: string): string =>
43 + typeof sp[k] === "string" ? (sp[k] as string).slice(0, 200) : "";
44 +
45 + const filters: InboxFilters = {
46 + category: one("category") || undefined,
47 + subCategory: one("sub") || undefined,
48 + status: one("status") || undefined,
49 + priority: one("priority") || undefined,
50 + site: one("site") || undefined,
51 + assigned: one("assigned") || undefined,
52 + kaId: one("ka_id") || undefined,
53 + q: one("q") || undefined,
54 + view: (["actives", "archivees", "spam", "toutes"].includes(one("view"))
55 + ? one("view")
56 + : "actives") as InboxFilters["view"],
57 + page: Math.max(1, Number(one("page")) || 1),
58 + };
59 + const { rows, total } = listSubmissions(session, filters);
60 + const admins = listAdmins().filter((a) => a.active);
61 + const pages = Math.max(1, Math.ceil(total / 40));
62 + const currentCat = filters.category ? categoryBySlug(filters.category) : undefined;
63 + const statusOptions = filters.category
64 + ? statusesFor(filters.category)
65 + : { ...STATUSES, ...JOB_STATUSES };
66 +
67 + const qs = (patch: Record<string, string>) => {
68 + const params = new URLSearchParams();
69 + for (const [k, v] of Object.entries({
70 + category: filters.category ?? "",
71 + sub: filters.subCategory ?? "",
72 + status: filters.status ?? "",
73 + priority: filters.priority ?? "",
74 + site: filters.site ?? "",
75 + assigned: filters.assigned ?? "",
76 + ka_id: filters.kaId ?? "",
77 + q: filters.q ?? "",
78 + view: filters.view ?? "actives",
79 + ...patch,
80 + }))
81 + if (v) params.set(k, v);
82 + const s = params.toString();
83 + return s ? `/admin/inbox?${s}` : "/admin/inbox";
84 + };
85 +
86 + return (
87 + <>
88 + <div className="flex flex-wrap items-end justify-between gap-4">
89 + <div>
90 + <p className="kicker">Inbox</p>
91 + <h1 className="gk-display mt-2 text-[clamp(22px,3vw,30px)] font-bold tracking-[-0.03em]">
92 + {total} demande{total > 1 ? "s" : ""}
93 + {currentCat ? ` · ${currentCat.title}` : ""}
94 + </h1>
95 + </div>
96 + <div className="flex flex-wrap gap-2">
97 + {QUICK.map((qf) => (
98 + <a
99 + key={qf.category}
100 + href={qs({ category: filters.category === qf.category ? "" : qf.category, page: "" })}
101 + className={`adm-chip no-underline ${filters.category === qf.category ? "adm-chip--lime" : ""}`}
102 + >
103 + {qf.label}
104 + </a>
105 + ))}
106 + </div>
107 + </div>
108 +
109 + {/* filtres — formulaire GET simple, robuste, sans JS */}
110 + <form method="get" action="/admin/inbox" className="gk-card mt-6 grid grid-cols-2 gap-3 p-4 !shadow-none sm:grid-cols-3 xl:grid-cols-6">
111 + <div className="col-span-2 sm:col-span-3 xl:col-span-2">
112 + <label className="klabel mb-[4px] block" htmlFor="f-q">Recherche globale</label>
113 + <input id="f-q" name="q" defaultValue={filters.q ?? ""} placeholder="référence, nom, organisation, sujet…" className="field !py-[8px]" />
114 + </div>
115 + <div>
116 + <label className="klabel mb-[4px] block" htmlFor="f-cat">Catégorie</label>
117 + <select id="f-cat" name="category" defaultValue={filters.category ?? ""} className="field cursor-pointer !py-[8px]">
118 + <option value="">Toutes</option>
119 + {CATEGORIES.map((c) => (
120 + <option key={c.slug} value={c.slug}>{c.short}</option>
121 + ))}
122 + </select>
123 + </div>
124 + <div>
125 + <label className="klabel mb-[4px] block" htmlFor="f-status">Statut</label>
126 + <select id="f-status" name="status" defaultValue={filters.status ?? ""} className="field cursor-pointer !py-[8px]">
127 + <option value="">Tous</option>
128 + {Object.entries(statusOptions).map(([k, v]) => (
129 + <option key={k} value={k}>{v}</option>
130 + ))}
131 + </select>
132 + </div>
133 + <div>
134 + <label className="klabel mb-[4px] block" htmlFor="f-prio">Priorité</label>
135 + <select id="f-prio" name="priority" defaultValue={filters.priority ?? ""} className="field cursor-pointer !py-[8px]">
136 + <option value="">Toutes</option>
137 + {Object.entries(PRIORITIES).map(([k, v]) => (
138 + <option key={k} value={k}>{v}</option>
139 + ))}
140 + </select>
141 + </div>
142 + <div>
143 + <label className="klabel mb-[4px] block" htmlFor="f-site">Site concerné</label>
144 + <select id="f-site" name="site" defaultValue={filters.site ?? ""} className="field cursor-pointer !py-[8px]">
145 + <option value="">Tous</option>
146 + {SITES_KA.map((s) => (
147 + <option key={s} value={s}>{s}</option>
148 + ))}
149 + </select>
150 + </div>
151 + <div>
152 + <label className="klabel mb-[4px] block" htmlFor="f-ass">Assignée à</label>
153 + <select id="f-ass" name="assigned" defaultValue={filters.assigned ?? ""} className="field cursor-pointer !py-[8px]">
154 + <option value="">Peu importe</option>
155 + <option value="moi">Moi</option>
156 + <option value="personne">Personne</option>
157 + {admins.map((a) => (
158 + <option key={a.id} value={String(a.id)}>{a.display_name}</option>
159 + ))}
160 + </select>
161 + </div>
162 + <div>
163 + <label className="klabel mb-[4px] block" htmlFor="f-view">Vue</label>
164 + <select id="f-view" name="view" defaultValue={filters.view} className="field cursor-pointer !py-[8px]">
165 + <option value="actives">Actives</option>
166 + <option value="toutes">Toutes</option>
167 + <option value="archivees">Archivées</option>
168 + <option value="spam">Spam</option>
169 + </select>
170 + </div>
171 + <div className="flex items-end gap-2">
172 + <button type="submit" className="btn btn-primary !min-h-[38px] flex-1 !px-4 !text-[13px]">Filtrer</button>
173 + <a href="/admin/inbox" className="btn btn-ghost !min-h-[38px] !px-4 !text-[13px]">Effacer</a>
174 + </div>
175 + </form>
176 +
177 + {/* liste */}
178 + <div className="gk-card mt-6 overflow-hidden !shadow-none">
179 + <div className="gk-mono hidden grid-cols-[130px_1fr_150px_130px_120px_110px] gap-3 border-b-[1.5px] border-ink bg-surface-2 px-4 py-2 text-[9.5px] font-bold tracking-[0.1em] text-ink-3 uppercase lg:grid">
180 + <span>Référence</span>
181 + <span>Demandeur · sujet</span>
182 + <span>Site / sous-cat.</span>
183 + <span>Priorité · statut</span>
184 + <span>Assignée à</span>
185 + <span>Date</span>
186 + </div>
187 + {rows.length === 0 ? (
188 + <p className="gk-mono p-6 text-[12px] text-ink-3">Aucune demande ne correspond à ces filtres.</p>
189 + ) : (
190 + rows.map((r) => {
191 + const cat = categoryBySlug(r.category);
192 + return (
193 + <a
194 + key={r.id}
195 + href={`/admin/demande/${r.reference}`}
196 + className={`adm-row lg:grid lg:grid-cols-[130px_1fr_150px_130px_120px_110px] lg:items-center lg:gap-3 ${r.is_read ? "" : "adm-row--unread"}`}
197 + >
198 + <span className="gk-mono block text-[11px] font-bold text-green">
199 + {r.reference}
200 + {r.ka_id ? <span className="block font-normal text-ink-3">{r.ka_id}</span> : null}
201 + </span>
202 + <span className="mt-1 block min-w-0 lg:mt-0">
203 + <span className="block truncate text-[13.5px] font-semibold">
204 + {r.is_anonymous
205 + ? "Anonyme"
206 + : [r.first_name, r.last_name].filter(Boolean).join(" ") || r.email || "—"}
207 + {r.organization ? <span className="font-normal text-ink-2"> · {r.organization}</span> : null}
208 + </span>
209 + <span className="block truncate text-[12px] text-ink-2">
210 + <span className="adm-chip mr-2 align-middle !text-[9px]">{cat?.short ?? r.category}</span>
211 + {r.subject ?? r.sub_category ?? (r.message ?? "").slice(0, 80)}
212 + </span>
213 + </span>
214 + <span className="gk-mono mt-1 block truncate text-[10.5px] text-ink-3 lg:mt-0">
215 + {r.site_concerned ?? "—"}
216 + {r.sub_category ? <span className="block truncate">{r.sub_category}</span> : null}
217 + </span>
218 + <span className="mt-1 flex flex-wrap gap-1 lg:mt-0">
219 + <span className={`adm-chip adm-chip--${r.priority} !text-[9px]`}>
220 + {PRIORITIES[r.priority as keyof typeof PRIORITIES] ?? r.priority}
221 + </span>
222 + <span className="adm-chip adm-chip--ghost !text-[9px]">
223 + {statusesFor(r.category)[r.status] ?? r.status}
224 + </span>
225 + </span>
226 + <span className="gk-mono mt-1 block truncate text-[10.5px] text-ink-2 lg:mt-0">
227 + {r.assignee ?? "—"}
228 + </span>
229 + <span className="gk-mono mt-1 block text-[10.5px] text-ink-3 lg:mt-0">
230 + {fmtDate(r.created_at)}
231 + {r.is_read ? null : <span className="ml-2 font-bold text-green">●</span>}
232 + </span>
233 + </a>
234 + );
235 + })
236 + )}
237 + </div>
238 +
239 + {/* pagination */}
240 + {pages > 1 ? (
241 + <div className="gk-mono mt-5 flex items-center gap-4 text-[12px] font-bold">
242 + {filters.page! > 1 ? (
243 + <a href={qs({ page: String(filters.page! - 1) })} className="underline underline-offset-4">← Précédente</a>
244 + ) : null}
245 + <span className="text-ink-3">
246 + Page {filters.page} / {pages}
247 + </span>
248 + {filters.page! < pages ? (
249 + <a href={qs({ page: String(filters.page! + 1) })} className="underline underline-offset-4">Suivante →</a>
250 + ) : null}
251 + </div>
252 + ) : null}
253 + </>
254 + );
255 +}
added src/app/admin/(panel)/layout.tsx +31 −0
@@ -0,0 +1,31 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Coquille du panel /admin — garde serveur (session ka_admin obligatoire),
3 +// navigation latérale encre, badge de non-lus. Tout ce qui est ici est
4 +// invisible sans session : la garde redirige AVANT tout rendu.
5 +import { redirect } from "next/navigation";
6 +import { getAdminSession } from "@/lib/comms/admin-auth";
7 +import { unreadCount } from "@/lib/comms/queries";
8 +import AdminNav from "./AdminNav";
9 +
10 +export const metadata = {
11 + robots: { index: false, follow: false },
12 +};
13 +
14 +export default async function AdminLayout({
15 + children,
16 +}: Readonly<{ children: React.ReactNode }>) {
17 + const session = await getAdminSession();
18 + if (!session) redirect("/admin/connexion");
19 + const unread = unreadCount(session);
20 + return (
21 + <div data-admin-root className="flex min-h-[100dvh] flex-col lg:flex-row">
22 + <AdminNav
23 + displayName={session.displayName}
24 + username={session.username}
25 + roles={session.roles}
26 + initialUnread={unread}
27 + />
28 + <main className="min-w-0 flex-1 px-4 py-6 sm:px-8 lg:py-8">{children}</main>
29 + </div>
30 + );
31 +}
added src/app/admin/(panel)/page.tsx +116 −0
@@ -0,0 +1,116 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /admin — dashboard du centre de communication : les chiffres qui comptent
3 +// (nouvelles demandes, non-lues, urgentes, investisseurs, candidatures,
4 +// légal, sécurité, résolues, délai de première réponse) + demandes récentes.
5 +// Chaque chiffre respecte le RBAC de l'admin connecté.
6 +import { redirect } from "next/navigation";
7 +import { getAdminSession } from "@/lib/comms/admin-auth";
8 +import { dashboardStats, recentSubmissions } from "@/lib/comms/queries";
9 +import { categoryBySlug, PRIORITIES, statusesFor } from "@/lib/comms/categories";
10 +
11 +export const dynamic = "force-dynamic";
12 +
13 +function fmtDate(iso: string): string {
14 + return iso.replace("T", " ").slice(0, 16);
15 +}
16 +
17 +export default async function AdminDashboard() {
18 + const session = await getAdminSession();
19 + if (!session) redirect("/admin/connexion");
20 + const s = dashboardStats(session);
21 + const recent = recentSubmissions(session, 8);
22 +
23 + const TILES: { label: string; value: string | number; href: string; tone?: "lime" | "danger" }[] = [
24 + { label: "Nouvelles aujourd'hui", value: s.today, href: "/admin/inbox", tone: "lime" },
25 + { label: "Non lues", value: s.unread, href: "/admin/inbox" },
26 + { label: "Ouvertes", value: s.open, href: "/admin/inbox" },
27 + { label: "Urgentes / hautes", value: s.urgent, href: "/admin/inbox?priority=urgente", tone: s.urgent > 0 ? "danger" : undefined },
28 + { label: "Investisseurs ouvertes", value: s.investors, href: "/admin/inbox?category=investisseurs" },
29 + { label: "Candidatures (30 j)", value: s.jobs, href: "/admin/candidatures" },
30 + { label: "Légal & Loi 25 ouvertes", value: s.legal, href: "/admin/inbox?category=legal" },
31 + { label: "Signalements sécurité", value: s.security, href: "/admin/inbox?category=securite" },
32 + { label: "Résolues cette semaine", value: s.resolvedWeek, href: "/admin/inbox?view=toutes" },
33 + {
34 + label: "1re réponse (moy. 30 j)",
35 + value: s.avgFirstResponseH != null ? `${s.avgFirstResponseH} h` : "—",
36 + href: "/admin/inbox",
37 + },
38 + ];
39 +
40 + return (
41 + <>
42 + <div className="flex flex-wrap items-end justify-between gap-4">
43 + <div>
44 + <p className="kicker">Communication Hub</p>
45 + <h1 className="gk-display mt-2 text-[clamp(24px,3.5vw,34px)] leading-tight font-bold tracking-[-0.03em]">
46 + Bonjour, {session.displayName.split(" ")[0]}.
47 + </h1>
48 + </div>
49 + <a href="/admin/inbox" className="btn btn-primary">
50 + Ouvrir l&apos;inbox {s.unread > 0 ? `(${s.unread} non lues)` : ""}
51 + </a>
52 + </div>
53 +
54 + <div className="mt-8 grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
55 + {TILES.map((t) => (
56 + <a
57 + key={t.label}
58 + href={t.href}
59 + className={`gk-card gk-card-hover p-4 no-underline ${
60 + t.tone === "lime" ? "!bg-lime-soft" : t.tone === "danger" ? "!bg-[var(--danger-soft)]" : ""
61 + }`}
62 + >
63 + <p className="stat-huge !text-[clamp(26px,3vw,38px)]">{t.value}</p>
64 + <p className="klabel mt-2 leading-snug normal-case">{t.label}</p>
65 + </a>
66 + ))}
67 + </div>
68 +
69 + <div className="sec-head mt-12">
70 + <p className="kicker">Demandes récentes</p>
71 + <a href="/admin/inbox" className="sec-index no-underline hover:text-green">
72 + Tout voir →
73 + </a>
74 + </div>
75 + <div className="gk-card mt-5 overflow-hidden !shadow-none">
76 + {recent.length === 0 ? (
77 + <p className="gk-mono p-6 text-[12px] text-ink-3">
78 + Aucune demande pour l&apos;instant — le centre de communication est prêt.
79 + </p>
80 + ) : (
81 + recent.map((r) => {
82 + const cat = categoryBySlug(r.category);
83 + return (
84 + <a
85 + key={r.id}
86 + href={`/admin/demande/${r.reference}`}
87 + className={`adm-row ${r.is_read ? "" : "adm-row--unread"}`}
88 + >
89 + <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
90 + <span className="gk-mono text-[11px] font-bold text-green">{r.reference}</span>
91 + <span className="adm-chip">{cat?.short ?? r.category}</span>
92 + <span className={`adm-chip adm-chip--${r.priority}`}>
93 + {PRIORITIES[r.priority as keyof typeof PRIORITIES] ?? r.priority}
94 + </span>
95 + <span className="adm-chip adm-chip--ghost">
96 + {statusesFor(r.category)[r.status] ?? r.status}
97 + </span>
98 + <span className="gk-mono ml-auto text-[10.5px] text-ink-3">{fmtDate(r.created_at)}</span>
99 + </div>
100 + <p className="mt-1 truncate text-[13.5px]">
101 + <strong>
102 + {r.is_anonymous
103 + ? "Anonyme"
104 + : [r.first_name, r.last_name].filter(Boolean).join(" ") || r.email || "—"}
105 + </strong>
106 + {r.organization ? ` · ${r.organization}` : ""}
107 + {r.subject ? ` — ${r.subject}` : r.sub_category ? ` — ${r.sub_category}` : ""}
108 + </p>
109 + </a>
110 + );
111 + })
112 + )}
113 + </div>
114 + </>
115 + );
116 +}
added src/app/admin/connexion/LoginForm.tsx +72 −0
@@ -0,0 +1,72 @@
1 +"use client";
2 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +// Formulaire de connexion du panel — POST /api/admin/login puis /admin.
4 +import { useState } from "react";
5 +import { useRouter } from "next/navigation";
6 +
7 +export default function LoginForm() {
8 + const router = useRouter();
9 + const [username, setUsername] = useState("");
10 + const [password, setPassword] = useState("");
11 + const [error, setError] = useState("");
12 + const [busy, setBusy] = useState(false);
13 +
14 + async function submit(e: React.FormEvent) {
15 + e.preventDefault();
16 + if (busy) return;
17 + setBusy(true);
18 + setError("");
19 + try {
20 + const res = await fetch("/api/admin/login", {
21 + method: "POST",
22 + headers: { "Content-Type": "application/json" },
23 + body: JSON.stringify({ username, password }),
24 + });
25 + const data = (await res.json().catch(() => ({}))) as { error?: string };
26 + if (res.ok) {
27 + router.replace("/admin");
28 + router.refresh();
29 + return;
30 + }
31 + setError(data.error ?? "Connexion impossible.");
32 + } catch {
33 + setError("Connexion impossible — vérifiez le réseau.");
34 + }
35 + setBusy(false);
36 + }
37 +
38 + return (
39 + <form onSubmit={submit}>
40 + <label className="klabel mb-[6px] block" htmlFor="adm-user">
41 + Identifiant
42 + </label>
43 + <input
44 + id="adm-user"
45 + className="field"
46 + autoComplete="username"
47 + autoCapitalize="none"
48 + value={username}
49 + onChange={(e) => setUsername(e.target.value)}
50 + required
51 + />
52 + <label className="klabel mt-4 mb-[6px] block" htmlFor="adm-pass">
53 + Mot de passe
54 + </label>
55 + <input
56 + id="adm-pass"
57 + type="password"
58 + className="field"
59 + autoComplete="current-password"
60 + value={password}
61 + onChange={(e) => setPassword(e.target.value)}
62 + required
63 + />
64 + {error ? (
65 + <p className="gk-mono mt-4 text-[11px] font-bold text-danger">{error}</p>
66 + ) : null}
67 + <button type="submit" className="btn btn-primary mt-6 w-full" disabled={busy}>
68 + {busy ? "Connexion…" : "Ouvrir le panel"}
69 + </button>
70 + </form>
71 + );
72 +}
added src/app/admin/connexion/page.tsx +42 −0
@@ -0,0 +1,42 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /admin/connexion — porte du panel. Hors du groupe (panel) : pas de garde,
3 +// pas de coquille — juste la carte de connexion sur fond encre.
4 +import type { Metadata } from "next";
5 +import { redirect } from "next/navigation";
6 +import { getAdminSession } from "@/lib/comms/admin-auth";
7 +import LoginForm from "./LoginForm";
8 +
9 +export const metadata: Metadata = {
10 + title: "Connexion — administration",
11 + robots: { index: false, follow: false },
12 +};
13 +
14 +export default async function AdminLoginPage() {
15 + if (await getAdminSession()) redirect("/admin");
16 + return (
17 + <main
18 + data-admin-root
19 + className="flex min-h-[100dvh] items-center justify-center bg-ink px-4"
20 + >
21 + <div className="w-full max-w-sm">
22 + <p className="gk-display text-center text-[26px] font-bold tracking-[-0.04em] text-paper">
23 + Groupe{" "}
24 + <span className="inline-block -rotate-2 rounded-md bg-lime px-[7px] pb-[2px] text-ink">
25 + KA
26 + </span>
27 + </p>
28 + <p className="gk-mono mt-2 text-center text-[10px] font-bold tracking-[0.22em] text-[rgba(245,243,238,0.5)] uppercase">
29 + Centre de communication · Administration
30 + </p>
31 + <div className="gk-card mt-8 p-7">
32 + <LoginForm />
33 + </div>
34 + <p className="gk-mono mt-6 text-center text-[10px] leading-relaxed text-[rgba(245,243,238,0.4)]">
35 + Accès réservé à l&apos;équipe Groupe KA.
36 + <br />
37 + Toutes les actions sont journalisées.
38 + </p>
39 + </div>
40 + </main>
41 + );
42 +}
added src/app/api/admin/attachments/[id]/route.ts +47 −0
@@ -0,0 +1,47 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /api/admin/attachments/[id] — téléchargement d'une pièce jointe.
3 +// Les fichiers vivent HORS de /public (data/contact-uploads) : seul un admin
4 +// autorisé à voir la demande peut les lire. Nom stocké aléatoire — le nom
5 +// d'origine n'est restitué que dans l'en-tête Content-Disposition.
6 +import { NextRequest, NextResponse } from "next/server";
7 +import fs from "fs/promises";
8 +import path from "path";
9 +import { findAttachment, findSubmissionById } from "@/lib/comms/db";
10 +import { getAdminSession, canViewSubmission } from "@/lib/comms/admin-auth";
11 +
12 +const UPLOAD_DIR = path.join(process.cwd(), "data", "contact-uploads");
13 +
14 +export async function GET(
15 + _req: NextRequest,
16 + { params }: { params: Promise<{ id: string }> },
17 +) {
18 + const session = await getAdminSession();
19 + if (!session)
20 + return NextResponse.json({ error: "Non autorisé." }, { status: 401 });
21 + const id = Number((await params).id);
22 + const att = Number.isInteger(id) ? findAttachment(id) : undefined;
23 + const sub = att ? findSubmissionById(att.submission_id) : undefined;
24 + if (!att || !sub || !canViewSubmission(session, sub))
25 + return NextResponse.json({ error: "Fichier introuvable." }, { status: 404 });
26 +
27 + // stored_name est produit par nous ("2026/KA-…-abcd.pdf") — on neutralise
28 + // quand même toute traversée de chemin.
29 + const safe = path.normalize(att.stored_name).replace(/^(\.\.[/\\])+/, "");
30 + const full = path.join(UPLOAD_DIR, safe);
31 + if (!full.startsWith(UPLOAD_DIR))
32 + return NextResponse.json({ error: "Chemin refusé." }, { status: 400 });
33 + let buf: Buffer;
34 + try {
35 + buf = await fs.readFile(full);
36 + } catch {
37 + return NextResponse.json({ error: "Fichier manquant sur le disque." }, { status: 404 });
38 + }
39 + const filename = att.original_name.replace(/["\r\n]/g, "");
40 + return new NextResponse(new Uint8Array(buf), {
41 + headers: {
42 + "Content-Type": att.mime,
43 + "Content-Disposition": `attachment; filename="${filename}"`,
44 + "Cache-Control": "private, no-store",
45 + },
46 + });
47 +}
added src/app/api/admin/badge/route.ts +13 −0
@@ -0,0 +1,13 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /api/admin/badge — compteur de demandes non lues (badge de la nav du
3 +// panel, rafraîchi par sondage léger côté client).
4 +import { NextResponse } from "next/server";
5 +import { getAdminSession } from "@/lib/comms/admin-auth";
6 +import { unreadCount } from "@/lib/comms/queries";
7 +
8 +export async function GET() {
9 + const session = await getAdminSession();
10 + if (!session)
11 + return NextResponse.json({ error: "Non autorisé." }, { status: 401 });
12 + return NextResponse.json({ unread: unreadCount(session) });
13 +}
added src/app/api/admin/login/route.ts +47 −0
@@ -0,0 +1,47 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /api/admin/login — ouverture de session du panel (cookie ka_admin, 12 h).
3 +// Délai constant sur échec (anti-énumération) + fenêtre glissante par IP.
4 +import { NextRequest, NextResponse } from "next/server";
5 +import { cookies } from "next/headers";
6 +import {
7 + findAdminByUsername,
8 + verifyAdminPassword,
9 + touchAdminLogin,
10 +} from "@/lib/comms/db";
11 +import {
12 + mintAdminToken,
13 + adminCookieOptions,
14 + ADMIN_COOKIE,
15 +} from "@/lib/comms/admin-auth";
16 +import { allowSubmission, ipFingerprint } from "@/lib/comms/rate-limit";
17 +
18 +export async function POST(req: NextRequest) {
19 + if (!allowSubmission("login:" + ipFingerprint(req)))
20 + return NextResponse.json(
21 + { error: "Trop de tentatives — réessayez dans quelques minutes." },
22 + { status: 429 },
23 + );
24 + const body = (await req.json().catch(() => null)) as {
25 + username?: string;
26 + password?: string;
27 + } | null;
28 + const username = (body?.username ?? "").trim().toLowerCase().slice(0, 80);
29 + const password = (body?.password ?? "").slice(0, 200);
30 + const fail = async () => {
31 + await new Promise((r) => setTimeout(r, 400));
32 + return NextResponse.json(
33 + { error: "Identifiant ou mot de passe invalide." },
34 + { status: 401 },
35 + );
36 + };
37 + if (!username || !password) return fail();
38 + const admin = findAdminByUsername(username);
39 + if (!admin || !admin.active) return fail();
40 + if (!verifyAdminPassword(password, admin.password_hash)) return fail();
41 +
42 + touchAdminLogin(admin.id);
43 + const token = await mintAdminToken(admin);
44 + const jar = await cookies();
45 + jar.set(ADMIN_COOKIE, token, adminCookieOptions);
46 + return NextResponse.json({ ok: true });
47 +}
added src/app/api/admin/logout/route.ts +11 −0
@@ -0,0 +1,11 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /api/admin/logout — fermeture de session du panel.
3 +import { NextResponse } from "next/server";
4 +import { cookies } from "next/headers";
5 +import { ADMIN_COOKIE } from "@/lib/comms/admin-auth";
6 +
7 +export async function POST() {
8 + const jar = await cookies();
9 + jar.delete(ADMIN_COOKIE);
10 + return NextResponse.json({ ok: true });
11 +}
added src/app/api/admin/password/route.ts +44 −0
@@ -0,0 +1,44 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /api/admin/password — changement de SON propre mot de passe (tout admin).
3 +// Exige le mot de passe actuel ; invalide toutes les autres sessions puis
4 +// réémet un cookie frais pour celle-ci.
5 +import { NextRequest, NextResponse } from "next/server";
6 +import { cookies } from "next/headers";
7 +import {
8 + findAdminById,
9 + setAdminPassword,
10 + verifyAdminPassword,
11 +} from "@/lib/comms/db";
12 +import {
13 + getAdminSession,
14 + mintAdminToken,
15 + adminCookieOptions,
16 + ADMIN_COOKIE,
17 +} from "@/lib/comms/admin-auth";
18 +
19 +export async function POST(req: NextRequest) {
20 + const session = await getAdminSession();
21 + if (!session)
22 + return NextResponse.json({ error: "Non autorisé." }, { status: 401 });
23 + const body = (await req.json().catch(() => null)) as {
24 + current?: string;
25 + next?: string;
26 + } | null;
27 + const current = body?.current ?? "";
28 + const next = body?.next ?? "";
29 + const row = findAdminById(session.id);
30 + if (!row || !verifyAdminPassword(current, row.password_hash))
31 + return NextResponse.json({ error: "Mot de passe actuel invalide." }, { status: 403 });
32 + if (next.length < 8)
33 + return NextResponse.json(
34 + { error: "Nouveau mot de passe trop court (8 caractères minimum)." },
35 + { status: 422 },
36 + );
37 + setAdminPassword(session.id, next);
38 + // Réémettre un jeton valide pour la session courante.
39 + const fresh = findAdminById(session.id)!;
40 + const token = await mintAdminToken(fresh);
41 + const jar = await cookies();
42 + jar.set(ADMIN_COOKIE, token, adminCookieOptions);
43 + return NextResponse.json({ ok: true });
44 +}
added src/app/api/admin/submissions/[id]/notes/route.ts +27 −0
@@ -0,0 +1,27 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /api/admin/submissions/[id]/notes — note interne (jamais visible du
3 +// demandeur) : auteur + date + demande, journalisée dans l'historique.
4 +import { NextRequest, NextResponse } from "next/server";
5 +import { findSubmissionById, insertNote, logEvent } from "@/lib/comms/db";
6 +import { getAdminSession, canViewSubmission } from "@/lib/comms/admin-auth";
7 +
8 +export async function POST(
9 + req: NextRequest,
10 + { params }: { params: Promise<{ id: string }> },
11 +) {
12 + const session = await getAdminSession();
13 + if (!session)
14 + return NextResponse.json({ error: "Non autorisé." }, { status: 401 });
15 + const id = Number((await params).id);
16 + const sub = Number.isInteger(id) ? findSubmissionById(id) : undefined;
17 + if (!sub || !canViewSubmission(session, sub))
18 + return NextResponse.json({ error: "Demande introuvable." }, { status: 404 });
19 +
20 + const body = (await req.json().catch(() => null)) as { body?: string } | null;
21 + const text = (body?.body ?? "").trim().slice(0, 8000);
22 + if (!text)
23 + return NextResponse.json({ error: "Note vide." }, { status: 422 });
24 + insertNote(sub.id, session.id, text);
25 + logEvent(sub.id, session.id, "note", "Note interne ajoutée");
26 + return NextResponse.json({ ok: true });
27 +}
added src/app/api/admin/submissions/[id]/reply/route.ts +61 −0
@@ -0,0 +1,61 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /api/admin/submissions/[id]/reply — réponse au demandeur depuis le panel.
3 +// Le message est D'ABORD écrit en base (source de vérité), puis envoyé par
4 +// courriel (Resend) ; l'échec d'envoi est signalé mais le message reste.
5 +// Pose first_response_at (délai de première réponse du dashboard).
6 +import { NextRequest, NextResponse } from "next/server";
7 +import {
8 + findSubmissionById,
9 + insertMessage,
10 + logEvent,
11 + touchFirstResponse,
12 +} from "@/lib/comms/db";
13 +import { getAdminSession, canViewSubmission } from "@/lib/comms/admin-auth";
14 +import { sendAdminReply } from "@/lib/comms/receipt-email";
15 +
16 +export async function POST(
17 + req: NextRequest,
18 + { params }: { params: Promise<{ id: string }> },
19 +) {
20 + const session = await getAdminSession();
21 + if (!session)
22 + return NextResponse.json({ error: "Non autorisé." }, { status: 401 });
23 + const id = Number((await params).id);
24 + const sub = Number.isInteger(id) ? findSubmissionById(id) : undefined;
25 + if (!sub || !canViewSubmission(session, sub))
26 + return NextResponse.json({ error: "Demande introuvable." }, { status: 404 });
27 +
28 + const body = (await req.json().catch(() => null)) as { body?: string } | null;
29 + const text = (body?.body ?? "").trim().slice(0, 12000);
30 + if (!text)
31 + return NextResponse.json({ error: "Message vide." }, { status: 422 });
32 + if (!sub.email)
33 + return NextResponse.json(
34 + { error: "Cette demande n'a pas de courriel (soumission anonyme) — utilisez une note interne." },
35 + { status: 422 },
36 + );
37 +
38 + insertMessage({
39 + submission_id: sub.id,
40 + sender_type: "admin",
41 + sender_admin_id: session.id,
42 + body: text,
43 + channel: "email",
44 + });
45 + touchFirstResponse(sub.id);
46 + logEvent(sub.id, session.id, "reponse", "Réponse envoyée au demandeur");
47 +
48 + let sent = true;
49 + try {
50 + await sendAdminReply({
51 + to: sub.email,
52 + name: [sub.first_name, sub.last_name].filter(Boolean).join(" ") || "bonjour",
53 + reference: sub.reference,
54 + body: text,
55 + });
56 + } catch (e) {
57 + sent = false;
58 + console.error("[admin/reply] Resend :", (e as Error).message);
59 + }
60 + return NextResponse.json({ ok: true, sent });
61 +}
added src/app/api/admin/submissions/[id]/route.ts +119 −0
@@ -0,0 +1,119 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /api/admin/submissions/[id] — workflow d'une demande depuis le panel.
3 +// PATCH { status | priority | confidentiality | assigned_to | tags | is_read
4 +// | is_spam | is_archived } — chaque changement est journalisé dans
5 +// l'historique d'activité. Contrôle d'accès PAR demande (anti-IDOR) : l'ID
6 +// vient de l'URL mais le droit est revérifié serveur à chaque appel.
7 +import { NextRequest, NextResponse } from "next/server";
8 +import {
9 + findSubmissionById,
10 + updateSubmission,
11 + markResolved,
12 + logEvent,
13 + findAdminById,
14 +} from "@/lib/comms/db";
15 +import {
16 + getAdminSession,
17 + canViewSubmission,
18 + isSuperAdmin,
19 + CATEGORY_ROLE,
20 +} from "@/lib/comms/admin-auth";
21 +import {
22 + statusesFor,
23 + TERMINAL_STATUSES,
24 + PRIORITIES,
25 + CONFIDENTIALITY_LEVELS,
26 +} from "@/lib/comms/categories";
27 +
28 +export async function PATCH(
29 + req: NextRequest,
30 + { params }: { params: Promise<{ id: string }> },
31 +) {
32 + const session = await getAdminSession();
33 + if (!session)
34 + return NextResponse.json({ error: "Non autorisé." }, { status: 401 });
35 + const id = Number((await params).id);
36 + const sub = Number.isInteger(id) ? findSubmissionById(id) : undefined;
37 + if (!sub || !canViewSubmission(session, sub))
38 + // 404 (et non 403) : ne pas révéler l'existence d'une demande interdite.
39 + return NextResponse.json({ error: "Demande introuvable." }, { status: 404 });
40 +
41 + const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
42 + if (!body)
43 + return NextResponse.json({ error: "Requête illisible." }, { status: 400 });
44 +
45 + const patch: Record<string, unknown> = {};
46 + const events: [string, string][] = [];
47 +
48 + if (typeof body.status === "string") {
49 + const allowed = statusesFor(sub.category);
50 + if (!allowed[body.status])
51 + return NextResponse.json({ error: "Statut invalide." }, { status: 422 });
52 + patch.status = body.status;
53 + events.push(["statut", `${allowed[sub.status] ?? sub.status} → ${allowed[body.status]}`]);
54 + }
55 + if (typeof body.priority === "string") {
56 + if (!(body.priority in PRIORITIES))
57 + return NextResponse.json({ error: "Priorité invalide." }, { status: 422 });
58 + patch.priority = body.priority;
59 + events.push(["priorite", `${sub.priority} → ${body.priority}`]);
60 + }
61 + if (typeof body.confidentiality === "string") {
62 + if (!(body.confidentiality in CONFIDENTIALITY_LEVELS))
63 + return NextResponse.json({ error: "Niveau invalide." }, { status: 422 });
64 + // Relever/abaisser la confidentialité d'une catégorie sensible :
65 + // super_admin ou rôle dédié seulement.
66 + const dedicated = CATEGORY_ROLE[sub.category];
67 + if (
68 + body.confidentiality === "restreinte" &&
69 + !isSuperAdmin(session) &&
70 + !(dedicated && session.roles.includes(dedicated))
71 + )
72 + return NextResponse.json(
73 + { error: "Seul le rôle dédié peut restreindre cette demande." },
74 + { status: 403 },
75 + );
76 + patch.confidentiality = body.confidentiality;
77 + events.push(["confidentialite", `${sub.confidentiality} → ${body.confidentiality}`]);
78 + }
79 + if (body.assigned_to !== undefined) {
80 + if (body.assigned_to === null) {
81 + patch.assigned_to = null;
82 + events.push(["assignation", "Demande désassignée"]);
83 + } else {
84 + const target = findAdminById(Number(body.assigned_to));
85 + if (!target || !target.active)
86 + return NextResponse.json({ error: "Admin inconnu." }, { status: 422 });
87 + patch.assigned_to = target.id;
88 + events.push(["assignation", `Assignée à ${target.display_name}`]);
89 + }
90 + }
91 + if (Array.isArray(body.tags)) {
92 + const tags = body.tags
93 + .filter((t): t is string => typeof t === "string")
94 + .map((t) => t.trim().slice(0, 40))
95 + .filter(Boolean)
96 + .slice(0, 12);
97 + patch.tags = JSON.stringify(tags);
98 + events.push(["tags", tags.join(", ") || "(aucun)"]);
99 + }
100 + for (const flag of ["is_read", "is_spam", "is_archived"] as const) {
101 + if (typeof body[flag] === "boolean") {
102 + patch[flag] = body[flag] ? 1 : 0;
103 + if (flag !== "is_read")
104 + events.push([
105 + flag === "is_spam" ? "spam" : "archive",
106 + body[flag] ? "activé" : "retiré",
107 + ]);
108 + }
109 + }
110 +
111 + if (!Object.keys(patch).length)
112 + return NextResponse.json({ error: "Rien à modifier." }, { status: 400 });
113 +
114 + updateSubmission(sub.id, patch);
115 + if (typeof patch.status === "string")
116 + markResolved(sub.id, TERMINAL_STATUSES.has(patch.status));
117 + for (const [kind, detail] of events) logEvent(sub.id, session.id, kind, detail);
118 + return NextResponse.json({ ok: true });
119 +}
added src/app/api/admin/team/[id]/route.ts +104 −0
@@ -0,0 +1,104 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /api/admin/team/[id] — gestion d'un compte admin (super_admin seulement) :
3 +// rôles, activation, nom, courriel, réinitialisation du mot de passe.
4 +// Garde-fous : impossible de se désactiver soi-même ou de retirer le dernier
5 +// super_admin actif.
6 +import { NextRequest, NextResponse } from "next/server";
7 +import {
8 + findAdminById,
9 + listAdmins,
10 + setAdminPassword,
11 + updateAdmin,
12 +} from "@/lib/comms/db";
13 +import {
14 + getAdminSession,
15 + isSuperAdmin,
16 + parseRoles,
17 + ADMIN_ROLES,
18 +} from "@/lib/comms/admin-auth";
19 +
20 +function lastActiveSuperAdmin(targetId: number): boolean {
21 + const remaining = listAdmins().filter(
22 + (a) => a.id !== targetId && a.active && parseRoles(a.roles).includes("super_admin"),
23 + );
24 + return remaining.length === 0;
25 +}
26 +
27 +export async function PATCH(
28 + req: NextRequest,
29 + { params }: { params: Promise<{ id: string }> },
30 +) {
31 + const session = await getAdminSession();
32 + if (!session)
33 + return NextResponse.json({ error: "Non autorisé." }, { status: 401 });
34 + if (!isSuperAdmin(session))
35 + return NextResponse.json({ error: "Réservé au super administrateur." }, { status: 403 });
36 + const id = Number((await params).id);
37 + const target = Number.isInteger(id) ? findAdminById(id) : undefined;
38 + if (!target)
39 + return NextResponse.json({ error: "Compte introuvable." }, { status: 404 });
40 +
41 + const body = (await req.json().catch(() => null)) as {
42 + display_name?: string;
43 + email?: string | null;
44 + roles?: string[];
45 + active?: boolean;
46 + password?: string;
47 + } | null;
48 + if (!body)
49 + return NextResponse.json({ error: "Requête illisible." }, { status: 400 });
50 +
51 + if (body.roles !== undefined) {
52 + const roles = body.roles.filter((r) => r in ADMIN_ROLES);
53 + if (!roles.length)
54 + return NextResponse.json({ error: "Au moins un rôle requis." }, { status: 422 });
55 + if (
56 + parseRoles(target.roles).includes("super_admin") &&
57 + !roles.includes("super_admin") &&
58 + lastActiveSuperAdmin(target.id)
59 + )
60 + return NextResponse.json(
61 + { error: "Impossible de retirer le dernier super administrateur." },
62 + { status: 422 },
63 + );
64 + updateAdmin(target.id, { roles });
65 + }
66 + if (body.active !== undefined) {
67 + if (target.id === session.id && !body.active)
68 + return NextResponse.json(
69 + { error: "Impossible de désactiver votre propre compte." },
70 + { status: 422 },
71 + );
72 + if (
73 + !body.active &&
74 + parseRoles(target.roles).includes("super_admin") &&
75 + lastActiveSuperAdmin(target.id)
76 + )
77 + return NextResponse.json(
78 + { error: "Impossible de désactiver le dernier super administrateur." },
79 + { status: 422 },
80 + );
81 + updateAdmin(target.id, { active: body.active });
82 + }
83 + if (body.display_name !== undefined) {
84 + const dn = body.display_name.trim().slice(0, 120);
85 + if (!dn)
86 + return NextResponse.json({ error: "Nom d'affichage requis." }, { status: 422 });
87 + updateAdmin(target.id, { display_name: dn });
88 + }
89 + if (body.email !== undefined)
90 + updateAdmin(target.id, {
91 + email: body.email ? body.email.trim().slice(0, 200) : null,
92 + });
93 + if (body.password !== undefined) {
94 + if (body.password.length < 8)
95 + return NextResponse.json(
96 + { error: "Mot de passe trop court (8 caractères minimum)." },
97 + { status: 422 },
98 + );
99 + // Change le mot de passe ET invalide toutes les sessions du compte
100 + // (l'empreinte pwv du jeton ne correspond plus).
101 + setAdminPassword(target.id, body.password);
102 + }
103 + return NextResponse.json({ ok: true });
104 +}
added src/app/api/admin/team/route.ts +51 −0
@@ -0,0 +1,51 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /api/admin/team — création d'un compte admin (super_admin seulement).
3 +import { NextRequest, NextResponse } from "next/server";
4 +import { createAdmin, findAdminByUsername } from "@/lib/comms/db";
5 +import { getAdminSession, isSuperAdmin, ADMIN_ROLES } from "@/lib/comms/admin-auth";
6 +
7 +export async function POST(req: NextRequest) {
8 + const session = await getAdminSession();
9 + if (!session)
10 + return NextResponse.json({ error: "Non autorisé." }, { status: 401 });
11 + if (!isSuperAdmin(session))
12 + return NextResponse.json({ error: "Réservé au super administrateur." }, { status: 403 });
13 +
14 + const body = (await req.json().catch(() => null)) as {
15 + username?: string;
16 + display_name?: string;
17 + email?: string;
18 + password?: string;
19 + roles?: string[];
20 + } | null;
21 + const username = (body?.username ?? "").trim().toLowerCase().slice(0, 40);
22 + const displayName = (body?.display_name ?? "").trim().slice(0, 120);
23 + const password = body?.password ?? "";
24 + const roles = (body?.roles ?? []).filter((r) => r in ADMIN_ROLES);
25 +
26 + if (!/^[a-z0-9._-]{3,40}$/.test(username))
27 + return NextResponse.json(
28 + { error: "Identifiant invalide (3-40 caractères : a-z, 0-9, . _ -)." },
29 + { status: 422 },
30 + );
31 + if (!displayName)
32 + return NextResponse.json({ error: "Nom d'affichage requis." }, { status: 422 });
33 + if (password.length < 8)
34 + return NextResponse.json(
35 + { error: "Mot de passe trop court (8 caractères minimum)." },
36 + { status: 422 },
37 + );
38 + if (!roles.length)
39 + return NextResponse.json({ error: "Au moins un rôle requis." }, { status: 422 });
40 + if (findAdminByUsername(username))
41 + return NextResponse.json({ error: "Cet identifiant existe déjà." }, { status: 409 });
42 +
43 + const id = createAdmin({
44 + username,
45 + display_name: displayName,
46 + email: (body?.email ?? "").trim().slice(0, 200) || null,
47 + password,
48 + roles,
49 + });
50 + return NextResponse.json({ ok: true, id });
51 +}
added src/app/api/contact/route.ts +247 −0
@@ -0,0 +1,247 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA Communication Hub — réception des soumissions publiques (/contact/*).
3 +// POST multipart/form-data : { category, _hp (honeypot), _ts (horodatage),
4 +// …champs du registre, fichiers }. Toute la validation est SERVEUR (le
5 +// registre src/lib/comms/categories.ts fait foi) ; le frontend n'est jamais
6 +// cru — ni pour la priorité, ni pour l'identité KA ID, ni pour les fichiers.
7 +import { NextRequest, NextResponse } from "next/server";
8 +import fs from "fs/promises";
9 +import path from "path";
10 +import crypto from "crypto";
11 +import { getSessionUser } from "@/lib/auth";
12 +import {
13 + categoryBySlug,
14 + computePriority,
15 + CORE_FIELDS,
16 + type FieldDef,
17 +} from "@/lib/comms/categories";
18 +import {
19 + insertSubmission,
20 + insertAttachment,
21 + logEvent,
22 + nextReference,
23 +} from "@/lib/comms/db";
24 +import { allowSubmission, ipFingerprint, looksLikeSpam } from "@/lib/comms/rate-limit";
25 +import { sendReceipt } from "@/lib/comms/receipt-email";
26 +
27 +const UPLOAD_DIR = path.join(process.cwd(), "data", "contact-uploads");
28 +const MAX_FILE_BYTES = 10 * 1024 * 1024;
29 +const MAX_FILES = 3;
30 +
31 +// Liste blanche stricte des types de fichiers (extension ET MIME).
32 +const ALLOWED_FILES: Record<string, string[]> = {
33 + ".pdf": ["application/pdf"],
34 + ".doc": ["application/msword"],
35 + ".docx": ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
36 + ".png": ["image/png"],
37 + ".jpg": ["image/jpeg"],
38 + ".jpeg": ["image/jpeg"],
39 + ".webp": ["image/webp"],
40 + ".txt": ["text/plain"],
41 +};
42 +
43 +function clean(v: FormDataEntryValue | null, maxLen = 500): string | null {
44 + if (typeof v !== "string") return null;
45 + // sanitation : caractères de contrôle retirés (le \\n des textarea survit),
46 + // longueur bornée
47 + const s = v.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "").trim();
48 + return s ? s.slice(0, maxLen) : null;
49 +}
50 +
51 +function isEmail(s: string): boolean {
52 + return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s) && s.length <= 200;
53 +}
54 +
55 +function isUrl(s: string): boolean {
56 + try {
57 + const u = new URL(s.startsWith("http") ? s : `https://${s}`);
58 + return u.protocol === "https:" || u.protocol === "http:";
59 + } catch {
60 + return false;
61 + }
62 +}
63 +
64 +/** Le champ est-il actif compte tenu des conditions showIf/showIfUnchecked ? */
65 +function fieldActive(f: FieldDef, values: Record<string, string | null>): boolean {
66 + if (f.showIf && values[f.showIf.field] !== f.showIf.equals) return false;
67 + if (f.showIfUnchecked && values[f.showIfUnchecked] === "on") return false;
68 + return true;
69 +}
70 +
71 +export async function POST(req: NextRequest) {
72 + const fd = await req.formData().catch(() => null);
73 + if (!fd)
74 + return NextResponse.json({ error: "Requête illisible." }, { status: 400 });
75 +
76 + const cat = categoryBySlug(clean(fd.get("category"), 40) ?? "");
77 + if (!cat)
78 + return NextResponse.json({ error: "Catégorie inconnue." }, { status: 400 });
79 +
80 + // Anti-abus : honeypot + temps de remplissage + fenêtre glissante par IP
81 + // (empreinte salée, en mémoire seulement — jamais d'IP en base).
82 + const spammy = looksLikeSpam(
83 + clean(fd.get("_hp"), 200),
84 + typeof fd.get("_ts") === "string" ? (fd.get("_ts") as string) : null,
85 + );
86 + if (!allowSubmission(ipFingerprint(req)))
87 + return NextResponse.json(
88 + { error: "Trop de soumissions rapprochées — réessayez dans quelques minutes." },
89 + { status: 429 },
90 + );
91 +
92 + /* ---------- validation champ par champ selon le registre ---------- */
93 + const values: Record<string, string | null> = {};
94 + for (const f of cat.fields) {
95 + if (f.type === "file") continue;
96 + if (f.type === "checkbox") {
97 + values[f.name] = fd.get(f.name) === "on" ? "on" : null;
98 + continue;
99 + }
100 + values[f.name] = clean(fd.get(f.name), f.maxLen ?? 500);
101 + }
102 +
103 + const errors: Record<string, string> = {};
104 + for (const f of cat.fields) {
105 + if (f.type === "file") continue;
106 + if (!fieldActive(f, values)) {
107 + values[f.name] = null; // champ inactif : on ne garde rien
108 + continue;
109 + }
110 + const v = values[f.name];
111 + if (f.required && !v) {
112 + errors[f.name] = "Ce champ est requis.";
113 + continue;
114 + }
115 + if (!v) continue;
116 + if (f.type === "email" && !isEmail(v)) errors[f.name] = "Courriel invalide.";
117 + if (f.type === "url" && !isUrl(v)) errors[f.name] = "Adresse web invalide.";
118 + if (f.type === "select" && f.options && !f.options.includes(v))
119 + errors[f.name] = "Valeur non reconnue.";
120 + if (f.type === "date" && !/^\d{4}-\d{2}-\d{2}$/.test(v))
121 + errors[f.name] = "Date invalide.";
122 + }
123 + if (Object.keys(errors).length)
124 + return NextResponse.json({ error: "Formulaire incomplet.", fields: errors }, { status: 422 });
125 +
126 + /* ---------- fichiers ---------- */
127 + type Staged = { field: string; name: string; mime: string; buf: Buffer };
128 + const staged: Staged[] = [];
129 + for (const f of cat.fields) {
130 + if (f.type !== "file" || !fieldActive(f, values)) continue;
131 + const file = fd.get(f.name);
132 + const present = file instanceof File && file.size > 0;
133 + if (f.required && !present)
134 + return NextResponse.json(
135 + { error: "Formulaire incomplet.", fields: { [f.name]: "Fichier requis." } },
136 + { status: 422 },
137 + );
138 + if (!present) continue;
139 + if (staged.length >= MAX_FILES)
140 + return NextResponse.json({ error: "Trop de fichiers (3 max)." }, { status: 422 });
141 + if (file.size > MAX_FILE_BYTES)
142 + return NextResponse.json(
143 + { error: "Fichier trop lourd — 10 Mo maximum.", fields: { [f.name]: "10 Mo maximum." } },
144 + { status: 413 },
145 + );
146 + const ext = path.extname(file.name || "").toLowerCase();
147 + const mimes = ALLOWED_FILES[ext];
148 + if (!mimes || !mimes.includes(file.type))
149 + return NextResponse.json(
150 + { error: "Format refusé.", fields: { [f.name]: "Formats acceptés : PDF, Word, image." } },
151 + { status: 415 },
152 + );
153 + staged.push({
154 + field: f.name,
155 + name: (file.name || `fichier${ext}`).slice(0, 200),
156 + mime: file.type,
157 + buf: Buffer.from(await file.arrayBuffer()),
158 + });
159 + }
160 +
161 + /* ---------- identité : session KA ID côté serveur uniquement ---------- */
162 + const user = await getSessionUser().catch(() => null);
163 + const isAnonymous = values.is_anonymous === "on";
164 +
165 + /* ---------- colonnes cœur vs metadata ---------- */
166 + const core: Record<string, string | null> = {};
167 + const metadata: Record<string, unknown> = {};
168 + for (const f of cat.fields) {
169 + if (f.type === "file") continue;
170 + const v = values[f.name];
171 + if (f.name === "is_anonymous") continue;
172 + if (CORE_FIELDS.has(f.name)) core[f.name] = v;
173 + else if (v != null) metadata[f.name] = f.type === "checkbox" ? true : v;
174 + }
175 + // fournisseurs/carrières : « Nom » simple saisi dans first_name — OK tel quel.
176 +
177 + const subCategory = cat.subCategoryFrom
178 + ? (values[cat.subCategoryFrom] ?? null)
179 + : null;
180 + const priority = spammy
181 + ? "basse"
182 + : computePriority(cat.slug, subCategory, metadata);
183 +
184 + const reference = nextReference(cat.prefix);
185 + const id = insertSubmission({
186 + reference,
187 + category: cat.slug,
188 + sub_category: subCategory,
189 + first_name: core.first_name ?? null,
190 + last_name: core.last_name ?? null,
191 + email: isAnonymous ? null : (core.email ?? null),
192 + phone: core.phone ?? null,
193 + organization: core.organization ?? null,
194 + job_title: core.job_title ?? null,
195 + website: core.website ?? null,
196 + linkedin_url: core.linkedin_url ?? null,
197 + github_url: core.github_url ?? null,
198 + site_concerned: core.site_concerned ?? null,
199 + subject: core.subject ?? null,
200 + message: core.message ?? null,
201 + metadata: Object.keys(metadata).length ? metadata : null,
202 + priority,
203 + confidentiality: cat.confidentiality,
204 + user_id: isAnonymous ? null : (user?.id ?? null),
205 + ka_id: isAnonymous ? null : (user?.kaId ?? null),
206 + is_anonymous: isAnonymous,
207 + });
208 + if (spammy) {
209 + // On enregistre quand même (faux positifs possibles) mais marqué spam.
210 + const { commsDb } = await import("@/lib/comms/db");
211 + commsDb
212 + .prepare("UPDATE contact_submissions SET is_spam = 1 WHERE id = ?")
213 + .run(id);
214 + }
215 + logEvent(id, null, "creation", `Soumission ${cat.slug} reçue (web)`);
216 +
217 + /* ---------- pièces jointes : nom aléatoire, hors du dossier public ---------- */
218 + if (staged.length) {
219 + const year = String(new Date().getFullYear());
220 + const dir = path.join(UPLOAD_DIR, year);
221 + await fs.mkdir(dir, { recursive: true });
222 + for (const s of staged) {
223 + const stored = `${reference}-${s.field}-${crypto.randomBytes(8).toString("hex")}${path.extname(s.name).toLowerCase()}`;
224 + await fs.writeFile(path.join(dir, stored), s.buf);
225 + insertAttachment({
226 + submission_id: id,
227 + field: s.field,
228 + original_name: s.name,
229 + stored_name: `${year}/${stored}`,
230 + mime: s.mime,
231 + size: s.buf.length,
232 + });
233 + }
234 + }
235 +
236 + /* ---------- accusé de réception (non bloquant) ---------- */
237 + const to = isAnonymous ? null : (core.email ?? null);
238 + if (to && !spammy) {
239 + const name =
240 + [core.first_name, core.last_name].filter(Boolean).join(" ") || "bonjour";
241 + sendReceipt({ to, name, reference, categoryTitle: cat.title }).catch((e) =>
242 + console.error("[contact] accusé de réception :", (e as Error).message),
243 + );
244 + }
245 +
246 + return NextResponse.json({ ok: true, reference });
247 +}
modified src/app/api/wallet/pass/route.ts +1 −1
@@ -104,7 +104,7 @@ export async function GET() {
104 104 {
105 105 key: "contact",
106 106 label: "Contact",
107 − value: "contact@groupe-ka.com",
107 + value: "www.groupe-ka.com/contact",
108 108 },
109 109 ],
110 110 },
modified src/app/bots/page.tsx +15 −15
@@ -77,10 +77,10 @@ const PRINCIPES: { title: string; body: React.ReactNode }[] = [
77 77 body: (
78 78 <>
79 79 Tout exploitant de site ou titulaire de droits peut demander
80 − l&apos;exclusion de son site ou le retrait d&apos;un contenu en
81 − écrivant à{" "}
82 − <a className={MAILTO} href="mailto:admin@groupe-ka.com">
83 − admin@groupe-ka.com
80 + l&apos;exclusion de son site ou le retrait d&apos;un contenu via
81 + le{" "}
82 + <a className={MAILTO} href="/contact/legal">
83 + formulaire Légal, vie privée &amp; Loi 25
84 84 </a>
85 85 . Les demandes sont traitées dans un délai de 48 à 72 heures
86 86 ouvrables — voir notre{" "}
@@ -198,9 +198,9 @@ const BOTS_FICHES: BotFiche[] = [
198 198 <>
199 199 toute personne figurant dans les données de ka·6 peut demander la
200 200 consultation, la rectification ou le retrait complet de son profil
201 − en écrivant à{" "}
202 − <a className={MAILTO} href="mailto:admin@groupe-ka.com">
203 − admin@groupe-ka.com
201 + via le{" "}
202 + <a className={MAILTO} href="/contact/legal">
203 + formulaire Légal, vie privée &amp; Loi 25
204 204 </a>
205 205 , sans avoir à justifier sa demande. Le retrait est traité en
206 206 priorité.
@@ -378,9 +378,9 @@ export default function Bots() {
378 378 Établir un partenariat de données
379 379 </strong>{" "}
380 380 (flux officiel plutôt que crawl) : nous sommes ouverts à toute
381 − entente, écrivez à{" "}
382 − <a className={MAILTO} href="mailto:contact@groupe-ka.com">
383 − contact@groupe-ka.com
381 + entente, passez par le{" "}
382 + <a className={MAILTO} href="/contact/data">
383 + formulaire Données, API &amp; intégrations
384 384 </a>
385 385 .
386 386 </Bullet>
@@ -392,13 +392,13 @@ export default function Bots() {
392 392 Responsable des robots d&apos;indexation :{" "}
393 393 <strong className="text-ink">Simon-Pierre Boucher</strong>
394 394 <br />
395 − Courriel :{" "}
396 − <a className={MAILTO} href="mailto:contact@groupe-ka.com">
397 − contact@groupe-ka.com
395 + Contact :{" "}
396 + <a className={MAILTO} href="/contact/data">
397 + formulaire Données &amp; API
398 398 </a>{" "}
399 399 · Demandes de retrait :{" "}
400 − <a className={MAILTO} href="mailto:admin@groupe-ka.com">
401 − admin@groupe-ka.com
400 + <a className={MAILTO} href="/contact/legal">
401 + formulaire Légal &amp; vie privée
402 402 </a>
403 403 </p>
404 404 </Card>
modified src/app/compte/page.tsx +2 −2
@@ -513,8 +513,8 @@ export default async function Compte() {
513 513 <a href="/confidentialite" className="btn btn-ghost">
514 514 Confidentialité
515 515 </a>
516 − <a href="mailto:admin@groupe-ka.com" className="btn btn-ghost">
517 − Vie privée : admin@groupe-ka.com
516 + <a href="/contact/legal" className="btn btn-ghost">
517 + Vie privée : nous joindre
518 518 </a>
519 519 </div>
520 520 </main>
modified src/app/conditions/page.tsx +6 −6
@@ -101,16 +101,16 @@ const SECTIONS: { num: string; title: string; body: React.ReactNode }[] = [
101 101 body: (
102 102 <>
103 103 Pour toute question relative aux présentes conditions :{" "}
104 − <a className="gk-mono font-bold underline underline-offset-4" href="mailto:admin@groupe-ka.com">
105 − admin@groupe-ka.com
104 + <a className="gk-mono font-bold underline underline-offset-4" href="/contact/legal">
105 + formulaire Légal, vie privée &amp; Loi 25
106 106 </a>
107 107 . Pour les questions générales :{" "}
108 − <a className="gk-mono font-bold underline underline-offset-4" href="mailto:info@groupe-ka.com">
109 − info@groupe-ka.com
108 + <a className="gk-mono font-bold underline underline-offset-4" href="/contact/general">
109 + formulaire Questions générales
110 110 </a>
111 111 . Pour les projets et partenariats :{" "}
112 − <a className="gk-mono font-bold underline underline-offset-4" href="mailto:contact@groupe-ka.com">
113 − contact@groupe-ka.com
112 + <a className="gk-mono font-bold underline underline-offset-4" href="/contact/partenariats">
113 + formulaire Projets &amp; partenariats
114 114 </a>
115 115 .
116 116 </>
modified src/app/confidentialite/page.tsx +7 −7
@@ -83,13 +83,13 @@ const SECTIONS: { num: string; title: string; body: React.ReactNode }[] = [
83 83 body: (
84 84 <>
85 85 Responsable de la protection des renseignements personnels :
86 − Simon-Pierre Boucher, Groupe KA —{" "}
87 − <a className="gk-mono font-bold underline underline-offset-4" href="mailto:admin@groupe-ka.com">
88 − admin@groupe-ka.com
89 − </a>{" "}
90 − (légal &amp; vie privée). Questions générales :{" "}
91 − <a className="gk-mono font-bold underline underline-offset-4" href="mailto:info@groupe-ka.com">
92 − info@groupe-ka.com
86 + Simon-Pierre Boucher, Groupe KA — joignable via le{" "}
87 + <a className="gk-mono font-bold underline underline-offset-4" href="/contact/legal">
88 + formulaire Légal, vie privée &amp; Loi 25
89 + </a>
90 + . Questions générales :{" "}
91 + <a className="gk-mono font-bold underline underline-offset-4" href="/contact/general">
92 + formulaire Questions générales
93 93 </a>
94 94 . Si notre réponse ne vous satisfait pas, vous pouvez saisir la
95 95 Commission d&apos;accès à l&apos;information du Québec (cai.gouv.qc.ca).
added src/app/contact/ContactForm.tsx +261 −0
@@ -0,0 +1,261 @@
1 +"use client";
2 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +// KA Communication Hub — formulaire public générique, piloté par le registre
4 +// (src/lib/comms/categories.ts). Un seul composant pour les 10 catégories :
5 +// il rend les champs, gère les conditions showIf, soumet en multipart à
6 +// /api/contact et affiche la référence publique (KA-XXX-2026-0000) en retour.
7 +// La validation client est une courtoisie — le serveur revalide tout.
8 +import { useMemo, useRef, useState } from "react";
9 +import type { FieldDef } from "@/lib/comms/categories";
10 +
11 +type Props = {
12 + slug: string;
13 + fields: FieldDef[];
14 + /** préremplissage KA ID (serveur) : { first_name: "…", email: "…" } */
15 + defaults: Record<string, string>;
16 + kaConnected: boolean;
17 +};
18 +
19 +function fieldVisible(f: FieldDef, values: Record<string, string>): boolean {
20 + if (f.showIf && values[f.showIf.field] !== f.showIf.equals) return false;
21 + if (f.showIfUnchecked && values[f.showIfUnchecked] === "on") return false;
22 + return true;
23 +}
24 +
25 +export default function ContactForm({ slug, fields, defaults, kaConnected }: Props) {
26 + const [values, setValues] = useState<Record<string, string>>(() => {
27 + const v: Record<string, string> = {};
28 + for (const f of fields) v[f.name] = defaults[f.name] ?? "";
29 + return v;
30 + });
31 + const [errors, setErrors] = useState<Record<string, string>>({});
32 + const [state, setState] = useState<"idle" | "sending" | "done">("idle");
33 + const [reference, setReference] = useState("");
34 + const [globalError, setGlobalError] = useState("");
35 + const tsRef = useRef(Date.now());
36 + const formRef = useRef<HTMLFormElement>(null);
37 +
38 + const visible = useMemo(
39 + () => fields.filter((f) => fieldVisible(f, values)),
40 + [fields, values],
41 + );
42 +
43 + function set(name: string, value: string) {
44 + setValues((v) => ({ ...v, [name]: value }));
45 + setErrors((e) => {
46 + if (!e[name]) return e;
47 + const next = { ...e };
48 + delete next[name];
49 + return next;
50 + });
51 + }
52 +
53 + async function submit(e: React.FormEvent) {
54 + e.preventDefault();
55 + if (state === "sending") return;
56 + setGlobalError("");
57 +
58 + // validation de courtoisie (le serveur fait foi)
59 + const errs: Record<string, string> = {};
60 + for (const f of visible) {
61 + if (f.type === "file" || f.type === "checkbox") continue;
62 + if (f.required && !values[f.name]?.trim()) errs[f.name] = "Ce champ est requis.";
63 + }
64 + for (const f of visible) {
65 + if (f.type === "checkbox" && f.required && values[f.name] !== "on")
66 + errs[f.name] = "Votre consentement est requis.";
67 + }
68 + if (Object.keys(errs).length) {
69 + setErrors(errs);
70 + document
71 + .querySelector(`[data-field="${Object.keys(errs)[0]}"]`)
72 + ?.scrollIntoView({ behavior: "smooth", block: "center" });
73 + return;
74 + }
75 +
76 + setState("sending");
77 + const fd = new FormData(formRef.current!);
78 + fd.set("category", slug);
79 + fd.set("_ts", String(tsRef.current));
80 + try {
81 + const res = await fetch("/api/contact", { method: "POST", body: fd });
82 + const data = (await res.json().catch(() => ({}))) as {
83 + ok?: boolean;
84 + reference?: string;
85 + error?: string;
86 + fields?: Record<string, string>;
87 + };
88 + if (res.ok && data.ok && data.reference) {
89 + setReference(data.reference);
90 + setState("done");
91 + window.scrollTo({ top: 0, behavior: "smooth" });
92 + return;
93 + }
94 + if (data.fields) setErrors(data.fields);
95 + setGlobalError(data.error ?? "Une erreur est survenue — réessayez.");
96 + setState("idle");
97 + } catch {
98 + setGlobalError("Connexion impossible — vérifiez votre réseau et réessayez.");
99 + setState("idle");
100 + }
101 + }
102 +
103 + if (state === "done") {
104 + return (
105 + <div className="gk-card mt-10 p-8 sm:p-10">
106 + <p className="kicker">Demande enregistrée</p>
107 + <h2 className="gk-display mt-4 text-[clamp(24px,4vw,34px)] leading-tight font-bold tracking-[-0.03em]">
108 + C&apos;est entre bonnes mains.
109 + </h2>
110 + <div className="mt-6 inline-block rounded-xl bg-ink px-7 py-5">
111 + <p className="gk-mono text-[10px] font-bold tracking-[0.26em] text-[rgba(217,242,107,0.65)] uppercase">
112 + Votre numéro de demande
113 + </p>
114 + <p className="gk-mono mt-2 text-[clamp(20px,4vw,28px)] font-bold tracking-[0.12em] text-lime">
115 + {reference}
116 + </p>
117 + </div>
118 + <p className="mt-6 max-w-xl text-[14px] leading-relaxed text-ink-2">
119 + Votre demande est enregistrée dans notre centre de communication et
120 + sera lue par l&apos;équipe concernée.{" "}
121 + {values.email && values.is_anonymous !== "on"
122 + ? "Un accusé de réception vient de partir vers votre courriel — conservez ce numéro, il identifie votre demande dans tous nos échanges."
123 + : "Conservez ce numéro : il identifie votre demande."}
124 + </p>
125 + <a href="/contact" className="cta-link mt-8">
126 + Retour au centre de communication
127 + </a>
128 + </div>
129 + );
130 + }
131 +
132 + return (
133 + <form ref={formRef} onSubmit={submit} className="mt-10" noValidate>
134 + {kaConnected ? (
135 + <p className="gk-mono mb-6 inline-flex items-center gap-2 rounded-full border-[1.5px] border-ink bg-lime-soft px-4 py-2 text-[11px] font-bold tracking-[0.08em] uppercase">
136 + <span className="pulse-dot" aria-hidden="true" />
137 + KA ID connecté — vos informations sont préremplies et la demande sera
138 + liée à votre compte
139 + </p>
140 + ) : null}
141 +
142 + {/* honeypot : invisible pour les humains, irrésistible pour les robots */}
143 + <div aria-hidden="true" className="absolute -left-[9999px] h-0 w-0 overflow-hidden">
144 + <label>
145 + Ne pas remplir ce champ
146 + <input type="text" name="_hp" tabIndex={-1} autoComplete="off" />
147 + </label>
148 + </div>
149 +
150 + <div className="grid gap-x-5 gap-y-5 sm:grid-cols-2">
151 + {visible.map((f) => {
152 + const err = errors[f.name];
153 + const wide = !f.half;
154 + if (f.type === "checkbox")
155 + return (
156 + <label
157 + key={f.name}
158 + data-field={f.name}
159 + className={`flex cursor-pointer items-start gap-3 ${wide ? "sm:col-span-2" : ""}`}
160 + >
161 + <input
162 + type="checkbox"
163 + name={f.name}
164 + checked={values[f.name] === "on"}
165 + onChange={(e) => set(f.name, e.target.checked ? "on" : "")}
166 + className="mt-[3px] h-[18px] w-[18px] flex-none accent-[var(--green)]"
167 + />
168 + <span className="text-[13.5px] leading-relaxed text-ink-2">
169 + {f.label}
170 + {f.required ? <span className="text-danger"> *</span> : null}
171 + {err ? (
172 + <span className="gk-mono mt-1 block text-[11px] font-bold text-danger">{err}</span>
173 + ) : null}
174 + </span>
175 + </label>
176 + );
177 + return (
178 + <div key={f.name} data-field={f.name} className={wide ? "sm:col-span-2" : ""}>
179 + <label className="klabel mb-[6px] block" htmlFor={`f-${f.name}`}>
180 + {f.label}
181 + {f.required ? <span className="text-danger"> *</span> : null}
182 + </label>
183 + {f.type === "textarea" ? (
184 + <textarea
185 + id={`f-${f.name}`}
186 + name={f.name}
187 + rows={f.rows ?? 5}
188 + maxLength={f.maxLen}
189 + placeholder={f.placeholder}
190 + value={values[f.name]}
191 + onChange={(e) => set(f.name, e.target.value)}
192 + className="field resize-y"
193 + />
194 + ) : f.type === "select" ? (
195 + <select
196 + id={`f-${f.name}`}
197 + name={f.name}
198 + value={values[f.name]}
199 + onChange={(e) => set(f.name, e.target.value)}
200 + className="field cursor-pointer"
201 + >
202 + <option value="">— Choisir —</option>
203 + {(f.options ?? []).map((o) => (
204 + <option key={o} value={o}>
205 + {o}
206 + </option>
207 + ))}
208 + </select>
209 + ) : f.type === "file" ? (
210 + <input
211 + id={`f-${f.name}`}
212 + type="file"
213 + name={f.name}
214 + accept={f.accept}
215 + className="field cursor-pointer file:mr-4 file:cursor-pointer file:rounded-full file:border-0 file:bg-ink file:px-4 file:py-[6px] file:font-[var(--font-display)] file:text-[12px] file:font-bold file:text-lime"
216 + />
217 + ) : (
218 + <input
219 + id={`f-${f.name}`}
220 + type={f.type === "date" ? "date" : f.type}
221 + name={f.name}
222 + maxLength={f.maxLen}
223 + placeholder={f.placeholder}
224 + value={values[f.name]}
225 + onChange={(e) => set(f.name, e.target.value)}
226 + className="field"
227 + />
228 + )}
229 + {f.help ? (
230 + <p className="gk-mono mt-[6px] text-[10.5px] leading-relaxed text-ink-3">{f.help}</p>
231 + ) : null}
232 + {err ? (
233 + <p className="gk-mono mt-[6px] text-[11px] font-bold text-danger">{err}</p>
234 + ) : null}
235 + </div>
236 + );
237 + })}
238 + </div>
239 +
240 + {globalError ? (
241 + <p className="gk-mono mt-6 rounded-lg border-[1.5px] border-danger bg-[var(--danger-soft)] px-4 py-3 text-[12px] font-bold text-danger">
242 + {globalError}
243 + </p>
244 + ) : null}
245 +
246 + <div className="mt-8 flex flex-wrap items-center gap-6">
247 + <button type="submit" className="btn btn-primary" disabled={state === "sending"}>
248 + {state === "sending" ? "Envoi en cours…" : "Envoyer ma demande"}
249 + </button>
250 + <p className="gk-mono max-w-sm text-[10.5px] leading-relaxed text-ink-3">
251 + Votre demande reçoit un numéro de suivi et est lue par une vraie
252 + personne. Renseignements traités selon notre{" "}
253 + <a href="/confidentialite" className="underline underline-offset-2">
254 + politique de confidentialité
255 + </a>
256 + .
257 + </p>
258 + </div>
259 + </form>
260 + );
261 +}
added src/app/contact/[category]/page.tsx +178 −0
@@ -0,0 +1,178 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /contact/[category] — le formulaire d'une porte d'entrée du centre de
3 +// communication. Rendu serveur : le registre fournit les champs, la session
4 +// KA ID (si présente) préremplit l'identité — on ne redemande pas ce qu'on
5 +// sait déjà, et la demande est liée au compte côté serveur.
6 +import type { Metadata } from "next";
7 +import { notFound } from "next/navigation";
8 +import { getSessionUser } from "@/lib/auth";
9 +import { findUserById, parseSocials } from "@/lib/db";
10 +import { categoryBySlug } from "@/lib/comms/categories";
11 +import { CategoryIcon } from "../icons";
12 +import ContactForm from "../ContactForm";
13 +
14 +// Page dynamique : la session KA ID (cookies) préremplit le formulaire.
15 +export const dynamic = "force-dynamic";
16 +
17 +export async function generateMetadata({
18 + params,
19 +}: {
20 + params: Promise<{ category: string }>;
21 +}): Promise<Metadata> {
22 + const cat = categoryBySlug((await params).category);
23 + if (!cat) return {};
24 + return {
25 + title: `${cat.title} — nous joindre`,
26 + description: cat.intro,
27 + };
28 +}
29 +
30 +/* Contenus éditoriaux propres à certaines catégories */
31 +
32 +function InvestisseursIntro() {
33 + const PROFILES = [
34 + "Investisseurs privés",
35 + "Fonds (VC, PE)",
36 + "Family offices",
37 + "Investisseurs stratégiques",
38 + "Partenaires financiers",
39 + "Collaborations & acquisitions",
40 + ];
41 + return (
42 + <div className="mt-8 grid gap-8 lg:grid-cols-[1.4fr_1fr]">
43 + <div>
44 + <p className="text-[14.5px] leading-relaxed text-ink-2">
45 + Le Groupe KA construit et opère{" "}
46 + <strong className="text-ink">quatorze plateformes d&apos;agrégation</strong>{" "}
47 + entièrement automatisées sur sa propre infrastructure — et publie ses
48 + chiffres comme ses données : en entier. Aucune ronde de financement
49 + n&apos;est en cours, mais nous sommes ouverts aux discussions
50 + sérieuses : investissement, partenariat financier, acquisition, joint
51 + venture ou simple introduction.
52 + </p>
53 + <p className="mt-4 text-[14.5px] leading-relaxed text-ink-2">
54 + Commencez par les{" "}
55 + <a href="/investisseurs" className="font-bold text-green underline underline-offset-4">
56 + documents corporatifs en libre accès
57 + </a>{" "}
58 + (plan d&apos;affaires, prévisions 2026–2029, infrastructure, coûts
59 + mesurés), puis présentez-vous ci-dessous. Ces demandes sont traitées
60 + confidentiellement par les relations investisseurs.
61 + </p>
62 + </div>
63 + <ul className="rule-ink grid grid-cols-1 gap-y-2 self-start pt-4">
64 + {PROFILES.map((p) => (
65 + <li key={p} className="gk-mono flex items-center gap-3 text-[11.5px] font-bold tracking-[0.06em] uppercase">
66 + <span className="h-[6px] w-[6px] flex-none rotate-45 bg-green" aria-hidden="true" />
67 + {p}
68 + </li>
69 + ))}
70 + </ul>
71 + </div>
72 + );
73 +}
74 +
75 +function CarrieresIntro() {
76 + return (
77 + <div className="mt-8 max-w-3xl">
78 + <p className="text-[14.5px] leading-relaxed text-ink-2">
79 + <strong className="text-ink">Carrières chez Groupe KA</strong> — nous
80 + construisons des agrégateurs automatisés, une infrastructure de calcul
81 + maison et des outils d&apos;IA appliquée, depuis le Québec. Deux façons
82 + de postuler : répondre à un poste précis, ou nous faire une{" "}
83 + <strong className="text-ink">candidature spontanée</strong> — « je ne
84 + vois pas de poste correspondant, mais je veux travailler avec Groupe
85 + KA ». Les deux sont enregistrées et lues avec la même attention.
86 + </p>
87 + <p className="gk-mono mt-4 text-[11px] leading-relaxed text-ink-3">
88 + Les offres publiées par les employeurs du Québec sont sur{" "}
89 + <a href="https://www.job-ka.com" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
90 + Job·Ka
91 + </a>{" "}
92 + — ce formulaire-ci concerne les emplois chez Groupe KA même. Votre CV
93 + est conservé de façon sécurisée et n&apos;est jamais partagé.
94 + </p>
95 + </div>
96 + );
97 +}
98 +
99 +function SecuriteIntro() {
100 + return (
101 + <div className="mt-8 max-w-3xl rounded-xl border-[1.5px] border-ink bg-lime-soft p-5">
102 + <p className="text-[13.5px] leading-relaxed text-ink-2">
103 + <strong className="text-ink">Divulgation responsable :</strong> si vous
104 + avez trouvé une vulnérabilité, décrivez-la ici sans l&apos;exploiter
105 + au-delà du nécessaire. Le signalement peut être{" "}
106 + <strong className="text-ink">anonyme</strong> — ne laissez un courriel
107 + que si vous souhaitez un suivi. Les signalements critiques passent
108 + automatiquement en priorité urgente.
109 + </p>
110 + </div>
111 + );
112 +}
113 +
114 +export default async function ContactCategoryPage({
115 + params,
116 +}: {
117 + params: Promise<{ category: string }>;
118 +}) {
119 + const cat = categoryBySlug((await params).category);
120 + if (!cat) notFound();
121 +
122 + // Préremplissage KA ID (serveur) — jamais confiance au client pour le lien
123 + // user_id/ka_id : /api/contact relit la session lui-même.
124 + const user = await getSessionUser();
125 + const defaults: Record<string, string> = {};
126 + if (user) {
127 + const row = findUserById(user.id);
128 + const [first, ...rest] = (user.name ?? "").trim().split(/\s+/);
129 + if (first) defaults.first_name = first;
130 + if (rest.length) defaults.last_name = rest.join(" ");
131 + defaults.email = user.email;
132 + if (row?.phone) defaults.phone = row.phone;
133 + if (row?.company) defaults.organization = row.company;
134 + if (row?.job_title) defaults.job_title = row.job_title;
135 + if (row?.website) defaults.website = row.website;
136 + if (row?.city) defaults.ville = row.city;
137 + const socials = parseSocials(row?.socials ?? null);
138 + if (socials.linkedin) defaults.linkedin_url = socials.linkedin;
139 + }
140 +
141 + return (
142 + <main className="mx-auto max-w-4xl px-4 py-12 sm:px-6">
143 + <nav aria-label="Fil d'Ariane">
144 + <a href="/contact" className="gk-mono text-[11px] font-bold tracking-[0.1em] text-ink-3 uppercase no-underline hover:text-green">
145 + ← Centre de communication
146 + </a>
147 + </nav>
148 +
149 + <div className="mt-6 flex items-start gap-5">
150 + <span className="hidden h-14 w-14 flex-none items-center justify-center rounded-xl border-[1.5px] border-ink bg-ink text-lime sm:flex">
151 + <CategoryIcon name={cat.icon} className="h-7 w-7" />
152 + </span>
153 + <div>
154 + <p className="kicker">Nous joindre · KA-{cat.prefix}</p>
155 + <h1 className="gk-display mt-2 text-[clamp(26px,4.5vw,40px)] leading-[1.02] font-bold tracking-[-0.03em] uppercase">
156 + {cat.title}
157 + </h1>
158 + </div>
159 + </div>
160 +
161 + {cat.slug === "investisseurs" ? (
162 + <InvestisseursIntro />
163 + ) : cat.slug === "carrieres" ? (
164 + <CarrieresIntro />
165 + ) : (
166 + <p className="mt-6 max-w-3xl text-[14.5px] leading-relaxed text-ink-2">{cat.intro}</p>
167 + )}
168 + {cat.slug === "securite" ? <SecuriteIntro /> : null}
169 +
170 + <ContactForm
171 + slug={cat.slug}
172 + fields={cat.fields}
173 + defaults={defaults}
174 + kaConnected={!!user}
175 + />
176 + </main>
177 + );
178 +}
added src/app/contact/icons.tsx +92 −0
@@ -0,0 +1,92 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA Communication Hub — pictogrammes des catégories (trait 1.8, style du site).
3 +const P = {
4 + handshake: (
5 + <>
6 + <path d="M11 17l-1.5 1.5a2.1 2.1 0 0 1-3-3L11 11l2.5-1.5a3 3 0 0 1 3 0L19 11l3 3" />
7 + <path d="M2 14l4-4 3-2" />
8 + <path d="M14 18l1.5 1.5a2.1 2.1 0 0 0 3-3" />
9 + <path d="M2 10l4 8" />
10 + <path d="M22 10l-3 8" />
11 + </>
12 + ),
13 + data: (
14 + <>
15 + <ellipse cx="12" cy="5" rx="8" ry="3" />
16 + <path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5" />
17 + <path d="M4 11v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6" />
18 + </>
19 + ),
20 + chart: (
21 + <>
22 + <path d="M3 3v18h18" />
23 + <path d="M7 15l4-5 3 3 5-7" />
24 + <circle cx="19" cy="6" r="1.4" />
25 + </>
26 + ),
27 + career: (
28 + <>
29 + <circle cx="12" cy="7" r="4" />
30 + <path d="M5 21v-2a7 7 0 0 1 14 0v2" />
31 + </>
32 + ),
33 + briefcase: (
34 + <>
35 + <rect x="3" y="7" width="18" height="13" rx="2" />
36 + <path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
37 + <path d="M3 13h18" />
38 + </>
39 + ),
40 + press: (
41 + <>
42 + <path d="M12 8a4 4 0 0 1 4 4v8H8v-8a4 4 0 0 1 4-4z" />
43 + <path d="M12 8V4" />
44 + <path d="M8 20h8" />
45 + <path d="M4 12H2M22 12h-2M6 6L4.5 4.5M18 6l1.5-1.5" />
46 + </>
47 + ),
48 + chat: (
49 + <>
50 + <path d="M21 11.5a8.38 8.38 0 0 1-9 8.35 8.5 8.5 0 0 1-3.4-.65L3 21l1.8-5.6A8.38 8.38 0 0 1 12 3a8.38 8.38 0 0 1 9 8.5z" />
51 + <path d="M8 11h8M8 14h5" />
52 + </>
53 + ),
54 + shield: (
55 + <>
56 + <path d="M12 22s8-3.5 8-10V5l-8-3-8 3v7c0 6.5 8 10 8 10z" />
57 + <path d="M9 12l2 2 4-4" />
58 + </>
59 + ),
60 + alert: (
61 + <>
62 + <path d="M10.3 3.9L1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" />
63 + <path d="M12 9v4M12 17h.01" />
64 + </>
65 + ),
66 + spark: (
67 + <>
68 + <path d="M12 3v4M12 17v4M3 12h4M17 12h4" />
69 + <path d="M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8" />
70 + </>
71 + ),
72 +} as const;
73 +
74 +export type IconName = keyof typeof P;
75 +
76 +export function CategoryIcon({ name, className }: { name: string; className?: string }) {
77 + const paths = P[name as IconName] ?? P.spark;
78 + return (
79 + <svg
80 + viewBox="0 0 24 24"
81 + className={className}
82 + aria-hidden="true"
83 + fill="none"
84 + stroke="currentColor"
85 + strokeWidth="1.8"
86 + strokeLinecap="round"
87 + strokeLinejoin="round"
88 + >
89 + {paths}
90 + </svg>
91 + );
92 +}
added src/app/contact/page.tsx +91 −0
@@ -0,0 +1,91 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /contact — le centre de communication du Groupe KA.
3 +// Fini les adresses courriel publiques : chaque type de relation (partenariat,
4 +// données, investisseur, candidature, fournisseur, média, légal, sécurité…)
5 +// a sa porte d'entrée, son formulaire structuré et son numéro de suivi.
6 +import type { Metadata } from "next";
7 +import { CATEGORIES } from "@/lib/comms/categories";
8 +import { CategoryIcon } from "./icons";
9 +
10 +export const metadata: Metadata = {
11 + title: "Nous joindre — le centre de communication",
12 + description:
13 + "Partenariats, données & API, investisseurs, carrières, fournisseurs, médias, questions, légal & Loi 25, sécurité : chaque demande passe par un formulaire dédié, reçoit un numéro de suivi et est lue par l'équipe concernée.",
14 +};
15 +
16 +const STEPS = [
17 + ["01", "Choisissez votre porte d'entrée", "Dix catégories, chacune avec un formulaire pensé pour son contexte."],
18 + ["02", "Recevez votre numéro de demande", "Chaque soumission est enregistrée et reçoit une référence du type KA-PAR-2026-0042."],
19 + ["03", "Une vraie personne vous répond", "Votre demande arrive directement dans l'inbox de l'équipe concernée — priorisée, assignée, suivie."],
20 +] as const;
21 +
22 +export default function ContactPage() {
23 + return (
24 + <main className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
25 + <p className="kicker">Centre de communication · Groupe KA</p>
26 + <h1 className="gk-display mt-3 text-[clamp(30px,5.5vw,56px)] leading-[1.0] font-bold tracking-[-0.035em] uppercase">
27 + Nous <span className="hl">joindre</span>
28 + </h1>
29 + <p className="mt-5 max-w-2xl text-[15px] leading-relaxed text-ink-2">
30 + Pas d&apos;adresse courriel à deviner, pas de boîte noire — comme pour
31 + nos données. Choisissez le type de demande : elle est enregistrée dans
32 + notre centre de communication, reçoit un{" "}
33 + <strong className="text-ink">numéro de suivi</strong> et est lue par
34 + l&apos;équipe concernée. Connecté avec votre{" "}
35 + <a href="/connexion?next=/contact" className="font-bold text-green underline underline-offset-4">
36 + KA&nbsp;ID
37 + </a>{" "}
38 + ? Vos informations sont préremplies et vos demandes liées à votre compte.
39 + </p>
40 +
41 + {/* comment ça marche */}
42 + <div className="mt-10 grid gap-x-10 gap-y-6 sm:grid-cols-3">
43 + {STEPS.map(([num, title, body]) => (
44 + <article key={num} className="rule-ink pt-4">
45 + <span className="gk-mono tnum text-[14px] font-bold text-green">{num}</span>
46 + <h2 className="gk-display mt-2 text-[15.5px] font-bold">{title}</h2>
47 + <p className="mt-2 text-[13px] leading-relaxed text-ink-2">{body}</p>
48 + </article>
49 + ))}
50 + </div>
51 +
52 + {/* les portes d'entrée */}
53 + <div className="sec-head mt-14">
54 + <p className="kicker">Choisissez votre porte d&apos;entrée</p>
55 + <span className="sec-index">{CATEGORIES.length} catégories</span>
56 + </div>
57 + <div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
58 + {CATEGORIES.map((c) => (
59 + <a
60 + key={c.slug}
61 + href={`/contact/${c.slug}`}
62 + className="gk-card gk-card-hover group flex flex-col p-6 no-underline"
63 + >
64 + <div className="flex items-start justify-between gap-3">
65 + <span className="flex h-12 w-12 items-center justify-center rounded-xl border-[1.5px] border-ink bg-ink text-lime">
66 + <CategoryIcon name={c.icon} className="h-6 w-6" />
67 + </span>
68 + <span className="gk-mono text-[10px] font-bold tracking-[0.1em] text-ink-3 uppercase">
69 + KA-{c.prefix}
70 + </span>
71 + </div>
72 + <h2 className="gk-display mt-4 text-[18px] leading-tight font-bold">{c.title}</h2>
73 + <p className="mt-2 flex-1 text-[13px] leading-relaxed text-ink-2">{c.tagline}</p>
74 + <span className="cta-link mt-5 !border-b-0 text-[13.5px]">Ouvrir le formulaire</span>
75 + </a>
76 + ))}
77 + </div>
78 +
79 + <p className="gk-mono mt-12 max-w-3xl text-[11px] leading-relaxed text-ink-3">
80 + Les demandes investisseurs, légales et de sécurité sont traitées avec un
81 + niveau de confidentialité élevé, par les seules personnes habilitées.
82 + Les signalements de sécurité peuvent être anonymes. Renseignements
83 + personnels traités selon la{" "}
84 + <a href="/loi-25" className="underline underline-offset-2">Loi 25</a> et notre{" "}
85 + <a href="/confidentialite" className="underline underline-offset-2">
86 + politique de confidentialité
87 + </a>.
88 + </p>
89 + </main>
90 + );
91 +}
modified src/app/globals.css +102 −0
@@ -1134,3 +1134,105 @@ h4 {
1134 1134 margin: 14mm 12mm;
1135 1135 }
1136 1136 }
1137 +
1138 +/* ==================================================================
1139 + PANEL /admin — KA Communication Hub
1140 + Coquille dédiée : le chrome public (header, ticker, footer, overlay
1141 + mobile, widget agent) est masqué dès que la page porte
1142 + [data-admin-root]. Design « outil interne » : dense, sobre, rapide.
1143 + ================================================================== */
1144 +
1145 +body:has([data-admin-root]) > header,
1146 +body:has([data-admin-root]) > .ticker,
1147 +body:has([data-admin-root]) > footer,
1148 +body:has([data-admin-root]) > .gk-mobile-overlay,
1149 +body:has([data-admin-root]) [class*="kaa-"] {
1150 + display: none !important;
1151 +}
1152 +body:has([data-admin-root]) {
1153 + background: #eeece5;
1154 +}
1155 +
1156 +/* Pastilles de statut/priorité de l'inbox */
1157 +.adm-chip {
1158 + display: inline-flex;
1159 + align-items: center;
1160 + gap: 6px;
1161 + padding: 3px 10px 4px;
1162 + border-radius: 999px;
1163 + border: 1.5px solid var(--ink);
1164 + font-family: var(--font-mono);
1165 + font-size: 10px;
1166 + font-weight: 700;
1167 + letter-spacing: 0.08em;
1168 + text-transform: uppercase;
1169 + white-space: nowrap;
1170 + background: var(--surface);
1171 + color: var(--ink);
1172 +}
1173 +.adm-chip--urgente {
1174 + background: var(--danger);
1175 + border-color: var(--danger);
1176 + color: #fff;
1177 +}
1178 +.adm-chip--haute {
1179 + background: var(--amber-soft);
1180 + border-color: var(--amber);
1181 + color: #7a4d0d;
1182 +}
1183 +.adm-chip--basse {
1184 + border-color: var(--line-strong);
1185 + color: var(--ink-3);
1186 +}
1187 +.adm-chip--lime {
1188 + background: var(--lime);
1189 +}
1190 +.adm-chip--ghost {
1191 + border-style: dashed;
1192 + color: var(--ink-3);
1193 +}
1194 +
1195 +/* Rangée d'inbox : lisible, dense, non-lu marqué à gauche */
1196 +.adm-row {
1197 + display: block;
1198 + border-top: 1px solid var(--line);
1199 + padding: 12px 14px;
1200 + text-decoration: none;
1201 + color: inherit;
1202 + transition: background 0.12s ease;
1203 +}
1204 +.adm-row:hover {
1205 + background: rgba(255, 255, 255, 0.85);
1206 +}
1207 +.adm-row--unread {
1208 + background: #ffffff;
1209 + box-shadow: inset 3px 0 0 var(--green);
1210 +}
1211 +
1212 +/* Navigation latérale du panel */
1213 +.adm-nav-link {
1214 + display: flex;
1215 + align-items: center;
1216 + justify-content: space-between;
1217 + gap: 10px;
1218 + padding: 9px 12px;
1219 + border-radius: 8px;
1220 + font-family: var(--font-display);
1221 + font-size: 13.5px;
1222 + font-weight: 700;
1223 + color: rgba(245, 243, 238, 0.72);
1224 + text-decoration: none;
1225 + transition: background 0.12s ease, color 0.12s ease;
1226 +}
1227 +.adm-nav-link:hover {
1228 + color: var(--lime);
1229 + background: rgba(217, 242, 107, 0.08);
1230 +}
1231 +.adm-nav-link--active {
1232 + color: var(--ink);
1233 + background: var(--lime);
1234 +}
1235 +.adm-nav-link--active:hover {
1236 + color: var(--ink);
1237 + background: var(--lime);
1238 +}
modified src/app/investisseurs/page.tsx +23 −2
@@ -139,11 +139,32 @@ export default function InvestisseursPage() {
139 139 ))}
140 140 </div>
141 141
142 + {/* Discussion d'investissement : la porte d'entrée officielle est le
143 + centre de communication (demandes confidentielles, suivies). */}
144 + <div className="gk-card mt-12 flex flex-wrap items-center justify-between gap-5 p-7">
145 + <div className="min-w-0">
146 + <p className="kicker">Discuter avec le groupe</p>
147 + <h2 className="gk-display mt-2 text-[clamp(19px,3vw,26px)] leading-tight font-bold tracking-[-0.03em]">
148 + Investissement, partenariat financier, acquisition&nbsp;?
149 + </h2>
150 + <p className="mt-2 max-w-xl text-[13.5px] text-ink-2">
151 + Les demandes investisseurs passent par le centre de communication —
152 + confidentielles, suivies, traitées par les relations investisseurs.
153 + </p>
154 + </div>
155 + <a href="/contact/investisseurs" className="btn btn-primary flex-none">
156 + Ouvrir le formulaire investisseurs
157 + </a>
158 + </div>
159 +
142 160 <p className="gk-mono mt-10 text-[11px] leading-relaxed text-ink-3">
143 161 Les prévisions sont préparées par la direction et non auditées ; les
144 162 coûts de développement sont mesurés à partir des journaux de sessions.
145 − Questions d&apos;affaires et partenariats : contact@groupe-ka.com.
146 − Documents produits avec Claude Code sur l&apos;infrastructure du groupe.
163 + Questions d&apos;affaires et partenariats :{" "}
164 + <a href="/contact/investisseurs" className="underline underline-offset-4">
165 + formulaire Investisseurs
166 + </a>
167 + . Documents produits avec Claude Code sur l&apos;infrastructure du groupe.
147 168 </p>
148 169 </main>
149 170 );
modified src/app/layout.tsx +26 −18
@@ -86,7 +86,12 @@ const JSON_LD = {
86 86 logo: `${SITE_URL}og.png`,
87 87 description:
88 88 "Holding québécois d’agrégateurs de produits et services entièrement automatisés — zéro saisie manuelle, zéro boîte noire.",
89 − email: "contact@groupe-ka.com",
89 + contactPoint: {
90 + "@type": "ContactPoint",
91 + url: `${SITE_URL}contact`,
92 + contactType: "customer support",
93 + availableLanguage: "fr-CA",
94 + },
90 95 areaServed: {
91 96 "@type": "AdministrativeArea",
92 97 name: "Québec, Canada",
@@ -126,7 +131,7 @@ const NAV_ITEMS = [
126 131 { href: "/telecharger", label: "Télécharger" },
127 132 { href: "/marque", label: "Marque" },
128 133 { href: "/status", label: "Statut" },
129 − { href: "/#contact", label: "Contact" },
134 + { href: "/contact", label: "Contact" },
130 135 ];
131 136
132 137 // Nav mobile = NAV_ITEMS + « Recherche » (sur desktop, la barre de recherche
@@ -222,7 +227,7 @@ export default async function RootLayout({
222 227 </li>
223 228 <li>
224 229 <a
225 − href="mailto:contact@groupe-ka.com"
230 + href="/contact"
226 231 className="gk-display ml-2 inline-block rounded-full bg-ink px-4 py-[8px] text-[13.5px] font-bold text-lime transition-colors hover:bg-green-deep"
227 232 >
228 233 Écrivez-nous
@@ -280,11 +285,8 @@ export default async function RootLayout({
280 285 </li>
281 286 ))}
282 287 <li className="pt-6">
283 − <a
284 − href="mailto:contact@groupe-ka.com"
285 − className="btn btn-primary w-full"
286 − >
287 − contact@groupe-ka.com
288 + <a href="/contact" className="btn btn-primary w-full">
289 + Nous joindre
288 290 </a>
289 291 </li>
290 292 </ul>
@@ -365,30 +367,36 @@ export default async function RootLayout({
365 367 </li>
366 368 ))}
367 369 </ul>
370 + {/* Centre de communication : les demandes passent par des
371 + formulaires structurés (suivies, priorisées) — plus aucune
372 + adresse courriel publique. */}
368 373 <div className="mt-8 grid gap-x-8 gap-y-4 border-t border-[rgba(245,243,238,0.15)] pt-7 sm:grid-cols-3">
369 374 {[
370 375 {
371 − email: "contact@groupe-ka.com",
372 − role: "Projets, partenariats & données",
376 + href: "/contact/partenariats",
377 + label: "Projets, partenariats & données",
378 + hint: "aussi : données & API, fournisseurs, investisseurs",
373 379 },
374 380 {
375 − email: "info@groupe-ka.com",
376 − role: "Médias & questions générales",
381 + href: "/contact/general",
382 + label: "Médias & questions générales",
383 + hint: "presse, entrevues, questions sur un site",
377 384 },
378 385 {
379 − email: "admin@groupe-ka.com",
380 − role: "Légal, vie privée & Loi 25",
386 + href: "/contact/legal",
387 + label: "Légal, vie privée & Loi 25",
388 + hint: "aussi : sécurité & signalements (anonymes possibles)",
381 389 },
382 390 ].map((c) => (
383 − <p key={c.email} className="text-[12px]">
391 + <p key={c.href} className="text-[12px]">
384 392 <a
385 − href={`mailto:${c.email}`}
393 + href={c.href}
386 394 className="gk-mono font-bold text-[rgba(245,243,238,0.85)] underline-offset-4 hover:text-lime hover:underline"
387 395 >
388 − {c.email}
396 + {c.label} →
389 397 </a>
390 398 <span className="mt-1 block text-[11px] text-[rgba(245,243,238,0.5)]">
391 − {c.role}
399 + {c.hint}
392 400 </span>
393 401 </p>
394 402 ))}
modified src/app/loi-25/page.tsx +8 −8
@@ -154,9 +154,9 @@ export default function Loi25() {
154 154 <p>
155 155 <strong className="text-ink">Simon-Pierre Boucher</strong>
156 156 <br />
157 − Courriel :{" "}
158 − <a className={MAILTO} href="mailto:admin@groupe-ka.com">
159 − admin@groupe-ka.com
157 + Contact :{" "}
158 + <a className={MAILTO} href="/contact/legal">
159 + formulaire Légal, vie privée &amp; Loi 25
160 160 </a>
161 161 <br />
162 162 Groupe KA, Québec (Canada)
@@ -448,9 +448,9 @@ export default function Loi25() {
448 448 </p>
449 449 <ul className="space-y-2">
450 450 <Bullet>
451 − Écrivez-nous à{" "}
452 − <a className={MAILTO} href="mailto:admin@groupe-ka.com">
453 − admin@groupe-ka.com
451 + Utilisez le{" "}
452 + <a className={MAILTO} href="/contact/legal">
453 + formulaire Légal, vie privée &amp; Loi 25
454 454 </a>{" "}
455 455 en indiquant le lien (URL) de la page concernée sur notre site;
456 456 </Bullet>
@@ -511,10 +511,10 @@ export default function Loi25() {
511 511 <p className="gk-mono mt-8 text-[11px] text-ink-3">
512 512 Pour toute question :{" "}
513 513 <a
514 − href="mailto:admin@groupe-ka.com"
514 + href="/contact/legal"
515 515 className="underline underline-offset-4 hover:text-ink"
516 516 >
517 − admin@groupe-ka.com
517 + le formulaire Légal, vie privée &amp; Loi 25
518 518 </a>{" "}
519 519 · Voir aussi :{" "}
520 520 <a
modified src/app/marque/page.tsx +5 −1
@@ -543,7 +543,11 @@ export default function MarquePage() {
543 543 Palettes lues en direct dans ecosystem.json (ka-ui) — les valeurs
544 544 affichées ne peuvent pas dériver du code. Logos collectés depuis les
545 545 sites en production le 2026-08-23. Questions de marque et demandes
546 − presse : info@groupe-ka.com.
546 + presse :{" "}
547 + <a href="/contact/medias" className="underline underline-offset-4">
548 + formulaire Médias &amp; presse
549 + </a>
550 + .
547 551 </p>
548 552 </section>
549 553 </main>
added src/app/nous-joindre/page.tsx +7 −0
@@ -0,0 +1,7 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /nous-joindre — alias francophone du centre de communication.
3 +import { redirect } from "next/navigation";
4 +
5 +export default function NousJoindre() {
6 + redirect("/contact");
7 +}
modified src/app/page.tsx +7 −9
@@ -1337,19 +1337,17 @@ export default async function Home() {
1337 1337 ramener à un seul endroit.
1338 1338 </p>
1339 1339 <div className="mt-9 flex flex-wrap items-center gap-x-8 gap-y-4">
1340 − <a
1341 − href="mailto:contact@groupe-ka.com"
1342 − className="btn btn-lime !border-lime"
1343 − >
1344 − contact@groupe-ka.com
1340 + <a href="/contact" className="btn btn-lime !border-lime">
1341 + Ouvrir le centre de communication
1345 1342 </a>
1346 − <a href="mailto:info@groupe-ka.com" className="cta-link cta-link--paper">
1347 − info@groupe-ka.com
1343 + <a href="/contact/partenariats" className="cta-link cta-link--paper">
1344 + Proposer un projet
1348 1345 </a>
1349 1346 </div>
1350 1347 <p className="gk-mono mt-7 text-[11px] text-[rgba(245,243,238,0.5)]">
1351 − contact@ — projets &amp; partenariats · info@ — médias &amp;
1352 − questions générales · admin@ — légal &amp; vie privée
1348 + partenariats · données &amp; API · investisseurs · carrières ·
1349 + fournisseurs · médias · légal &amp; Loi 25 · sécurité — chaque
1350 + demande reçoit un numéro de suivi
1353 1351 </p>
1354 1352 </div>
1355 1353 </section>
modified src/app/retrait/page.tsx +18 −18
@@ -115,9 +115,9 @@ export default function Retrait() {
115 115 Vrai-Prix et ValoPlex.
116 116 </p>
117 117 <p className="rise mt-4 max-w-2xl text-[15px] text-ink-2 [animation-delay:0.2s]">
118 − Toutes les demandes se font par courriel à{" "}
119 − <a className={MAILTO} href="mailto:admin@groupe-ka.com">
120 − admin@groupe-ka.com
118 + Toutes les demandes se font via le{" "}
119 + <a className={MAILTO} href="/contact/legal">
120 + formulaire Légal, vie privée &amp; Loi 25
121 121 </a>{" "}
122 122 et sont traitées dans un délai de{" "}
123 123 <strong className="text-ink">48 à 72 heures ouvrables</strong>.
@@ -214,11 +214,11 @@ export default function Retrait() {
214 214
215 215 <Card num="A3" title="Comment faire votre demande" index={2}>
216 216 <p>
217 − Envoyez un courriel à{" "}
218 − <a className={MAILTO} href="mailto:admin@groupe-ka.com">
219 − admin@groupe-ka.com
217 + Soumettez le{" "}
218 + <a className={MAILTO} href="/contact/legal">
219 + formulaire Légal, vie privée &amp; Loi 25
220 220 </a>{" "}
221 − contenant :
221 + en indiquant :
222 222 </p>
223 223 <Numbered
224 224 items={[
@@ -336,9 +336,9 @@ export default function Retrait() {
336 336 demander une révision.
337 337 </p>
338 338 <p>
339 − <strong className="text-ink">Comment faire :</strong> écrivez à{" "}
340 − <a className={MAILTO} href="mailto:admin@groupe-ka.com">
341 − admin@groupe-ka.com
339 + <strong className="text-ink">Comment faire :</strong> utilisez le{" "}
340 + <a className={MAILTO} href="/contact/legal">
341 + formulaire Légal, vie privée &amp; Loi 25
342 342 </a>{" "}
343 343 avec :
344 344 </p>
@@ -393,9 +393,9 @@ export default function Retrait() {
393 393 votre décision.
394 394 </p>
395 395 <p>
396 − <strong className="text-ink">Comment faire :</strong> écrivez à{" "}
397 − <a className={MAILTO} href="mailto:admin@groupe-ka.com">
398 − admin@groupe-ka.com
396 + <strong className="text-ink">Comment faire :</strong> utilisez le{" "}
397 + <a className={MAILTO} href="/contact/legal">
398 + formulaire Légal, vie privée &amp; Loi 25
399 399 </a>{" "}
400 400 avec :
401 401 </p>
@@ -487,9 +487,9 @@ export default function Retrait() {
487 487 <strong className="text-ink">Refus :</strong> tout refus est
488 488 motivé par écrit. Vous pouvez alors compléter votre demande ou
489 489 la contester auprès de notre responsable de la protection des
490 − renseignements personnels (
491 − <a className={MAILTO} href="mailto:admin@groupe-ka.com">
492 − admin@groupe-ka.com
490 + renseignements personnels (via le{" "}
491 + <a className={MAILTO} href="/contact/legal">
492 + formulaire légal &amp; vie privée
493 493 </a>
494 494 ).
495 495 </Bullet>
@@ -532,10 +532,10 @@ export default function Retrait() {
532 532 </a>
533 533 . Contact :{" "}
534 534 <a
535 − href="mailto:admin@groupe-ka.com"
535 + href="/contact/legal"
536 536 className="underline underline-offset-4 hover:text-ink"
537 537 >
538 − admin@groupe-ka.com
538 + le formulaire Légal, vie privée &amp; Loi 25
539 539 </a>
540 540 </p>
541 541 </main>
modified src/app/robots.ts +1 −1
@@ -9,7 +9,7 @@ export default function robots(): MetadataRoute.Robots {
9 9 userAgent: "*",
10 10 allow: "/",
11 11 // Routes techniques ou de compte : rien à indexer.
12 − disallow: ["/api/", "/compte", "/sso"],
12 + disallow: ["/api/", "/compte", "/sso", "/admin"],
13 13 },
14 14 ],
15 15 sitemap: "https://www.groupe-ka.com/sitemap.xml",
modified src/app/sitemap.ts +10 −0
@@ -14,6 +14,16 @@ export default function sitemap(): MetadataRoute.Sitemap {
14 14 priority: number;
15 15 }[] = [
16 16 { path: "/", changeFrequency: "daily", priority: 1 },
17 + { path: "/contact", changeFrequency: "monthly", priority: 0.8 },
18 + { path: "/contact/partenariats", changeFrequency: "monthly", priority: 0.6 },
19 + { path: "/contact/data", changeFrequency: "monthly", priority: 0.6 },
20 + { path: "/contact/investisseurs", changeFrequency: "monthly", priority: 0.6 },
21 + { path: "/contact/carrieres", changeFrequency: "monthly", priority: 0.6 },
22 + { path: "/contact/fournisseurs", changeFrequency: "monthly", priority: 0.5 },
23 + { path: "/contact/medias", changeFrequency: "monthly", priority: 0.5 },
24 + { path: "/contact/general", changeFrequency: "monthly", priority: 0.5 },
25 + { path: "/contact/legal", changeFrequency: "monthly", priority: 0.5 },
26 + { path: "/contact/securite", changeFrequency: "monthly", priority: 0.5 },
17 27 { path: "/recherche", changeFrequency: "weekly", priority: 0.7 },
18 28 { path: "/stats", changeFrequency: "daily", priority: 0.8 },
19 29 { path: "/rapports", changeFrequency: "daily", priority: 0.7 },
added src/lib/comms/admin-auth.ts +130 −0
@@ -0,0 +1,130 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA Communication Hub — sessions admin (/admin) + RBAC côté serveur.
3 +// Cookie distinct de ka_session : le panel est un outil interne, ses comptes
4 +// (admin_users) ne sont pas des KA ID. Le jeton embarque une empreinte du
5 +// mot de passe : changer le mot de passe invalide toutes les sessions.
6 +import { SignJWT, jwtVerify } from "jose";
7 +import { cookies } from "next/headers";
8 +import crypto from "crypto";
9 +import { findAdminById, type AdminRow, type SubmissionRow } from "./db";
10 +
11 +export const ADMIN_COOKIE = "ka_admin";
12 +const ISSUER = "https://www.groupe-ka.com/admin";
13 +
14 +function secret(): Uint8Array {
15 + const s = process.env.KA_AUTH_SECRET;
16 + if (!s) {
17 + if (process.env.NODE_ENV === "production")
18 + throw new Error("KA_AUTH_SECRET manquant");
19 + return new TextEncoder().encode("ka-dev-secret-non-production");
20 + }
21 + return new TextEncoder().encode(s);
22 +}
23 +
24 +function pwFingerprint(hash: string): string {
25 + return crypto.createHash("sha256").update(hash).digest("hex").slice(0, 12);
26 +}
27 +
28 +/* ---------- rôles ---------- */
29 +
30 +export { ADMIN_ROLES } from "./categories";
31 +
32 +/** Catégories sensibles → rôle dédié requis (en plus de super_admin). */
33 +export const CATEGORY_ROLE: Record<string, string> = {
34 + investisseurs: "investor_relations",
35 + legal: "legal",
36 + securite: "security",
37 + carrieres: "hr",
38 + medias: "media",
39 +};
40 +
41 +/** Catégories dont l'accès est limité même pour le rôle « admin ». */
42 +export const SENSITIVE_CATEGORIES = new Set(["investisseurs", "legal", "securite"]);
43 +
44 +export type AdminSession = {
45 + id: number;
46 + username: string;
47 + displayName: string;
48 + roles: string[];
49 +};
50 +
51 +export function parseRoles(raw: string): string[] {
52 + try {
53 + const arr = JSON.parse(raw) as unknown;
54 + return Array.isArray(arr) ? arr.filter((r): r is string => typeof r === "string") : [];
55 + } catch {
56 + return [];
57 + }
58 +}
59 +
60 +export async function mintAdminToken(admin: AdminRow): Promise<string> {
61 + return await new SignJWT({ adm: admin.id, pwv: pwFingerprint(admin.password_hash) })
62 + .setProtectedHeader({ alg: "HS256" })
63 + .setIssuedAt()
64 + .setIssuer(ISSUER)
65 + .setExpirationTime("12h")
66 + .sign(secret());
67 +}
68 +
69 +export const adminCookieOptions = {
70 + httpOnly: true,
71 + secure: process.env.NODE_ENV === "production",
72 + sameSite: "lax" as const,
73 + path: "/",
74 + maxAge: 60 * 60 * 12,
75 +};
76 +
77 +export async function getAdminSession(): Promise<AdminSession | null> {
78 + const jar = await cookies();
79 + const token = jar.get(ADMIN_COOKIE)?.value;
80 + if (!token) return null;
81 + try {
82 + const { payload } = await jwtVerify(token, secret(), { issuer: ISSUER });
83 + const row = findAdminById(Number(payload.adm));
84 + if (!row || !row.active) return null;
85 + // Le mot de passe a changé depuis l'émission → session invalide.
86 + if (payload.pwv !== pwFingerprint(row.password_hash)) return null;
87 + return {
88 + id: row.id,
89 + username: row.username,
90 + displayName: row.display_name,
91 + roles: parseRoles(row.roles),
92 + };
93 + } catch {
94 + return null;
95 + }
96 +}
97 +
98 +/* ---------- règles d'accès (appliquées SERVEUR, jamais confiance client) ---------- */
99 +
100 +export function isSuperAdmin(s: AdminSession): boolean {
101 + return s.roles.includes("super_admin");
102 +}
103 +
104 +/** L'admin peut-il voir les demandes de cette catégorie ? */
105 +export function canViewCategory(s: AdminSession, category: string): boolean {
106 + if (isSuperAdmin(s)) return true;
107 + const dedicated = CATEGORY_ROLE[category];
108 + if (dedicated && s.roles.includes(dedicated)) return true;
109 + if (SENSITIVE_CATEGORIES.has(category))
110 + // Sensible sans rôle dédié : seul « admin » passe (le niveau
111 + // « restreinte » est ensuite re-filtré par canViewSubmission).
112 + return s.roles.includes("admin");
113 + return s.roles.includes("admin") || s.roles.includes("support");
114 +}
115 +
116 +/** Contrôle d'accès PAR demande (catégorie + niveau de confidentialité). */
117 +export function canViewSubmission(s: AdminSession, sub: SubmissionRow): boolean {
118 + if (!canViewCategory(s, sub.category)) return false;
119 + if (sub.confidentiality === "restreinte") {
120 + // Restreinte : super_admin ou rôle dédié de la catégorie, personne d'autre.
121 + const dedicated = CATEGORY_ROLE[sub.category];
122 + return isSuperAdmin(s) || (!!dedicated && s.roles.includes(dedicated));
123 + }
124 + return true;
125 +}
126 +
127 +/** Liste des catégories visibles — pour filtrer les requêtes SQL de l'inbox. */
128 +export function visibleCategories(s: AdminSession, all: string[]): string[] {
129 + return all.filter((c) => canViewCategory(s, c));
130 +}
added src/lib/comms/categories.ts +658 −0
@@ -0,0 +1,658 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA Communication Hub — registre des catégories de demandes.
3 +// UNE source de vérité pour le public ET l'admin : chaque catégorie décrit
4 +// sa carte (/contact), son formulaire (champs, validation), son préfixe de
5 +// référence, sa confidentialité par défaut et sa règle de priorité.
6 +// Les champs « cœur » (CORE_FIELDS) vont en colonnes SQL filtrables ;
7 +// tout le reste va proprement dans metadata (JSON).
8 +
9 +export type FieldDef = {
10 + name: string;
11 + label: string;
12 + type:
13 + | "text"
14 + | "email"
15 + | "tel"
16 + | "url"
17 + | "textarea"
18 + | "select"
19 + | "checkbox"
20 + | "file"
21 + | "date";
22 + required?: boolean;
23 + options?: string[];
24 + placeholder?: string;
25 + help?: string;
26 + /** demi-largeur sur desktop (grille 2 colonnes) */
27 + half?: boolean;
28 + rows?: number;
29 + maxLen?: number;
30 + /** extensions acceptées pour type=file (ex. ".pdf,.doc,.docx") */
31 + accept?: string;
32 + /** champ affiché (et requis) seulement si un autre champ a cette valeur */
33 + showIf?: { field: string; equals: string };
34 + /** pour type=checkbox : affiché seulement si la case N'EST PAS cochée */
35 + showIfUnchecked?: string;
36 +};
37 +
38 +export type CategoryDef = {
39 + slug: string;
40 + prefix: string;
41 + title: string;
42 + short: string;
43 + tagline: string;
44 + intro: string;
45 + icon: string;
46 + confidentiality: "standard" | "elevee";
47 + subCategoryFrom?: string;
48 + fields: FieldDef[];
49 +};
50 +
51 +/** Champs qui vont en colonnes SQL (filtrables) — le reste part en metadata. */
52 +export const CORE_FIELDS = new Set([
53 + "first_name",
54 + "last_name",
55 + "email",
56 + "phone",
57 + "organization",
58 + "job_title",
59 + "website",
60 + "linkedin_url",
61 + "github_url",
62 + "site_concerned",
63 + "subject",
64 + "message",
65 +]);
66 +
67 +export const SITES_KA = [
68 + "Groupe KA (portail)",
69 + "Trouve·Ka",
70 + "Lou·Ka",
71 + "Immo·Ka",
72 + "Vrai-Prix",
73 + "ValoPlex",
74 + "Auto·Ka",
75 + "Fabri·Ka",
76 + "Food·Ka",
77 + "Resto·Ka",
78 + "Sorti·Ka",
79 + "Créa·Ka",
80 + "API·Ka",
81 + "Job·Ka",
82 + "Ka·Stats",
83 + "Plusieurs sites / tout l'écosystème",
84 +];
85 +
86 +/* ---------- gabarits de champs réutilisés ---------- */
87 +
88 +const IDENT: FieldDef[] = [
89 + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 },
90 + { name: "last_name", label: "Nom", type: "text", required: true, half: true, maxLen: 80 },
91 + { name: "email", label: "Courriel", type: "email", required: true, half: true, maxLen: 200 },
92 + { name: "phone", label: "Téléphone (facultatif)", type: "tel", half: true, maxLen: 40 },
93 +];
94 +
95 +/* ---------- le registre ---------- */
96 +
97 +export const CATEGORIES: CategoryDef[] = [
98 + {
99 + slug: "partenariats",
100 + prefix: "PAR",
101 + title: "Projets & partenariats",
102 + short: "Partenariats",
103 + tagline: "Construire quelque chose avec Groupe KA.",
104 + intro:
105 + "Partenariat stratégique, distribution, collaboration technologique, projet commun — décrivez ce que vous imaginez : de vraies personnes lisent chaque demande.",
106 + icon: "handshake",
107 + confidentiality: "standard",
108 + subCategoryFrom: "type_partenariat",
109 + fields: [
110 + ...IDENT,
111 + { name: "organization", label: "Organisation / entreprise", type: "text", required: true, half: true, maxLen: 160 },
112 + { name: "job_title", label: "Poste", type: "text", half: true, maxLen: 120 },
113 + { name: "website", label: "Site web", type: "url", half: true, maxLen: 300, placeholder: "https://" },
114 + {
115 + name: "type_partenariat",
116 + label: "Type de partenariat",
117 + type: "select",
118 + required: true,
119 + half: true,
120 + options: [
121 + "Partenariat stratégique",
122 + "Distribution",
123 + "Collaboration technologique",
124 + "Données",
125 + "Commercial",
126 + "Publicité",
127 + "Contenu",
128 + "Institutionnel",
129 + "Projet commun",
130 + "Autre",
131 + ],
132 + },
133 + { name: "message", label: "Description du projet", type: "textarea", required: true, rows: 6, maxLen: 8000 },
134 + { name: "objectif", label: "Objectif recherché", type: "textarea", required: true, rows: 3, maxLen: 2000 },
135 + { name: "echeancier", label: "Échéancier approximatif", type: "text", half: true, maxLen: 160, placeholder: "ex. T1 2027" },
136 + { name: "budget", label: "Budget approximatif (facultatif)", type: "text", half: true, maxLen: 120 },
137 + { name: "piece_jointe", label: "Pièce jointe (facultative)", type: "file", accept: ".pdf,.doc,.docx,.png,.jpg,.jpeg,.webp", help: "PDF, Word ou image — 10 Mo max." },
138 + ],
139 + },
140 + {
141 + slug: "data",
142 + prefix: "DAT",
143 + title: "Données, API & intégrations",
144 + short: "Données & API",
145 + tagline: "Accéder à nos données ou les intégrer.",
146 + intro:
147 + "Accès API, licence de données, dataset, partage ou intégration technique — dites-nous ce dont vous avez besoin, sur quels produits, et l'usage prévu.",
148 + icon: "data",
149 + confidentiality: "standard",
150 + subCategoryFrom: "type_demande",
151 + fields: [
152 + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 },
153 + { name: "last_name", label: "Nom", type: "text", required: true, half: true, maxLen: 80 },
154 + { name: "organization", label: "Organisation", type: "text", required: true, half: true, maxLen: 160 },
155 + { name: "job_title", label: "Poste", type: "text", half: true, maxLen: 120 },
156 + { name: "email", label: "Courriel professionnel", type: "email", required: true, half: true, maxLen: 200 },
157 + { name: "website", label: "Site web", type: "url", half: true, maxLen: 300, placeholder: "https://" },
158 + {
159 + name: "type_demande",
160 + label: "Type de demande",
161 + type: "select",
162 + required: true,
163 + half: true,
164 + options: [
165 + "Accès API",
166 + "Licence de données",
167 + "Dataset",
168 + "Partage de données",
169 + "Intégration technique",
170 + "Accès recherche",
171 + "Utilisation académique",
172 + "Autre",
173 + ],
174 + },
175 + { name: "site_concerned", label: "Produit Groupe KA concerné", type: "select", required: true, half: true, options: SITES_KA },
176 + { name: "volume", label: "Volume estimé", type: "text", half: true, maxLen: 160, placeholder: "ex. 50 000 requêtes / mois" },
177 + {
178 + name: "usage_commercial",
179 + label: "Usage",
180 + type: "select",
181 + required: true,
182 + half: true,
183 + options: ["Non commercial", "Commercial", "À déterminer"],
184 + },
185 + { name: "usage_prevu", label: "Usage prévu", type: "textarea", required: true, rows: 3, maxLen: 2000 },
186 + { name: "message", label: "Description détaillée", type: "textarea", required: true, rows: 6, maxLen: 8000 },
187 + { name: "besoin_sla", label: "Nous avons besoin d'un SLA", type: "checkbox", half: true },
188 + { name: "besoin_entente", label: "Nous avons besoin d'une entente contractuelle", type: "checkbox", half: true },
189 + ],
190 + },
191 + {
192 + slug: "investisseurs",
193 + prefix: "INV",
194 + title: "Investisseurs",
195 + short: "Investisseurs",
196 + tagline: "Discuter d'investissement ou de partenariats financiers.",
197 + intro:
198 + "Le Groupe KA est ouvert aux discussions avec les investisseurs privés, fonds, family offices, investisseurs stratégiques, partenaires financiers et entreprises intéressées à des collaborations ou acquisitions. Ces demandes sont traitées confidentiellement par les relations investisseurs.",
199 + icon: "chart",
200 + confidentiality: "elevee",
201 + subCategoryFrom: "nature",
202 + fields: [
203 + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 },
204 + { name: "last_name", label: "Nom", type: "text", required: true, half: true, maxLen: 80 },
205 + { name: "organization", label: "Organisation / fonds", type: "text", required: true, half: true, maxLen: 160 },
206 + { name: "job_title", label: "Poste", type: "text", required: true, half: true, maxLen: 120 },
207 + { name: "email", label: "Courriel professionnel", type: "email", required: true, half: true, maxLen: 200 },
208 + { name: "phone", label: "Téléphone", type: "tel", required: true, half: true, maxLen: 40 },
209 + { name: "linkedin_url", label: "Site web / LinkedIn (facultatif)", type: "url", half: true, maxLen: 300, placeholder: "https://" },
210 + {
211 + name: "type_investisseur",
212 + label: "Type d'investisseur",
213 + type: "select",
214 + required: true,
215 + half: true,
216 + options: [
217 + "Ange",
218 + "VC",
219 + "Private Equity",
220 + "Family Office",
221 + "Corporate Venture",
222 + "Institutionnel",
223 + "Investisseur stratégique",
224 + "Autre",
225 + ],
226 + },
227 + { name: "taille_typique", label: "Taille typique des investissements", type: "text", half: true, maxLen: 160, placeholder: "ex. 500 k$ – 2 M$" },
228 + { name: "secteurs", label: "Secteurs d'intérêt", type: "text", half: true, maxLen: 300 },
229 + { name: "site_concerned", label: "Société / projet Groupe KA concerné", type: "select", half: true, options: SITES_KA },
230 + {
231 + name: "nature",
232 + label: "Nature de la demande",
233 + type: "select",
234 + required: true,
235 + half: true,
236 + options: [
237 + "Demande d'information",
238 + "Investissement potentiel",
239 + "Partenariat financier",
240 + "Acquisition",
241 + "Joint venture",
242 + "Introduction",
243 + "Autre",
244 + ],
245 + },
246 + { name: "message", label: "Message", type: "textarea", required: true, rows: 6, maxLen: 8000 },
247 + { name: "piece_jointe", label: "Pièce jointe (facultative)", type: "file", accept: ".pdf,.doc,.docx", help: "PDF ou Word — 10 Mo max." },
248 + ],
249 + },
250 + {
251 + slug: "carrieres",
252 + prefix: "JOB",
253 + title: "Emplois & carrières",
254 + short: "Carrières",
255 + tagline: "Rejoindre Groupe KA.",
256 + intro:
257 + "Développement, IA, données, produit, design, opérations… Postulez à un poste, ou dites-nous simplement : « Je ne vois pas de poste correspondant, mais je veux travailler avec Groupe KA. » — les candidatures spontanées sont lues avec la même attention.",
258 + icon: "career",
259 + confidentiality: "standard",
260 + subCategoryFrom: "type_candidature",
261 + fields: [
262 + {
263 + name: "type_candidature",
264 + label: "Type de candidature",
265 + type: "select",
266 + required: true,
267 + options: [
268 + "Candidature à un poste",
269 + "Candidature spontanée — je ne vois pas de poste correspondant, mais je veux travailler avec Groupe KA",
270 + ],
271 + },
272 + ...IDENT,
273 + { name: "ville", label: "Ville", type: "text", half: true, maxLen: 120 },
274 + { name: "linkedin_url", label: "LinkedIn", type: "url", half: true, maxLen: 300, placeholder: "https://linkedin.com/in/…" },
275 + { name: "github_url", label: "GitHub / portfolio (facultatif)", type: "url", half: true, maxLen: 300, placeholder: "https://" },
276 + {
277 + name: "job_title",
278 + label: "Poste recherché",
279 + type: "text",
280 + required: true,
281 + half: true,
282 + maxLen: 160,
283 + placeholder: "ex. Développeur·euse TypeScript",
284 + showIf: { field: "type_candidature", equals: "Candidature à un poste" },
285 + },
286 + {
287 + name: "domaine",
288 + label: "Domaine",
289 + type: "select",
290 + required: true,
291 + half: true,
292 + options: [
293 + "Développement logiciel",
294 + "IA / ML",
295 + "Data",
296 + "Produit",
297 + "Design",
298 + "Marketing",
299 + "Ventes",
300 + "Finance",
301 + "Immobilier",
302 + "Opérations",
303 + "Juridique / conformité",
304 + "Stagiaire",
305 + "Autre",
306 + ],
307 + },
308 + {
309 + name: "type_emploi",
310 + label: "Type d'emploi",
311 + type: "select",
312 + required: true,
313 + half: true,
314 + options: ["Temps plein", "Temps partiel", "Stage", "Contrat", "Freelance"],
315 + },
316 + {
317 + name: "mode_travail",
318 + label: "Mode de travail",
319 + type: "select",
320 + half: true,
321 + options: ["Télétravail", "Hybride", "Présentiel", "Indifférent"],
322 + },
323 + { name: "cv", label: "CV", type: "file", required: true, accept: ".pdf,.doc,.docx", help: "PDF ou Word — 10 Mo max." },
324 + { name: "message", label: "Lettre ou message (facultatif)", type: "textarea", rows: 5, maxLen: 8000 },
325 + { name: "disponibilite", label: "Disponibilité", type: "text", half: true, maxLen: 160, placeholder: "ex. dès maintenant, 2 semaines de préavis…" },
326 + { name: "salaire", label: "Salaire souhaité (facultatif)", type: "text", half: true, maxLen: 120 },
327 + ],
328 + },
329 + {
330 + slug: "fournisseurs",
331 + prefix: "FOU",
332 + title: "Fournisseurs & services professionnels",
333 + short: "Fournisseurs",
334 + tagline: "Proposer un produit ou un service.",
335 + intro:
336 + "Hébergement, infrastructure, cybersécurité, données, publicité, juridique, comptabilité, consultation, recrutement, design, développement, immobilier, assurances… Présentez votre offre et sa valeur pour le groupe.",
337 + icon: "briefcase",
338 + confidentiality: "standard",
339 + subCategoryFrom: "type_service",
340 + fields: [
341 + { name: "organization", label: "Entreprise", type: "text", required: true, half: true, maxLen: 160 },
342 + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 },
343 + { name: "last_name", label: "Nom", type: "text", required: true, half: true, maxLen: 80 },
344 + { name: "job_title", label: "Poste", type: "text", half: true, maxLen: 120 },
345 + { name: "email", label: "Courriel", type: "email", required: true, half: true, maxLen: 200 },
346 + { name: "phone", label: "Téléphone", type: "tel", half: true, maxLen: 40 },
347 + { name: "website", label: "Site web", type: "url", half: true, maxLen: 300, placeholder: "https://" },
348 + {
349 + name: "type_service",
350 + label: "Type de service",
351 + type: "select",
352 + required: true,
353 + half: true,
354 + options: [
355 + "Hébergement / infrastructure",
356 + "Cybersécurité",
357 + "Données",
358 + "Publicité / marketing",
359 + "Cabinet juridique",
360 + "Comptabilité",
361 + "Consultation",
362 + "Recrutement",
363 + "Design",
364 + "Développement",
365 + "Immobilier",
366 + "Assurances",
367 + "Autre",
368 + ],
369 + },
370 + { name: "site_concerned", label: "Produits Groupe KA potentiellement concernés", type: "select", half: true, options: SITES_KA },
371 + { name: "message", label: "Description", type: "textarea", required: true, rows: 5, maxLen: 8000 },
372 + { name: "proposition_valeur", label: "Proposition de valeur", type: "textarea", required: true, rows: 3, maxLen: 2000 },
373 + { name: "tarification", label: "Prix ou modèle tarifaire (facultatif)", type: "text", maxLen: 300 },
374 + { name: "documents", label: "Documents (facultatifs)", type: "file", accept: ".pdf,.doc,.docx,.png,.jpg,.jpeg,.webp", help: "PDF, Word ou image — 10 Mo max." },
375 + ],
376 + },
377 + {
378 + slug: "medias",
379 + prefix: "MED",
380 + title: "Médias & presse",
381 + short: "Médias",
382 + tagline: "Presse, entrevues et statistiques.",
383 + intro:
384 + "Entrevue, citation, données, communiqué, conférence — indiquez votre échéance : les demandes avec un délai serré sont priorisées automatiquement.",
385 + icon: "press",
386 + confidentiality: "standard",
387 + subCategoryFrom: "type_demande",
388 + fields: [
389 + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 },
390 + { name: "last_name", label: "Nom", type: "text", required: true, half: true, maxLen: 80 },
391 + { name: "organization", label: "Média / organisation", type: "text", required: true, half: true, maxLen: 160 },
392 + { name: "job_title", label: "Poste", type: "text", half: true, maxLen: 120 },
393 + { name: "email", label: "Courriel", type: "email", required: true, half: true, maxLen: 200 },
394 + { name: "phone", label: "Téléphone", type: "tel", half: true, maxLen: 40 },
395 + {
396 + name: "type_demande",
397 + label: "Type de demande",
398 + type: "select",
399 + required: true,
400 + half: true,
401 + options: [
402 + "Entrevue",
403 + "Citation",
404 + "Information",
405 + "Données / statistiques",
406 + "Communiqué",
407 + "Conférence",
408 + "Autre",
409 + ],
410 + },
411 + { name: "echeance", label: "Échéance (votre deadline)", type: "date", half: true, help: "Une échéance à moins de 48 h passe automatiquement en priorité haute." },
412 + { name: "subject", label: "Sujet", type: "text", required: true, maxLen: 240 },
413 + { name: "message", label: "Message", type: "textarea", required: true, rows: 5, maxLen: 8000 },
414 + ],
415 + },
416 + {
417 + slug: "general",
418 + prefix: "GEN",
419 + title: "Questions générales",
420 + short: "Questions",
421 + tagline: "Une question, un commentaire, une suggestion.",
422 + intro:
423 + "Question sur un site, commentaire, suggestion, problème technique ou problème avec une annonce — c'est ici.",
424 + icon: "chat",
425 + confidentiality: "standard",
426 + subCategoryFrom: "type_demande",
427 + fields: [
428 + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 },
429 + { name: "last_name", label: "Nom", type: "text", half: true, maxLen: 80 },
430 + { name: "email", label: "Courriel", type: "email", required: true, half: true, maxLen: 200 },
431 + { name: "site_concerned", label: "Site Groupe KA concerné", type: "select", required: true, half: true, options: SITES_KA },
432 + {
433 + name: "type_demande",
434 + label: "Type",
435 + type: "select",
436 + required: true,
437 + half: true,
438 + options: [
439 + "Question",
440 + "Commentaire",
441 + "Suggestion",
442 + "Problème technique",
443 + "Problème avec une annonce",
444 + "Autre",
445 + ],
446 + },
447 + { name: "subject", label: "Sujet", type: "text", required: true, half: true, maxLen: 240 },
448 + { name: "message", label: "Message", type: "textarea", required: true, rows: 6, maxLen: 8000 },
449 + ],
450 + },
451 + {
452 + slug: "legal",
453 + prefix: "LEG",
454 + title: "Légal, vie privée & Loi 25",
455 + short: "Vie privée & légal",
456 + tagline: "Demandes juridiques et Loi 25.",
457 + intro:
458 + "Accès, rectification ou suppression de renseignements personnels, retrait de consentement, propriété intellectuelle, droit d'auteur, question juridique — ces demandes sont traitées en priorité et en confidentialité par les personnes responsables (Loi 25).",
459 + icon: "shield",
460 + confidentiality: "elevee",
461 + subCategoryFrom: "type_demande",
462 + fields: [
463 + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 },
464 + { name: "last_name", label: "Nom", type: "text", required: true, half: true, maxLen: 80 },
465 + { name: "email", label: "Courriel", type: "email", required: true, half: true, maxLen: 200 },
466 + { name: "phone", label: "Téléphone (facultatif)", type: "tel", half: true, maxLen: 40 },
467 + {
468 + name: "type_demande",
469 + label: "Type de demande",
470 + type: "select",
471 + required: true,
472 + half: true,
473 + options: [
474 + "Vie privée",
475 + "Loi 25",
476 + "Accès aux renseignements personnels",
477 + "Rectification",
478 + "Suppression",
479 + "Retrait de consentement",
480 + "Signalement",
481 + "Propriété intellectuelle",
482 + "Droit d'auteur",
483 + "Question juridique",
484 + "Sécurité",
485 + "Autre",
486 + ],
487 + },
488 + { name: "site_concerned", label: "Site Groupe KA concerné", type: "select", required: true, half: true, options: SITES_KA },
489 + { name: "message", label: "Description détaillée", type: "textarea", required: true, rows: 7, maxLen: 12000, help: "Pour un retrait ou une rectification : incluez l'URL exacte de la page concernée." },
490 + { name: "pieces", label: "Pièces justificatives (facultatives)", type: "file", accept: ".pdf,.doc,.docx,.png,.jpg,.jpeg,.webp", help: "PDF, Word ou image — 10 Mo max." },
491 + {
492 + name: "consentement",
493 + label: "Je consens à ce que Groupe KA traite les renseignements fournis dans ce formulaire aux seules fins du traitement de ma demande.",
494 + type: "checkbox",
495 + required: true,
496 + },
497 + ],
498 + },
499 + {
500 + slug: "securite",
501 + prefix: "SEC",
502 + title: "Sécurité & signalements",
503 + short: "Sécurité",
504 + tagline: "Signaler un problème ou une vulnérabilité.",
505 + intro:
506 + "Vulnérabilité, bug critique, fraude, arnaque, faux listing, abus, usurpation d'identité, contenu illégal — signalez-le ici. La soumission peut être anonyme : ne laissez un courriel que si vous souhaitez un suivi.",
507 + icon: "alert",
508 + confidentiality: "elevee",
509 + subCategoryFrom: "type_signalement",
510 + fields: [
511 + {
512 + name: "type_signalement",
513 + label: "Type de signalement",
514 + type: "select",
515 + required: true,
516 + options: [
517 + "Vulnérabilité de sécurité",
518 + "Bug critique",
519 + "Fraude",
520 + "Tentative d'arnaque",
521 + "Faux listing",
522 + "Abus",
523 + "Usurpation d'identité",
524 + "Contenu illégal",
525 + "Autre problème de sécurité",
526 + ],
527 + },
528 + { name: "site_concerned", label: "Site Groupe KA concerné", type: "select", required: true, half: true, options: SITES_KA },
529 + { name: "is_anonymous", label: "Je souhaite rester anonyme", type: "checkbox", half: true },
530 + {
531 + name: "first_name",
532 + label: "Nom (facultatif)",
533 + type: "text",
534 + half: true,
535 + maxLen: 120,
536 + showIfUnchecked: "is_anonymous",
537 + },
538 + {
539 + name: "email",
540 + label: "Courriel — seulement si vous souhaitez un suivi",
541 + type: "email",
542 + half: true,
543 + maxLen: 200,
544 + showIfUnchecked: "is_anonymous",
545 + },
546 + { name: "message", label: "Description du problème", type: "textarea", required: true, rows: 7, maxLen: 12000, help: "URL concernée, étapes de reproduction, impact observé — tout détail aide." },
547 + ],
548 + },
549 + {
550 + slug: "autre",
551 + prefix: "DEM",
552 + title: "Autre demande",
553 + short: "Autre",
554 + tagline: "Tout ce qui n'entre pas dans les autres cases.",
555 + intro:
556 + "Votre demande ne correspond à aucune catégorie ? Écrivez-nous ici — elle sera dirigée vers la bonne équipe.",
557 + icon: "spark",
558 + confidentiality: "standard",
559 + fields: [
560 + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 },
561 + { name: "last_name", label: "Nom", type: "text", half: true, maxLen: 80 },
562 + { name: "email", label: "Courriel", type: "email", required: true, half: true, maxLen: 200 },
563 + { name: "organization", label: "Organisation (facultative)", type: "text", half: true, maxLen: 160 },
564 + { name: "subject", label: "Sujet", type: "text", required: true, maxLen: 240 },
565 + { name: "message", label: "Message", type: "textarea", required: true, rows: 6, maxLen: 8000 },
566 + ],
567 + },
568 +];
569 +
570 +export function categoryBySlug(slug: string): CategoryDef | undefined {
571 + return CATEGORIES.find((c) => c.slug === slug);
572 +}
573 +
574 +/* ---------- priorité intelligente (centralisée côté backend) ---------- */
575 +
576 +export type Priority = "basse" | "normale" | "haute" | "urgente";
577 +
578 +const CRITICAL_SIGNALS = new Set([
579 + "Vulnérabilité de sécurité",
580 + "Fraude",
581 + "Usurpation d'identité",
582 + "Contenu illégal",
583 +]);
584 +
585 +/** Priorité initiale d'une soumission — ne dépend JAMAIS du frontend. */
586 +export function computePriority(
587 + slug: string,
588 + subCategory: string | null,
589 + metadata: Record<string, unknown>,
590 +): Priority {
591 + if (slug === "securite")
592 + return subCategory && CRITICAL_SIGNALS.has(subCategory) ? "urgente" : "haute";
593 + if (slug === "legal") return "haute";
594 + if (slug === "investisseurs") return "haute";
595 + if (slug === "medias") {
596 + const d = typeof metadata.echeance === "string" ? metadata.echeance : "";
597 + if (/^\d{4}-\d{2}-\d{2}$/.test(d)) {
598 + const delta = new Date(d + "T23:59:59").getTime() - Date.now();
599 + if (delta < 48 * 3600 * 1000) return "haute";
600 + }
601 + return "normale";
602 + }
603 + return "normale";
604 +}
605 +
606 +/* ---------- statuts ---------- */
607 +
608 +export const STATUSES: Record<string, string> = {
609 + nouvelle: "Nouvelle",
610 + en_cours: "En cours",
611 + en_attente: "En attente",
612 + resolue: "Résolue",
613 +};
614 +
615 +/** Pipeline propre aux candidatures (catégorie carrieres). */
616 +export const JOB_STATUSES: Record<string, string> = {
617 + nouvelle: "Nouvelle candidature",
618 + a_analyser: "À analyser",
619 + entrevue: "Entrevue",
620 + a_revoir: "À revoir",
621 + offre: "Offre",
622 + embauchee: "Embauchée",
623 + refusee: "Refusée",
624 +};
625 +
626 +export function statusesFor(slug: string): Record<string, string> {
627 + return slug === "carrieres" ? JOB_STATUSES : STATUSES;
628 +}
629 +
630 +/** Statuts terminaux : posent resolved_at. */
631 +export const TERMINAL_STATUSES = new Set(["resolue", "refusee", "embauchee"]);
632 +
633 +export const PRIORITIES: Record<Priority, string> = {
634 + basse: "Basse",
635 + normale: "Normale",
636 + haute: "Haute",
637 + urgente: "Urgente",
638 +};
639 +
640 +export const CONFIDENTIALITY_LEVELS: Record<string, string> = {
641 + standard: "Standard",
642 + elevee: "Élevée",
643 + restreinte: "Restreinte",
644 +};
645 +
646 +/* ---------- rôles admin (libellés — la LOGIQUE d'accès vit dans
647 + src/lib/comms/admin-auth.ts, côté serveur uniquement) ---------- */
648 +
649 +export const ADMIN_ROLES: Record<string, string> = {
650 + super_admin: "Super administrateur",
651 + admin: "Administrateur",
652 + support: "Support",
653 + legal: "Légal & vie privée",
654 + investor_relations: "Relations investisseurs",
655 + hr: "Ressources humaines",
656 + media: "Relations médias",
657 + security: "Sécurité",
658 +};
added src/lib/comms/db.ts +606 −0
@@ -0,0 +1,606 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA Communication Hub — base de données du centre de communication.
3 +// Composant transversal du Groupe KA : toutes les demandes entrantes
4 +// (partenariats, données, investisseurs, candidatures, médias, légal,
5 +// sécurité…) vivent ici — la DB est LA source de vérité, jamais le courriel.
6 +// Base séparée de ka-id.db (data/ka-comms.db, SQLite WAL) : le hub identité
7 +// reste minimal, le hub communication évolue à son rythme.
8 +import Database from "better-sqlite3";
9 +import path from "path";
10 +import fs from "fs";
11 +import crypto from "crypto";
12 +
13 +function open() {
14 + const dbPath =
15 + process.env.KA_COMMS_DB_PATH ??
16 + path.join(process.cwd(), "data", "ka-comms.db");
17 + fs.mkdirSync(path.dirname(dbPath), { recursive: true });
18 + const db = new Database(dbPath);
19 + db.pragma("journal_mode = WAL");
20 + db.exec(`
21 + CREATE TABLE IF NOT EXISTS contact_submissions (
22 + id INTEGER PRIMARY KEY AUTOINCREMENT,
23 + reference TEXT UNIQUE NOT NULL,
24 + category TEXT NOT NULL,
25 + sub_category TEXT,
26 + first_name TEXT,
27 + last_name TEXT,
28 + email TEXT,
29 + phone TEXT,
30 + organization TEXT,
31 + job_title TEXT,
32 + website TEXT,
33 + linkedin_url TEXT,
34 + github_url TEXT,
35 + site_concerned TEXT,
36 + subject TEXT,
37 + message TEXT,
38 + metadata TEXT,
39 + status TEXT NOT NULL DEFAULT 'nouvelle',
40 + priority TEXT NOT NULL DEFAULT 'normale',
41 + confidentiality TEXT NOT NULL DEFAULT 'standard',
42 + assigned_to INTEGER,
43 + tags TEXT,
44 + source TEXT NOT NULL DEFAULT 'web',
45 + user_id INTEGER,
46 + ka_id TEXT,
47 + is_anonymous INTEGER NOT NULL DEFAULT 0,
48 + is_read INTEGER NOT NULL DEFAULT 0,
49 + is_spam INTEGER NOT NULL DEFAULT 0,
50 + is_archived INTEGER NOT NULL DEFAULT 0,
51 + ai_category TEXT,
52 + ai_priority TEXT,
53 + ai_summary TEXT,
54 + ai_confidence REAL,
55 + created_at TEXT NOT NULL DEFAULT (datetime('now')),
56 + updated_at TEXT NOT NULL DEFAULT (datetime('now')),
57 + first_response_at TEXT,
58 + resolved_at TEXT
59 + );
60 + CREATE INDEX IF NOT EXISTS cs_category ON contact_submissions(category, created_at);
61 + CREATE INDEX IF NOT EXISTS cs_status ON contact_submissions(status);
62 + CREATE INDEX IF NOT EXISTS cs_user ON contact_submissions(user_id);
63 + CREATE INDEX IF NOT EXISTS cs_read ON contact_submissions(is_read);
64 +
65 + -- Compteurs de références publiques (KA-INV-2026-0012) : un compteur
66 + -- par préfixe et par année — jamais d'ID interne exposé.
67 + CREATE TABLE IF NOT EXISTS contact_counters (
68 + prefix TEXT NOT NULL,
69 + year INTEGER NOT NULL,
70 + n INTEGER NOT NULL DEFAULT 0,
71 + PRIMARY KEY (prefix, year)
72 + );
73 +
74 + -- Pièces jointes : stockées sous data/contact-uploads/, nom aléatoire,
75 + -- servies UNIQUEMENT par la route admin authentifiée.
76 + CREATE TABLE IF NOT EXISTS contact_attachments (
77 + id INTEGER PRIMARY KEY AUTOINCREMENT,
78 + submission_id INTEGER NOT NULL,
79 + field TEXT NOT NULL,
80 + original_name TEXT NOT NULL,
81 + stored_name TEXT NOT NULL,
82 + mime TEXT NOT NULL,
83 + size INTEGER NOT NULL,
84 + created_at TEXT NOT NULL DEFAULT (datetime('now'))
85 + );
86 + CREATE INDEX IF NOT EXISTS ca_submission ON contact_attachments(submission_id);
87 +
88 + -- Notes internes : jamais visibles par le demandeur.
89 + CREATE TABLE IF NOT EXISTS contact_notes (
90 + id INTEGER PRIMARY KEY AUTOINCREMENT,
91 + submission_id INTEGER NOT NULL,
92 + admin_id INTEGER NOT NULL,
93 + body TEXT NOT NULL,
94 + created_at TEXT NOT NULL DEFAULT (datetime('now'))
95 + );
96 + CREATE INDEX IF NOT EXISTS cn_submission ON contact_notes(submission_id);
97 +
98 + -- Conversation : messages échangés dans une demande (admin ↔ demandeur).
99 + -- La DB reste la source de vérité ; le courriel (Resend) n'est qu'un canal.
100 + CREATE TABLE IF NOT EXISTS contact_messages (
101 + id INTEGER PRIMARY KEY AUTOINCREMENT,
102 + submission_id INTEGER NOT NULL,
103 + sender_type TEXT NOT NULL, -- 'admin' | 'user' | 'system'
104 + sender_admin_id INTEGER,
105 + sender_user_id INTEGER,
106 + body TEXT NOT NULL,
107 + channel TEXT NOT NULL DEFAULT 'email',
108 + provider_message_id TEXT,
109 + created_at TEXT NOT NULL DEFAULT (datetime('now'))
110 + );
111 + CREATE INDEX IF NOT EXISTS cm_submission ON contact_messages(submission_id);
112 +
113 + -- Historique d'activité (workflow) : chaque changement est journalisé.
114 + CREATE TABLE IF NOT EXISTS contact_events (
115 + id INTEGER PRIMARY KEY AUTOINCREMENT,
116 + submission_id INTEGER NOT NULL,
117 + admin_id INTEGER,
118 + kind TEXT NOT NULL,
119 + detail TEXT,
120 + created_at TEXT NOT NULL DEFAULT (datetime('now'))
121 + );
122 + CREATE INDEX IF NOT EXISTS ce_submission ON contact_events(submission_id);
123 +
124 + -- Comptes administrateurs du panel /admin — RBAC par rôles (JSON).
125 + -- Distincts des KA ID : le panel est un outil interne.
126 + CREATE TABLE IF NOT EXISTS admin_users (
127 + id INTEGER PRIMARY KEY AUTOINCREMENT,
128 + username TEXT UNIQUE NOT NULL,
129 + display_name TEXT NOT NULL,
130 + email TEXT,
131 + password_hash TEXT NOT NULL,
132 + roles TEXT NOT NULL DEFAULT '["support"]',
133 + active INTEGER NOT NULL DEFAULT 1,
134 + created_at TEXT NOT NULL DEFAULT (datetime('now')),
135 + last_login TEXT
136 + );
137 + `);
138 + return db;
139 +}
140 +
141 +// Singleton (survit au rechargement à chaud en dev).
142 +const g = globalThis as unknown as { __kaCommsDb?: Database.Database };
143 +export const commsDb: Database.Database = g.__kaCommsDb ?? (g.__kaCommsDb = open());
144 +
145 +/* ---------- types ---------- */
146 +
147 +export type SubmissionRow = {
148 + id: number;
149 + reference: string;
150 + category: string;
151 + sub_category: string | null;
152 + first_name: string | null;
153 + last_name: string | null;
154 + email: string | null;
155 + phone: string | null;
156 + organization: string | null;
157 + job_title: string | null;
158 + website: string | null;
159 + linkedin_url: string | null;
160 + github_url: string | null;
161 + site_concerned: string | null;
162 + subject: string | null;
163 + message: string | null;
164 + metadata: string | null;
165 + status: string;
166 + priority: string;
167 + confidentiality: string;
168 + assigned_to: number | null;
169 + tags: string | null;
170 + source: string;
171 + user_id: number | null;
172 + ka_id: string | null;
173 + is_anonymous: number;
174 + is_read: number;
175 + is_spam: number;
176 + is_archived: number;
177 + ai_category: string | null;
178 + ai_priority: string | null;
179 + ai_summary: string | null;
180 + ai_confidence: number | null;
181 + created_at: string;
182 + updated_at: string;
183 + first_response_at: string | null;
184 + resolved_at: string | null;
185 +};
186 +
187 +export type AttachmentRow = {
188 + id: number;
189 + submission_id: number;
190 + field: string;
191 + original_name: string;
192 + stored_name: string;
193 + mime: string;
194 + size: number;
195 + created_at: string;
196 +};
197 +
198 +export type NoteRow = {
199 + id: number;
200 + submission_id: number;
201 + admin_id: number;
202 + body: string;
203 + created_at: string;
204 +};
205 +
206 +export type MessageRow = {
207 + id: number;
208 + submission_id: number;
209 + sender_type: string;
210 + sender_admin_id: number | null;
211 + sender_user_id: number | null;
212 + body: string;
213 + channel: string;
214 + provider_message_id: string | null;
215 + created_at: string;
216 +};
217 +
218 +export type EventRow = {
219 + id: number;
220 + submission_id: number;
221 + admin_id: number | null;
222 + kind: string;
223 + detail: string | null;
224 + created_at: string;
225 +};
226 +
227 +export type AdminRow = {
228 + id: number;
229 + username: string;
230 + display_name: string;
231 + email: string | null;
232 + password_hash: string;
233 + roles: string;
234 + active: number;
235 + created_at: string;
236 + last_login: string | null;
237 +};
238 +
239 +/* ---------- référence publique ---------- */
240 +
241 +/** Référence humaine du type KA-INV-2026-0012 — jamais l'ID interne. */
242 +export function nextReference(prefix: string): string {
243 + const year = new Date().getFullYear();
244 + const bump = commsDb.transaction(() => {
245 + commsDb
246 + .prepare(
247 + `INSERT INTO contact_counters (prefix, year, n) VALUES (?, ?, 1)
248 + ON CONFLICT(prefix, year) DO UPDATE SET n = n + 1`,
249 + )
250 + .run(prefix, year);
251 + return (
252 + commsDb
253 + .prepare("SELECT n FROM contact_counters WHERE prefix = ? AND year = ?")
254 + .get(prefix, year) as { n: number }
255 + ).n;
256 + });
257 + const n = bump();
258 + return `KA-${prefix}-${year}-${String(n).padStart(4, "0")}`;
259 +}
260 +
261 +/* ---------- soumissions ---------- */
262 +
263 +export function insertSubmission(s: {
264 + reference: string;
265 + category: string;
266 + sub_category?: string | null;
267 + first_name?: string | null;
268 + last_name?: string | null;
269 + email?: string | null;
270 + phone?: string | null;
271 + organization?: string | null;
272 + job_title?: string | null;
273 + website?: string | null;
274 + linkedin_url?: string | null;
275 + github_url?: string | null;
276 + site_concerned?: string | null;
277 + subject?: string | null;
278 + message?: string | null;
279 + metadata?: unknown;
280 + priority: string;
281 + confidentiality: string;
282 + user_id?: number | null;
283 + ka_id?: string | null;
284 + is_anonymous?: boolean;
285 + source?: string;
286 +}): number {
287 + const info = commsDb
288 + .prepare(
289 + `INSERT INTO contact_submissions
290 + (reference, category, sub_category, first_name, last_name, email, phone,
291 + organization, job_title, website, linkedin_url, github_url,
292 + site_concerned, subject, message, metadata, priority, confidentiality,
293 + user_id, ka_id, is_anonymous, source)
294 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
295 + )
296 + .run(
297 + s.reference,
298 + s.category,
299 + s.sub_category ?? null,
300 + s.first_name ?? null,
301 + s.last_name ?? null,
302 + s.email ?? null,
303 + s.phone ?? null,
304 + s.organization ?? null,
305 + s.job_title ?? null,
306 + s.website ?? null,
307 + s.linkedin_url ?? null,
308 + s.github_url ?? null,
309 + s.site_concerned ?? null,
310 + s.subject ?? null,
311 + s.message ?? null,
312 + s.metadata != null ? JSON.stringify(s.metadata) : null,
313 + s.priority,
314 + s.confidentiality,
315 + s.user_id ?? null,
316 + s.ka_id ?? null,
317 + s.is_anonymous ? 1 : 0,
318 + s.source ?? "web",
319 + );
320 + return Number(info.lastInsertRowid);
321 +}
322 +
323 +export function findSubmissionByRef(ref: string): SubmissionRow | undefined {
324 + return commsDb
325 + .prepare("SELECT * FROM contact_submissions WHERE reference = ?")
326 + .get(ref) as SubmissionRow | undefined;
327 +}
328 +
329 +export function findSubmissionById(id: number): SubmissionRow | undefined {
330 + return commsDb
331 + .prepare("SELECT * FROM contact_submissions WHERE id = ?")
332 + .get(id) as SubmissionRow | undefined;
333 +}
334 +
335 +/** Champs modifiables depuis le panel — liste blanche stricte (anti-IDOR). */
336 +const UPDATABLE = new Set([
337 + "status",
338 + "priority",
339 + "confidentiality",
340 + "assigned_to",
341 + "tags",
342 + "is_read",
343 + "is_spam",
344 + "is_archived",
345 +]);
346 +
347 +export function updateSubmission(
348 + id: number,
349 + patch: Record<string, unknown>,
350 +): void {
351 + const keys = Object.keys(patch).filter((k) => UPDATABLE.has(k));
352 + if (!keys.length) return;
353 + const sets = keys.map((k) => `${k} = ?`).join(", ");
354 + commsDb
355 + .prepare(
356 + `UPDATE contact_submissions SET ${sets}, updated_at = datetime('now') WHERE id = ?`,
357 + )
358 + .run(...keys.map((k) => patch[k] as string | number | null), id);
359 +}
360 +
361 +export function markResolved(id: number, resolved: boolean): void {
362 + commsDb
363 + .prepare(
364 + `UPDATE contact_submissions
365 + SET resolved_at = ${resolved ? "datetime('now')" : "NULL"},
366 + updated_at = datetime('now')
367 + WHERE id = ?`,
368 + )
369 + .run(id);
370 +}
371 +
372 +export function touchFirstResponse(id: number): void {
373 + commsDb
374 + .prepare(
375 + `UPDATE contact_submissions
376 + SET first_response_at = COALESCE(first_response_at, datetime('now')),
377 + updated_at = datetime('now')
378 + WHERE id = ?`,
379 + )
380 + .run(id);
381 +}
382 +
383 +/* ---------- pièces jointes / notes / messages / événements ---------- */
384 +
385 +export function insertAttachment(a: {
386 + submission_id: number;
387 + field: string;
388 + original_name: string;
389 + stored_name: string;
390 + mime: string;
391 + size: number;
392 +}): void {
393 + commsDb
394 + .prepare(
395 + `INSERT INTO contact_attachments
396 + (submission_id, field, original_name, stored_name, mime, size)
397 + VALUES (?, ?, ?, ?, ?, ?)`,
398 + )
399 + .run(a.submission_id, a.field, a.original_name, a.stored_name, a.mime, a.size);
400 +}
401 +
402 +export function attachmentsOf(submissionId: number): AttachmentRow[] {
403 + return commsDb
404 + .prepare("SELECT * FROM contact_attachments WHERE submission_id = ?")
405 + .all(submissionId) as AttachmentRow[];
406 +}
407 +
408 +export function findAttachment(id: number): AttachmentRow | undefined {
409 + return commsDb
410 + .prepare("SELECT * FROM contact_attachments WHERE id = ?")
411 + .get(id) as AttachmentRow | undefined;
412 +}
413 +
414 +export function insertNote(submissionId: number, adminId: number, body: string): void {
415 + commsDb
416 + .prepare(
417 + "INSERT INTO contact_notes (submission_id, admin_id, body) VALUES (?, ?, ?)",
418 + )
419 + .run(submissionId, adminId, body);
420 +}
421 +
422 +export function notesOf(submissionId: number): (NoteRow & { author: string })[] {
423 + return commsDb
424 + .prepare(
425 + `SELECT n.*, COALESCE(a.display_name, 'Admin retiré') AS author
426 + FROM contact_notes n LEFT JOIN admin_users a ON a.id = n.admin_id
427 + WHERE n.submission_id = ? ORDER BY n.created_at DESC`,
428 + )
429 + .all(submissionId) as (NoteRow & { author: string })[];
430 +}
431 +
432 +export function insertMessage(m: {
433 + submission_id: number;
434 + sender_type: "admin" | "user" | "system";
435 + sender_admin_id?: number | null;
436 + sender_user_id?: number | null;
437 + body: string;
438 + channel?: string;
439 + provider_message_id?: string | null;
440 +}): void {
441 + commsDb
442 + .prepare(
443 + `INSERT INTO contact_messages
444 + (submission_id, sender_type, sender_admin_id, sender_user_id, body,
445 + channel, provider_message_id)
446 + VALUES (?, ?, ?, ?, ?, ?, ?)`,
447 + )
448 + .run(
449 + m.submission_id,
450 + m.sender_type,
451 + m.sender_admin_id ?? null,
452 + m.sender_user_id ?? null,
453 + m.body,
454 + m.channel ?? "email",
455 + m.provider_message_id ?? null,
456 + );
457 +}
458 +
459 +export function messagesOf(
460 + submissionId: number,
461 +): (MessageRow & { author: string | null })[] {
462 + return commsDb
463 + .prepare(
464 + `SELECT m.*, a.display_name AS author
465 + FROM contact_messages m LEFT JOIN admin_users a ON a.id = m.sender_admin_id
466 + WHERE m.submission_id = ? ORDER BY m.created_at ASC`,
467 + )
468 + .all(submissionId) as (MessageRow & { author: string | null })[];
469 +}
470 +
471 +export function logEvent(
472 + submissionId: number,
473 + adminId: number | null,
474 + kind: string,
475 + detail?: string,
476 +): void {
477 + commsDb
478 + .prepare(
479 + "INSERT INTO contact_events (submission_id, admin_id, kind, detail) VALUES (?, ?, ?, ?)",
480 + )
481 + .run(submissionId, adminId, kind, detail ?? null);
482 +}
483 +
484 +export function eventsOf(
485 + submissionId: number,
486 +): (EventRow & { author: string | null })[] {
487 + return commsDb
488 + .prepare(
489 + `SELECT e.*, a.display_name AS author
490 + FROM contact_events e LEFT JOIN admin_users a ON a.id = e.admin_id
491 + WHERE e.submission_id = ? ORDER BY e.created_at DESC`,
492 + )
493 + .all(submissionId) as (EventRow & { author: string | null })[];
494 +}
495 +
496 +/* ---------- comptes admin ---------- */
497 +
498 +function hashPassword(password: string): string {
499 + const salt = crypto.randomBytes(16).toString("hex");
500 + const hash = crypto.scryptSync(password, salt, 64).toString("hex");
501 + return `${salt}:${hash}`;
502 +}
503 +
504 +export function verifyAdminPassword(password: string, stored: string): boolean {
505 + const [salt, hash] = stored.split(":");
506 + if (!salt || !hash) return false;
507 + const test = crypto.scryptSync(password, salt, 64);
508 + return crypto.timingSafeEqual(test, Buffer.from(hash, "hex"));
509 +}
510 +
511 +export function findAdminByUsername(username: string): AdminRow | undefined {
512 + return commsDb
513 + .prepare("SELECT * FROM admin_users WHERE username = ?")
514 + .get(username.toLowerCase()) as AdminRow | undefined;
515 +}
516 +
517 +export function findAdminById(id: number): AdminRow | undefined {
518 + return commsDb.prepare("SELECT * FROM admin_users WHERE id = ?").get(id) as
519 + | AdminRow
520 + | undefined;
521 +}
522 +
523 +export function listAdmins(): AdminRow[] {
524 + return commsDb
525 + .prepare("SELECT * FROM admin_users ORDER BY created_at ASC")
526 + .all() as AdminRow[];
527 +}
528 +
529 +export function createAdmin(a: {
530 + username: string;
531 + display_name: string;
532 + email?: string | null;
533 + password: string;
534 + roles: string[];
535 +}): number {
536 + const info = commsDb
537 + .prepare(
538 + `INSERT INTO admin_users (username, display_name, email, password_hash, roles)
539 + VALUES (?, ?, ?, ?, ?)`,
540 + )
541 + .run(
542 + a.username.toLowerCase(),
543 + a.display_name,
544 + a.email ?? null,
545 + hashPassword(a.password),
546 + JSON.stringify(a.roles),
547 + );
548 + return Number(info.lastInsertRowid);
549 +}
550 +
551 +export function setAdminPassword(id: number, password: string): void {
552 + commsDb
553 + .prepare("UPDATE admin_users SET password_hash = ? WHERE id = ?")
554 + .run(hashPassword(password), id);
555 +}
556 +
557 +export function updateAdmin(
558 + id: number,
559 + patch: { display_name?: string; email?: string | null; roles?: string[]; active?: boolean },
560 +): void {
561 + if (patch.display_name !== undefined)
562 + commsDb
563 + .prepare("UPDATE admin_users SET display_name = ? WHERE id = ?")
564 + .run(patch.display_name, id);
565 + if (patch.email !== undefined)
566 + commsDb.prepare("UPDATE admin_users SET email = ? WHERE id = ?").run(patch.email, id);
567 + if (patch.roles !== undefined)
568 + commsDb
569 + .prepare("UPDATE admin_users SET roles = ? WHERE id = ?")
570 + .run(JSON.stringify(patch.roles), id);
571 + if (patch.active !== undefined)
572 + commsDb
573 + .prepare("UPDATE admin_users SET active = ? WHERE id = ?")
574 + .run(patch.active ? 1 : 0, id);
575 +}
576 +
577 +export function touchAdminLogin(id: number): void {
578 + commsDb
579 + .prepare("UPDATE admin_users SET last_login = datetime('now') WHERE id = ?")
580 + .run(id);
581 +}
582 +
583 +// Amorçage : les deux comptes maîtres demandés (mot de passe changeable
584 +// depuis /admin/equipe — à changer dès la première connexion).
585 +(function seedAdmins() {
586 + try {
587 + const count = (
588 + commsDb.prepare("SELECT COUNT(*) AS c FROM admin_users").get() as { c: number }
589 + ).c;
590 + if (count > 0) return;
591 + createAdmin({
592 + username: "erikabc",
593 + display_name: "Erika BC",
594 + password: "admin123",
595 + roles: ["super_admin"],
596 + });
597 + createAdmin({
598 + username: "spboucher",
599 + display_name: "Simon-Pierre Boucher",
600 + password: "admin123",
601 + roles: ["super_admin"],
602 + });
603 + } catch {
604 + /* course entre workers de build (UNIQUE username) : un seul amorce */
605 + }
606 +})();
added src/lib/comms/queries.ts +215 −0
@@ -0,0 +1,215 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA Communication Hub — requêtes du panel /admin (inbox, dashboard).
3 +// Le RBAC est appliqué DANS la requête : un admin ne reçoit jamais une ligne
4 +// qu'il n'a pas le droit de voir (pas de filtrage côté client).
5 +import { commsDb, type SubmissionRow } from "./db";
6 +import { CATEGORIES } from "./categories";
7 +import {
8 + canViewCategory,
9 + isSuperAdmin,
10 + CATEGORY_ROLE,
11 + type AdminSession,
12 +} from "./admin-auth";
13 +
14 +export type InboxFilters = {
15 + category?: string;
16 + subCategory?: string;
17 + status?: string;
18 + priority?: string;
19 + site?: string;
20 + assigned?: string; // id numérique, "moi", "personne"
21 + kaId?: string;
22 + q?: string;
23 + view?: "actives" | "archivees" | "spam" | "toutes";
24 + page?: number;
25 +};
26 +
27 +const PAGE_SIZE = 40;
28 +
29 +/** WHERE RBAC : catégories visibles + niveaux restreints réservés. */
30 +function rbacWhere(s: AdminSession): { sql: string; args: (string | number)[] } {
31 + if (isSuperAdmin(s)) return { sql: "1=1", args: [] };
32 + const cats = CATEGORIES.map((c) => c.slug).filter((c) => canViewCategory(s, c));
33 + if (!cats.length) return { sql: "1=0", args: [] };
34 + const dedicated = Object.entries(CATEGORY_ROLE)
35 + .filter(([, role]) => s.roles.includes(role))
36 + .map(([cat]) => cat);
37 + // « restreinte » : visible seulement si l'admin porte le rôle dédié.
38 + const dedic = dedicated.length
39 + ? `category IN (${dedicated.map(() => "?").join(",")})`
40 + : "1=0";
41 + return {
42 + sql: `category IN (${cats.map(() => "?").join(",")})
43 + AND (confidentiality != 'restreinte' OR ${dedic})`,
44 + args: [...cats, ...dedicated],
45 + };
46 +}
47 +
48 +export function listSubmissions(
49 + s: AdminSession,
50 + f: InboxFilters,
51 +): { rows: (SubmissionRow & { assignee: string | null })[]; total: number } {
52 + const rbac = rbacWhere(s);
53 + const where: string[] = [rbac.sql];
54 + const args: (string | number)[] = [...rbac.args];
55 +
56 + const view = f.view ?? "actives";
57 + if (view === "actives") where.push("is_archived = 0 AND is_spam = 0");
58 + else if (view === "archivees") where.push("is_archived = 1");
59 + else if (view === "spam") where.push("is_spam = 1");
60 +
61 + if (f.category) {
62 + where.push("category = ?");
63 + args.push(f.category);
64 + }
65 + if (f.subCategory) {
66 + where.push("sub_category = ?");
67 + args.push(f.subCategory);
68 + }
69 + if (f.status) {
70 + where.push("status = ?");
71 + args.push(f.status);
72 + }
73 + if (f.priority) {
74 + where.push("priority = ?");
75 + args.push(f.priority);
76 + }
77 + if (f.site) {
78 + where.push("site_concerned = ?");
79 + args.push(f.site);
80 + }
81 + if (f.kaId) {
82 + where.push("ka_id = ?");
83 + args.push(f.kaId);
84 + }
85 + if (f.assigned === "moi") {
86 + where.push("assigned_to = ?");
87 + args.push(s.id);
88 + } else if (f.assigned === "personne") {
89 + where.push("assigned_to IS NULL");
90 + } else if (f.assigned && /^\d+$/.test(f.assigned)) {
91 + where.push("assigned_to = ?");
92 + args.push(Number(f.assigned));
93 + }
94 + if (f.q) {
95 + // recherche globale : référence, identité, organisation, sujet, message
96 + where.push(`(reference LIKE ? OR first_name LIKE ? OR last_name LIKE ?
97 + OR email LIKE ? OR organization LIKE ? OR subject LIKE ? OR message LIKE ?)`);
98 + const like = `%${f.q}%`;
99 + args.push(like, like, like, like, like, like, like);
100 + }
101 +
102 + const whereSql = where.join(" AND ");
103 + const total = (
104 + commsDb
105 + .prepare(`SELECT COUNT(*) AS c FROM contact_submissions WHERE ${whereSql}`)
106 + .get(...args) as { c: number }
107 + ).c;
108 + const page = Math.max(1, f.page ?? 1);
109 + const rows = commsDb
110 + .prepare(
111 + `SELECT cs.*, a.display_name AS assignee
112 + FROM contact_submissions cs
113 + LEFT JOIN admin_users a ON a.id = cs.assigned_to
114 + WHERE ${whereSql}
115 + ORDER BY
116 + CASE cs.priority WHEN 'urgente' THEN 0 WHEN 'haute' THEN 1
117 + WHEN 'normale' THEN 2 ELSE 3 END,
118 + cs.created_at DESC
119 + LIMIT ? OFFSET ?`,
120 + )
121 + .all(...args, PAGE_SIZE, (page - 1) * PAGE_SIZE) as (SubmissionRow & {
122 + assignee: string | null;
123 + })[];
124 + return { rows, total };
125 +}
126 +
127 +/** La demande est-elle visible pour cet admin ? (contrôle unitaire, anti-IDOR) */
128 +export function visibleTo(s: AdminSession, sub: SubmissionRow): boolean {
129 + const rbac = rbacWhere(s);
130 + if (rbac.sql === "1=1") return true;
131 + const row = commsDb
132 + .prepare(
133 + `SELECT 1 AS ok FROM contact_submissions WHERE id = ? AND ${rbac.sql}`,
134 + )
135 + .get(sub.id, ...rbac.args);
136 + return !!row;
137 +}
138 +
139 +/* ---------- dashboard ---------- */
140 +
141 +export type DashboardStats = {
142 + today: number;
143 + unread: number;
144 + open: number;
145 + urgent: number;
146 + investors: number;
147 + jobs: number;
148 + legal: number;
149 + security: number;
150 + resolvedWeek: number;
151 + avgFirstResponseH: number | null;
152 +};
153 +
154 +export function dashboardStats(s: AdminSession): DashboardStats {
155 + const rbac = rbacWhere(s);
156 + const base = `FROM contact_submissions WHERE ${rbac.sql} AND is_spam = 0 AND is_archived = 0`;
157 + const one = (sql: string, extra: (string | number)[] = []): number =>
158 + (
159 + commsDb.prepare(`SELECT COUNT(*) AS c ${base} AND ${sql}`).get(
160 + ...rbac.args,
161 + ...extra,
162 + ) as { c: number }
163 + ).c;
164 + const catCount = (cat: string, extra = "resolved_at IS NULL"): number =>
165 + canViewCategory(s, cat) ? one(`category = ? AND ${extra}`, [cat]) : 0;
166 +
167 + const avg = commsDb
168 + .prepare(
169 + `SELECT AVG((julianday(first_response_at) - julianday(created_at)) * 24) AS h
170 + ${base} AND first_response_at IS NOT NULL
171 + AND created_at > datetime('now', '-30 days')`,
172 + )
173 + .get(...rbac.args) as { h: number | null };
174 +
175 + return {
176 + today: one("date(created_at) = date('now')"),
177 + unread: one("is_read = 0"),
178 + open: one("resolved_at IS NULL"),
179 + urgent: one("priority IN ('urgente','haute') AND resolved_at IS NULL"),
180 + investors: catCount("investisseurs"),
181 + jobs: catCount("carrieres", "date(created_at) > date('now', '-30 days')"),
182 + legal: catCount("legal"),
183 + security: catCount("securite"),
184 + resolvedWeek: one("resolved_at IS NOT NULL AND resolved_at > datetime('now', '-7 days')"),
185 + avgFirstResponseH: avg.h != null ? Math.round(avg.h * 10) / 10 : null,
186 + };
187 +}
188 +
189 +export function recentSubmissions(
190 + s: AdminSession,
191 + limit = 8,
192 +): (SubmissionRow & { assignee: string | null })[] {
193 + const rbac = rbacWhere(s);
194 + return commsDb
195 + .prepare(
196 + `SELECT cs.*, a.display_name AS assignee
197 + FROM contact_submissions cs
198 + LEFT JOIN admin_users a ON a.id = cs.assigned_to
199 + WHERE ${rbac.sql} AND cs.is_spam = 0 AND cs.is_archived = 0
200 + ORDER BY cs.created_at DESC LIMIT ?`,
201 + )
202 + .all(...rbac.args, limit) as (SubmissionRow & { assignee: string | null })[];
203 +}
204 +
205 +export function unreadCount(s: AdminSession): number {
206 + const rbac = rbacWhere(s);
207 + return (
208 + commsDb
209 + .prepare(
210 + `SELECT COUNT(*) AS c FROM contact_submissions
211 + WHERE ${rbac.sql} AND is_read = 0 AND is_spam = 0 AND is_archived = 0`,
212 + )
213 + .get(...rbac.args) as { c: number }
214 + ).c;
215 +}
added src/lib/comms/rate-limit.ts +52 −0
@@ -0,0 +1,52 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA Communication Hub — anti-abus du formulaire public.
3 +// Privacy-by-design : l'IP n'est JAMAIS écrite en base — uniquement une
4 +// empreinte SHA-256 salée, en mémoire, avec fenêtre glissante. Redémarrage
5 +// du processus = compteurs remis à zéro (acceptable pour ce volume).
6 +import crypto from "crypto";
7 +
8 +const salt =
9 + process.env.KA_AUTH_SECRET ?? "ka-dev-salt";
10 +
11 +type Window = { stamps: number[] };
12 +const buckets = new Map<string, Window>();
13 +
14 +const SHORT_WINDOW_MS = 10 * 60 * 1000; // 5 soumissions / 10 min
15 +const SHORT_MAX = 5;
16 +const LONG_WINDOW_MS = 60 * 60 * 1000; // 15 soumissions / heure
17 +const LONG_MAX = 15;
18 +
19 +export function ipFingerprint(req: Request): string {
20 + const fwd = req.headers.get("x-forwarded-for") ?? "";
21 + const ip = fwd.split(",")[0]?.trim() || "local";
22 + return crypto.createHash("sha256").update(salt + ip).digest("hex").slice(0, 24);
23 +}
24 +
25 +/** true = la soumission passe ; false = trop de soumissions, 429. */
26 +export function allowSubmission(fp: string): boolean {
27 + const now = Date.now();
28 + const w = buckets.get(fp) ?? { stamps: [] };
29 + w.stamps = w.stamps.filter((t) => now - t < LONG_WINDOW_MS);
30 + const recent = w.stamps.filter((t) => now - t < SHORT_WINDOW_MS);
31 + if (recent.length >= SHORT_MAX || w.stamps.length >= LONG_MAX) return false;
32 + w.stamps.push(now);
33 + buckets.set(fp, w);
34 + // ménage opportuniste — évite la croissance infinie de la Map
35 + if (buckets.size > 5000)
36 + for (const [k, v] of buckets)
37 + if (!v.stamps.some((t) => now - t < LONG_WINDOW_MS)) buckets.delete(k);
38 + return true;
39 +}
40 +
41 +/**
42 + * Honeypot + temps minimal de remplissage.
43 + * `hp` est un champ invisible (les humains le laissent vide) ; `ts` est
44 + * l'horodatage d'affichage du formulaire (un humain met > 3 s à remplir).
45 + */
46 +export function looksLikeSpam(hp: string | null, ts: string | null): boolean {
47 + if (hp) return true;
48 + const t = Number(ts);
49 + if (!Number.isFinite(t)) return true;
50 + const elapsed = Date.now() - t;
51 + return elapsed < 3000 || elapsed > 24 * 3600 * 1000;
52 +}
added src/lib/comms/receipt-email.ts +117 −0
@@ -0,0 +1,117 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA Communication Hub — courriels du centre de communication (Resend).
3 +// Deux gabarits : l'accusé de réception (avec la référence publique) et la
4 +// réponse d'un admin depuis le panel. Même identité « éditorial sharp » que
5 +// les courriels KA ID (src/lib/email.ts) — la DB reste la source de vérité,
6 +// le courriel n'est qu'un canal.
7 +import { sendEmail } from "@/lib/email";
8 +
9 +const INK = "#141814";
10 +const INK_2 = "#4d5551";
11 +const INK_3 = "#8b928c";
12 +const PAPER = "#f5f3ee";
13 +const LIME = "#d9f26b";
14 +const GREEN = "#1c5c41";
15 +const F_DISPLAY = "'Space Grotesk',Arial,'Helvetica Neue',sans-serif";
16 +const F_BODY = "'Inter',Helvetica,Arial,sans-serif";
17 +const F_MONO = "'JetBrains Mono','SFMono-Regular',Menlo,Consolas,'Courier New',monospace";
18 +
19 +function shell(opts: { kicker: string; title: string; preheader: string; inner: string }): string {
20 + return `<!doctype html>
21 +<html lang="fr">
22 +<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${opts.title.replace(/<[^>]+>/g, " ")}</title></head>
23 +<body style="margin:0;padding:0;background:${PAPER};">
24 + <div style="display:none;max-height:0;overflow:hidden;">${opts.preheader}</div>
25 + <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${PAPER};">
26 + <tr><td align="center" style="padding:40px 16px 36px;">
27 + <table role="presentation" cellpadding="0" cellspacing="0" style="width:560px;max-width:100%;">
28 + <tr><td style="padding:0 2px 16px;">
29 + <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>
30 + <td style="font-family:${F_DISPLAY};font-weight:700;font-size:22px;letter-spacing:-0.03em;color:${INK};white-space:nowrap;">Groupe&nbsp;<span style="display:inline-block;background:${LIME};color:${INK};padding:0 7px 3px;border-radius:6px;">Ka</span></td>
31 + <td align="right" style="font-family:${F_MONO};font-size:10px;font-weight:700;letter-spacing:0.16em;text-transform:uppercase;color:${INK_3};">${opts.kicker}</td>
32 + </tr></table>
33 + </td></tr>
34 + <tr><td style="background:#ffffff;border:2px solid ${INK};border-right-width:8px;border-bottom-width:8px;border-radius:12px;padding:30px 28px 24px;">
35 + <table role="presentation" width="100%" cellpadding="0" cellspacing="0">
36 + <tr><td style="font-family:${F_MONO};font-size:11px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:${GREEN};"><span style="display:inline-block;width:22px;height:2px;background:${GREEN};vertical-align:middle;">&nbsp;</span>&nbsp;&nbsp;Centre&nbsp;de&nbsp;communication</td></tr>
37 + <tr><td style="padding:12px 0 0;font-family:${F_DISPLAY};font-weight:700;font-size:27px;line-height:1.08;letter-spacing:-0.03em;color:${INK};">${opts.title}</td></tr>
38 + <tr><td style="padding:14px 0 0;">${opts.inner}</td></tr>
39 + </table>
40 + </td></tr>
41 + <tr><td style="padding:20px 2px 0;font-family:${F_MONO};font-size:9.5px;font-weight:700;line-height:1.9;letter-spacing:0.1em;text-transform:uppercase;color:${INK_3};">
42 + Un seul endroit pour nous joindre<br>
43 + <a href="https://www.groupe-ka.com/contact" style="color:${GREEN};text-decoration:none;">www.groupe-ka.com/contact</a>
44 + </td></tr>
45 + </table>
46 + </td></tr>
47 + </table>
48 +</body>
49 +</html>`;
50 +}
51 +
52 +function p(html: string): string {
53 + return `<p style="margin:0 0 12px;font-family:${F_BODY};font-size:14.5px;line-height:1.65;color:${INK_2};">${html}</p>`;
54 +}
55 +
56 +function refPanel(reference: string): string {
57 + return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:18px 0 6px;">
58 + <tr><td align="center" style="background:${INK};border-radius:12px;padding:22px 18px 18px;">
59 + <p style="margin:0 0 10px;font-family:${F_MONO};font-size:10px;font-weight:700;letter-spacing:0.26em;text-transform:uppercase;color:rgba(217,242,107,0.65);">Votre numéro de demande</p>
60 + <p style="margin:0;font-family:${F_MONO};font-weight:700;font-size:26px;line-height:1;letter-spacing:0.12em;color:${LIME};">${reference}</p>
61 + </td></tr>
62 + </table>`;
63 +}
64 +
65 +function esc(s: string): string {
66 + return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
67 +}
68 +
69 +/** Accusé de réception — envoyé au demandeur dès la soumission enregistrée. */
70 +export async function sendReceipt(opts: {
71 + to: string;
72 + name: string;
73 + reference: string;
74 + categoryTitle: string;
75 +}): Promise<void> {
76 + const inner = `
77 + ${p(`Bonjour <strong style="color:${INK};">${esc(opts.name)}</strong>,`)}
78 + ${p(`votre demande <strong style="color:${INK};">${esc(opts.categoryTitle)}</strong> est bien reçue et enregistrée dans notre centre de communication. Elle sera lue par l'équipe concernée — de vraies personnes, comme promis.`)}
79 + ${refPanel(opts.reference)}
80 + ${p(`Conservez ce numéro : il identifie votre demande dans tous nos échanges.`)}`;
81 + await sendEmail({
82 + to: opts.to,
83 + subject: `${opts.reference} — votre demande est reçue · Groupe KA`,
84 + html: shell({
85 + kicker: "Accusé de réception",
86 + title: "Votre demande est<br>entre bonnes mains",
87 + preheader: `Demande ${opts.reference} enregistrée — l'équipe concernée vous répondra.`,
88 + inner,
89 + }),
90 + text: `Bonjour ${opts.name},\n\nVotre demande « ${opts.categoryTitle} » est bien reçue.\nNuméro de demande : ${opts.reference}\n\nConservez ce numéro : il identifie votre demande dans tous nos échanges.\n\nGroupe Ka — www.groupe-ka.com/contact`,
91 + });
92 +}
93 +
94 +/** Réponse d'un admin depuis le panel — la conversation continue par courriel. */
95 +export async function sendAdminReply(opts: {
96 + to: string;
97 + name: string;
98 + reference: string;
99 + body: string;
100 +}): Promise<void> {
101 + const inner = `
102 + ${p(`Bonjour <strong style="color:${INK};">${esc(opts.name)}</strong>,`)}
103 + ${p(`voici la réponse de l'équipe Groupe KA à votre demande <strong style="color:${INK};">${esc(opts.reference)}</strong>&nbsp;:`)}
104 + <div style="margin:14px 0;padding:16px 18px;border-left:3px solid ${GREEN};background:#faf9f5;border-radius:0 8px 8px 0;font-family:${F_BODY};font-size:14.5px;line-height:1.7;color:${INK};white-space:pre-wrap;">${esc(opts.body)}</div>
105 + ${p(`Pour poursuivre l'échange, répondez simplement à ce courriel en conservant le numéro <strong style="color:${INK};">${esc(opts.reference)}</strong> dans l'objet.`)}`;
106 + await sendEmail({
107 + to: opts.to,
108 + subject: `${opts.reference} — réponse de Groupe KA`,
109 + html: shell({
110 + kicker: "Réponse à votre demande",
111 + title: "L'équipe vous répond",
112 + preheader: `Réponse à votre demande ${opts.reference}.`,
113 + inner,
114 + }),
115 + text: `Bonjour ${opts.name},\n\nRéponse de Groupe KA à votre demande ${opts.reference} :\n\n${opts.body}\n\nPour poursuivre l'échange, répondez à ce courriel en conservant le numéro ${opts.reference} dans l'objet.\n\nGroupe Ka — www.groupe-ka.com/contact`,
116 + });
117 +}
118