Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 108 additions & 1 deletion actions/patients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
}

Expand Down Expand Up @@ -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<CarnetImportEvent[]> {
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<ApiResponse<{ publicCode: string }>> {
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);
Expand Down
3 changes: 3 additions & 0 deletions app/(app)/app/patients/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -541,6 +542,8 @@ export default function PatientDetailPage() {
{/* ─ Carnet Numerique ─ */}
<CarnetNumeriqueSection patientId={patient.id} />

{patient.carnet?.public_code && <CarnetAccessPanel patientId={patient.id} />}

{/* ─ Consultation history ─ */}
<div className="glass-card p-6">
<div className="section-header mb-5">
Expand Down
121 changes: 121 additions & 0 deletions components/patients/carnet-access-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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);
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<div className="card-panel p-5 space-y-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex items-center gap-2.5">
<History className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-foreground">{t("title")}</h3>
</div>
<Button
size="sm"
variant="outline"
onClick={rotateCode}
disabled={isRotating}
className="rounded-xl gap-1.5"
>
{isRotating ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
{t("regenerate")}
</Button>
</div>

{rotationError && (
<p className="flex items-center gap-2 text-sm text-destructive">
<ShieldAlert className="w-4 h-4 shrink-0" />
{rotationError}
</p>
)}

{isLoading ? (
<Skeleton className="h-16 w-full rounded-xl" />
) : !importEvents || importEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("noAccess")}</p>
) : (
<ul className="space-y-2">
{importEvents.map((event) => (
<li
key={event.id}
className="flex items-center justify-between gap-4 rounded-xl border border-border bg-muted/30 px-4 py-2.5"
>
<div>
<p className="text-sm font-medium text-foreground">
{event.clinic?.name ?? t("deletedClinic")}
</p>
{event.imported_by?.full_name && (
<p className="text-xs text-muted-foreground">
{t("by", { name: event.imported_by.full_name })}
</p>
)}
</div>
<time className="text-xs text-muted-foreground shrink-0">
{format(parseISO(event.created_at), "d MMM yyyy 'à' HH:mm", { locale: fr })}
</time>
</li>
))}
</ul>
)}

<p className="text-xs text-muted-foreground border-t border-border pt-3">{t("notice")}</p>
</div>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
9 changes: 9 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
9 changes: 9 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
93 changes: 93 additions & 0 deletions supabase/migrations/014_journalisation_et_rotation_carnets.sql
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

-- 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;
Comment on lines +89 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Chercher un ALTER DEFAULT PRIVILEGES ou GRANT EXECUTE existant pour service_role sur les routines.
rg -n -i 'default privileges|grant.*execute.*service_role|grant all on all routines' supabase/migrations

Repository: Zoubeir23/DocFlowAI

Length of output: 667


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate migration files =="
git ls-files 'supabase/migrations/*.sql' | sort -V | sed -n '1,40p'

echo
echo "== rotation function references =="
rg -n 'rotate_patient_carnet_code|REVOKE ALL ON FUNCTION|grant[[:space:]]+execute[[:space:]]+on[[:space:]]+function|alter default privileges' supabase/migrations -i

echo
echo "== target migration header and security lines =="
sed -n '1,120p' supabase/migrations/014_journalisation_et_rotation_carnets.sql | cat -n

echo
echo "== policy/call path =="
fd -a 'patients\.(ts|tsx)$' . | sed 's#^\./##' | while read -r f; do
  echo "--- $f"
  rg -n 'rotate_patient_carnet_code|rpc\(' "$f"
done

echo
echo "== security hardening relevant lines =="
sed -n '60,105p' supabase/migrations/005_security_hardening.sql | cat -n

echo
echo "== service_role grants across migrations =="
python3 - <<'PY'
from pathlib import Path
import re
for p in sorted(Path("supabase/migrations").glob("*.sql")):
    txt=p.read_text(errors="replace")
    lines=[(n,l) for n,l in enumerate(txt.splitlines(),1) if re.search(r'grant\s+execute\s+on\s+function\s+[^;\n]+service_role', l.strip(), re.I)]
    if lines:
        print(f"{p}")
        for n,l in lines:
            print(f"  {n}: {l.strip()}")
PY

Repository: Zoubeir23/DocFlowAI

Length of output: 10212


Accordez EXECUTE à service_role pour rotate_patient_carnet_code.

Ce fichier crée la fonction puis révoque PUBLIC, anon et authenticated, mais il ne restaure pas le droit pour service_role. Appelée côté serveur via adminDb.rpc(...), cette fonction doit explicitement accorder GRANT EXECUTE ON FUNCTION rotate_patient_carnet_code(UUID) TO service_role, comme pour les autres fonctions sécurisées.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/014_journalisation_et_rotation_carnets.sql` around lines
75 - 79, Accordez explicitement le privilège EXECUTE sur la fonction
rotate_patient_carnet_code(UUID) au rôle service_role après les révocations
existantes, afin que les appels serveur via adminDb.rpc(...) restent autorisés.

Loading