diff --git a/actions/patients.ts b/actions/patients.ts index 749db84..403e435 100644 --- a/actions/patients.ts +++ b/actions/patients.ts @@ -200,7 +200,30 @@ export async function importPatientCarnet( .maybeSingle(); if (error || !patient) return { success: false, error: error?.message ?? "Erreur lors de la création du patient" }; - + + // Journalisation de l'accès : le code carnet étant un jeton porteur, le + // patient doit pouvoir savoir quelles cliniques ont rattaché son dossier et + // quand. L'écriture passe par le client d'administration — aucune policy + // d'INSERT n'existe sur la table, pour qu'un journal ne puisse pas être + // falsifié par celui qu'il décrit. + const { data: authData } = await db.auth.getUser(); + const { error: journalError } = await adminDb.from("carnet_import_events").insert({ + carnet_id: carnet.id, + clinic_id: clinicId, + imported_by_user_id: authData?.user?.id ?? null, + patient_id: patient.id, + }); + + // L'import a réussi et le patient existe : échouer ici reviendrait à mentir à + // l'appelant. On signale l'anomalie sans annuler l'opération. + if (journalError) { + console.error("[carnet] échec de journalisation de l'import", { + carnetId: carnet.id, + clinicId, + message: journalError.message, + }); + } + return { success: true, data: { id: patient.id } }; } @@ -231,6 +254,90 @@ export async function getPatientCarnetHistory(patientId: string) { return data || []; } +export interface CarnetImportEvent { + id: string; + created_at: string; + clinic: { name: string } | null; + imported_by: { full_name: string | null } | null; +} + +/** + * Historique des rattachements du carnet d'un patient. + * + * Répond à « quelles cliniques ont accédé à mon dossier, et quand ? ». Réservé + * au rôle médical de la clinique du patient : la liste des cabinets fréquentés + * est elle-même une donnée de santé. + */ +export async function getCarnetImportHistory(patientId: string): Promise { + const db = await getDB(); + const clinicId = await getAuthenticatedClinicIdForMedicalRole(db); + if (!clinicId) return []; + + const { data: patient } = await db + .from("patients") + .select("carnet_id") + .eq("id", patientId) + .eq("clinic_id", clinicId) + .maybeSingle(); + + if (!patient?.carnet_id) return []; + + const { data } = await db + .from("carnet_import_events") + .select("id, created_at, clinic:clinics(name), imported_by:users(full_name)") + .eq("carnet_id", patient.carnet_id) + .order("created_at", { ascending: false }); + + return (data ?? []) as CarnetImportEvent[]; +} + +/** + * Génère un nouveau code porteur pour le carnet d'un patient. + * + * Un code divulgué donne un accès permanent à l'historique inter-cliniques : + * il faut pouvoir le révoquer. La rotation invalide immédiatement l'ancien code, + * mais ne détache pas les cliniques déjà rattachées — leur accès repose sur le + * `carnet_id`, pas sur le code. C'est volontaire : rompre un suivi médical en + * cours serait plus dangereux que la fuite elle-même. Le journal d'imports + * permet d'identifier un rattachement illégitime et de le traiter à part. + */ +export async function regenerateCarnetCode( + patientId: string +): Promise> { + const db = await getDB(); + const clinicId = await getAuthenticatedClinicIdForMedicalRole(db); + if (!clinicId) return { success: false, error: "Unauthorized" }; + + if (!(await checkAuthenticatedRateLimit(clinicId, "rotate-carnet"))) { + return { success: false, error: "Trop de tentatives. Réessayez dans une minute." }; + } + + const { data: patient } = await db + .from("patients") + .select("carnet_id") + .eq("id", patientId) + .eq("clinic_id", clinicId) + .maybeSingle(); + + if (!patient?.carnet_id) return { success: false, error: "Carnet introuvable" }; + + // Aucune policy d'UPDATE n'existe sur patient_carnets : la rotation passe par + // la fonction SECURITY DEFINER, exécutée avec le rôle de service une fois + // l'autorisation vérifiée ci-dessus. + const { createAdminClient } = await import("@/lib/supabase/server"); + const adminDb = (await createAdminClient()) as any; + + const { data: newCode, error } = await adminDb.rpc("rotate_patient_carnet_code", { + p_carnet_id: patient.carnet_id, + }); + + if (error || !newCode) { + return { success: false, error: error?.message ?? "Échec de la régénération du code" }; + } + + return { success: true, data: { publicCode: newCode as string } }; +} + export async function getPatientsWithCarnets() { const db = await getDB(); const clinicId = await getAuthenticatedClinicId(db); diff --git a/app/(app)/app/patients/[id]/page.tsx b/app/(app)/app/patients/[id]/page.tsx index 7390d1e..0e32250 100644 --- a/app/(app)/app/patients/[id]/page.tsx +++ b/app/(app)/app/patients/[id]/page.tsx @@ -31,6 +31,7 @@ import { InvitePatientButton } from "@/components/portail/invite-patient-button" import { StartTeleconsultationButton } from "@/components/teleconsultation/start-teleconsultation-button"; import { PreconsultationCard, type PreconsultationData } from "@/components/appointments/preconsultation-card"; import { CarnetNumeriqueSection } from "@/components/patients/carnet-numerique-section"; +import { CarnetAccessPanel } from "@/components/patients/carnet-access-panel"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -541,6 +542,8 @@ export default function PatientDetailPage() { {/* ─ Carnet Numerique ─ */} + {patient.carnet?.public_code && } + {/* ─ Consultation history ─ */}
diff --git a/components/patients/carnet-access-panel.tsx b/components/patients/carnet-access-panel.tsx new file mode 100644 index 0000000..39d9e0d --- /dev/null +++ b/components/patients/carnet-access-panel.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { format, parseISO } from "date-fns"; +import { fr } from "date-fns/locale"; +import { History, RefreshCw, ShieldAlert, Loader2 } from "lucide-react"; +import { getCarnetImportHistory, regenerateCarnetCode } from "@/actions/patients"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface CarnetAccessPanelProps { + patientId: string; +} + +/** + * Traçabilité du carnet : quelles cliniques ont rattaché ce dossier, et + * révocation du code porteur. + * + * Le code donne accès à l'historique médical inter-cliniques ; le praticien doit + * pouvoir constater les rattachements et régénérer le code si l'un d'eux est + * illégitime. + */ +export function CarnetAccessPanel({ patientId }: CarnetAccessPanelProps) { + const t = useTranslations("patients.carnetAccess"); + const queryClient = useQueryClient(); + const [isRotating, setIsRotating] = useState(false); + const [rotationError, setRotationError] = useState(null); + + const { data: importEvents, isLoading } = useQuery({ + queryKey: ["carnet-import-history", patientId], + queryFn: () => getCarnetImportHistory(patientId), + }); + + const rotateCode = async () => { + setIsRotating(true); + setRotationError(null); + + // `finally` indispensable : si l'action serveur rejette, le bouton resterait + // désactivé et le spinner tournerait indéfiniment, sans message. + try { + const result = await regenerateCarnetCode(patientId); + + if (result.success) { + // La fiche patient porte l'ancien code : elle doit être rechargée. + queryClient.invalidateQueries({ queryKey: ["patient", patientId] }); + queryClient.invalidateQueries({ queryKey: ["patients-with-carnets"] }); + } else { + setRotationError(result.error ?? t("rotationError")); + } + } catch { + setRotationError(t("rotationError")); + } finally { + setIsRotating(false); + } + }; + + return ( +
+
+
+ +

{t("title")}

+
+ +
+ + {rotationError && ( +

+ + {rotationError} +

+ )} + + {isLoading ? ( + + ) : !importEvents || importEvents.length === 0 ? ( +

{t("noAccess")}

+ ) : ( +
    + {importEvents.map((event) => ( +
  • +
    +

    + {event.clinic?.name ?? t("deletedClinic")} +

    + {event.imported_by?.full_name && ( +

    + {t("by", { name: event.imported_by.full_name })} +

    + )} +
    + +
  • + ))} +
+ )} + +

{t("notice")}

+
+ ); +} diff --git a/messages/en.json b/messages/en.json index a256a2a..0b4925d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -263,6 +263,15 @@ "errorNoCode": "Please enter a record code.", "errorImport": "Import failed.", "consultation": "Consultation" + }, + "carnetAccess": { + "title": "Health book access", + "regenerate": "Regenerate code", + "noAccess": "No other clinic has linked this health book.", + "deletedClinic": "Deleted clinic", + "by": "by {name}", + "rotationError": "Could not regenerate the code", + "notice": "Regenerating the code invalidates the previous one immediately. Clinics already linked keep their access: their link is to the health book, not the code." } }, "services": { diff --git a/messages/fr.json b/messages/fr.json index 84482ca..91b073f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -263,6 +263,15 @@ "errorNoCode": "Veuillez saisir un code carnet.", "errorImport": "Échec de l'importation.", "consultation": "Consultation" + }, + "carnetAccess": { + "title": "Accès au carnet", + "regenerate": "Régénérer le code", + "noAccess": "Aucune autre clinique n'a rattaché ce carnet.", + "deletedClinic": "Clinique supprimée", + "by": "par {name}", + "rotationError": "Échec de la régénération du code", + "notice": "Régénérer le code invalide l'ancien immédiatement. Les cliniques déjà rattachées conservent leur accès : leur lien repose sur le carnet, pas sur le code." } }, "services": { diff --git a/supabase/migrations/014_journalisation_et_rotation_carnets.sql b/supabase/migrations/014_journalisation_et_rotation_carnets.sql new file mode 100644 index 0000000..21aecda --- /dev/null +++ b/supabase/migrations/014_journalisation_et_rotation_carnets.sql @@ -0,0 +1,93 @@ +-- ═══════════════════════════════════════════════════════════════════════════════ +-- 014 — Journalisation des imports de carnet et rotation du code porteur +-- (audit signature / carnet / ordonnance 2026-08-02) +-- +-- Le code `CAR-XXXXXXXXXXXXXXXX` est un jeton porteur : quiconque le détient peut, +-- via `importPatientCarnet`, rattacher le patient à sa clinique et lire tout +-- l'historique médical validé inter-cliniques. Deux manques en découlaient : +-- +-- 1. Aucune trace. Impossible de répondre à « quelles cliniques ont accédé à +-- mon dossier, et quand ? » — question à laquelle un patient a le droit +-- d'obtenir une réponse (RGPD, droit d'accès aux destinataires). +-- 2. Aucune révocation. Un code divulgué le restait définitivement, puisque +-- rien ne permettait d'en générer un nouveau. +-- +-- On ajoute donc un journal des imports, lisible par le patient concerné, et la +-- possibilité de faire tourner le code. +-- ═══════════════════════════════════════════════════════════════════════════════ + +-- ── Journal des imports ─────────────────────────────────────────────────────── +-- `carnet_id` et non le code lui-même : journaliser un jeton porteur reviendrait +-- à le dupliquer dans une table de plus. +CREATE TABLE IF NOT EXISTS carnet_import_events ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + carnet_id UUID NOT NULL REFERENCES patient_carnets(id) ON DELETE CASCADE, + clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, + imported_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + patient_id UUID REFERENCES patients(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_carnet_import_events_carnet ON carnet_import_events (carnet_id, created_at DESC); + +ALTER TABLE carnet_import_events ENABLE ROW LEVEL SECURITY; + +-- Le patient voit qui a importé son carnet : c'est l'intérêt principal du journal. +CREATE POLICY "carnet_import_events_select_patient" ON carnet_import_events FOR SELECT +USING ( + carnet_id IN ( + SELECT carnet_id FROM patients WHERE auth_user_id = auth.uid() + ) +); + +-- Une clinique voit ses propres imports, pour justifier de son accès — mais +-- seulement par ses rôles médicaux. La liste des cabinets ayant consulté un +-- dossier est elle-même une donnée de santé : l'ouvrir à receptionist/assistant +-- contournerait le contrôle déjà appliqué par getCarnetImportHistory. +CREATE POLICY "carnet_import_events_select_clinic" ON carnet_import_events FOR SELECT +USING ( + clinic_id IN ( + SELECT clinic_id FROM users + WHERE id = auth.uid() AND role IN ('owner', 'super_admin') + ) +); + +-- Aucune policy d'INSERT, d'UPDATE ni de DELETE : l'écriture passe exclusivement +-- par le client d'administration depuis `importPatientCarnet`. Un journal que son +-- sujet peut réécrire ou effacer ne vaut rien. + +-- ── Rotation du code porteur ────────────────────────────────────────────────── +-- Même format et même garantie d'unicité que le trigger de la migration 004. +-- SECURITY DEFINER : l'appelant n'a aucun droit d'UPDATE sur patient_carnets, et +-- ne doit pas en obtenir — l'autorisation est vérifiée côté Server Action. +CREATE OR REPLACE FUNCTION rotate_patient_carnet_code(p_carnet_id UUID) +RETURNS TEXT AS $$ +DECLARE + new_code TEXT; + success BOOLEAN := FALSE; +BEGIN + WHILE NOT success LOOP + new_code := 'CAR-' || upper(substr(replace(uuid_generate_v4()::text, '-', ''), 1, 16)); + BEGIN + UPDATE patient_carnets SET public_code = new_code WHERE id = p_carnet_id; + -- Un UPDATE sans correspondance ne lève rien : sans ce contrôle, la + -- fonction renverrait un code et l'appelant annoncerait une rotation + -- réussie alors que rien n'aurait changé en base. + IF NOT FOUND THEN + RAISE EXCEPTION 'Carnet % introuvable', p_carnet_id USING ERRCODE = 'no_data_found'; + END IF; + success := TRUE; + EXCEPTION WHEN unique_violation THEN + -- collision improbable sur 64 bits : on retente avec un nouvel UUID + END; + END LOOP; + + RETURN new_code; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- La fonction n'est jamais appelée directement depuis le navigateur : seul le +-- rôle de service l'exécute, après contrôle applicatif. +REVOKE ALL ON FUNCTION rotate_patient_carnet_code(UUID) FROM PUBLIC; +REVOKE ALL ON FUNCTION rotate_patient_carnet_code(UUID) FROM anon; +REVOKE ALL ON FUNCTION rotate_patient_carnet_code(UUID) FROM authenticated;