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
120 changes: 120 additions & 0 deletions __tests__/lib/document-seal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import {
buildDocumentFingerprint,
computeDocumentSeal,
verifyDocumentSeal,
formatSealReference,
type SealableDocument,
} from "@/lib/document-seal";

const BASE_DOCUMENT: SealableDocument = {
document_type: "prescription",
patient_full_name: "Amina Diallo",
patient_age_years: 42,
patient_sex: "female",
patient_weight_kg: 68,
patient_blood_group: "O+",
validated_diagnosis_code: "BA00",
validated_diagnosis_name: "Hypertension essentielle",
chief_complaint: "Céphalées répétées",
clinical_notes: "Tension élevée sur trois mesures.",
treatments: [{ drug_name: "Lisinopril", dosage_mg: "10 mg", duration_days: 30 }],
recommendations: ["Réduire le sel"],
follow_up_delay_days: 30,
follow_up_tests: ["Ionogramme"],
practitioner_name: "Martin",
practitioner_title: "Dr.",
practitioner_rpps: "10101010101",
validated_by: "Dr. Martin",
validated_by_user_id: "0f7a1c9e-3c5b-4c1a-9f2d-8e5b6a4c1d2f",
validated_at: "2026-08-02T09:30:00.000Z",
};

function withChange(change: Partial<SealableDocument>): SealableDocument {
return { ...BASE_DOCUMENT, ...change };
}

describe("computeDocumentSeal", () => {
it("produit la même empreinte pour un contenu identique", () => {
expect(computeDocumentSeal(BASE_DOCUMENT)).toBe(computeDocumentSeal({ ...BASE_DOCUMENT }));
});

it("ne dépend pas de l'ordre d'insertion des clés", () => {
// Un objet reconstruit dans un autre ordre décrit le même document : il ne
// doit pas déclencher une fausse alerte de falsification.
const reordered = Object.fromEntries(
Object.entries(BASE_DOCUMENT).reverse()
) as unknown as SealableDocument;

expect(computeDocumentSeal(reordered)).toBe(computeDocumentSeal(BASE_DOCUMENT));
});

it("produit une empreinte SHA-256 hexadécimale", () => {
expect(computeDocumentSeal(BASE_DOCUMENT)).toMatch(/^[0-9a-f]{64}$/);
});
});

describe("détection des modifications", () => {
const scenarios: Array<[string, Partial<SealableDocument>]> = [
["la posologie d'un traitement", {
treatments: [{ drug_name: "Lisinopril", dosage_mg: "40 mg", duration_days: 30 }],
}],
["l'ajout d'un traitement", {
treatments: [
{ drug_name: "Lisinopril", dosage_mg: "10 mg", duration_days: 30 },
{ drug_name: "Tramadol", dosage_mg: "50 mg", duration_days: 7 },
],
}],
["le diagnostic retenu", { validated_diagnosis_code: "5A11" }],
["l'identité du patient", { patient_full_name: "Amina Diallo-Sy" }],
["le numéro RPPS du praticien", { practitioner_rpps: "20202020202" }],
["le nom du validateur", { validated_by: "Dr. Autre" }],
["une recommandation", { recommendations: ["Réduire le sel", "Arrêter le tabac"] }],
["les notes cliniques", { clinical_notes: "Tension normale." }],
];

for (const [label, change] of scenarios) {
it(`rompt le sceau si l'on modifie ${label}`, () => {
const seal = computeDocumentSeal(BASE_DOCUMENT);
expect(verifyDocumentSeal(withChange(change), seal)).toBe("tampered");
});
}

it("confirme un document intact", () => {
const seal = computeDocumentSeal(BASE_DOCUMENT);
expect(verifyDocumentSeal(BASE_DOCUMENT, seal)).toBe("sealed");
});

it("distingue un document jamais scellé d'un document altéré", () => {
expect(verifyDocumentSeal(BASE_DOCUMENT, null)).toBe("unsealed");
expect(verifyDocumentSeal(BASE_DOCUMENT, "")).toBe("unsealed");
});
});

describe("buildDocumentFingerprint", () => {
it("distingue null d'une chaîne vide", () => {
const withNull = buildDocumentFingerprint(withChange({ clinical_notes: null }));
const withEmpty = buildDocumentFingerprint(withChange({ clinical_notes: "" }));

expect(withNull).not.toBe(withEmpty);
});

it("tient compte de l'ordre des traitements", () => {
// Deux ordonnances listant les mêmes molécules dans un ordre différent sont
// deux documents différents à l'impression : le sceau doit le refléter.
const first = buildDocumentFingerprint(
withChange({ treatments: [{ drug_name: "A" }, { drug_name: "B" }] })
);
const second = buildDocumentFingerprint(
withChange({ treatments: [{ drug_name: "B" }, { drug_name: "A" }] })
);

expect(first).not.toBe(second);
});
});

describe("formatSealReference", () => {
it("met en forme un préfixe lisible pour impression", () => {
expect(formatSealReference("a1b2c3d4e5f60718293a4b5c6d7e8f90")).toBe("A1B2-C3D4-E5F6-0718");
});
});
38 changes: 37 additions & 1 deletion actions/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use server";

import { createClient } from "@/lib/supabase/server";
import { computeDocumentSeal } from "@/lib/document-seal";
import type {
DiagnosticRecord,
PatientProfileInput,
Expand Down Expand Up @@ -313,7 +314,39 @@ export async function updateDiagnosticPrescription(
return { success: false, error: "Le diagnostic doit d'abord être validé par un médecin." };
}

const { error } = await (supabase as any)
// Un seul cast pour les deux requêtes de cette fonction, plutôt qu'un par appel.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any;

// Champs du document qui ne figurent pas dans le formulaire de prescription :
// ils entrent pourtant dans l'empreinte, puisqu'ils sont imprimés.
const { data: existing } = await db
.from("diagnostics")
.select(
"patient_full_name, patient_age_years, patient_sex, patient_weight_kg, patient_blood_group, validated_diagnosis_code, validated_diagnosis_name, chief_complaint, clinical_notes, validated_by, validated_by_user_id, validated_at"
)
.eq("id", diagnosticId)
.eq("clinic_id", clinicId)
.maybeSingle();

if (!existing) return { success: false, error: "Diagnostic introuvable" };

// Le sceau est calculé sur le contenu tel qu'il sera enregistré, jamais sur ce
// que le client prétend avoir produit : une empreinte fournie par l'appelant
// ne prouverait rien.
const documentSeal = computeDocumentSeal({
...existing,
document_type: prescription.document_type,
treatments: prescription.treatments,
recommendations: prescription.recommendations,
follow_up_delay_days: prescription.follow_up_delay_days,
follow_up_tests: prescription.follow_up_tests,
practitioner_name: prescription.practitioner_name,
practitioner_title: prescription.practitioner_title,
practitioner_rpps: prescription.practitioner_rpps,
});

const { error } = await db
.from("diagnostics")
.update({
document_type: prescription.document_type,
Expand All @@ -328,6 +361,9 @@ export async function updateDiagnosticPrescription(
// désormais imputable à un compte réel et vérifiable.
prescribed_by_user_id: userId,
icf_codes: prescription.icf_codes ?? [],
document_seal: documentSeal,
document_sealed_at: new Date().toISOString(),
document_sealed_by_user_id: userId,
current_step: nextStep(state.current_step, 6),
})
.eq("id", diagnosticId)
Expand Down
10 changes: 10 additions & 0 deletions app/(app)/app/diagnostics/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Link from "next/link";
import { ArrowLeft, FileEdit, Stethoscope } from "lucide-react";
import { getDiagnosticById } from "@/actions/diagnostics";
import { getSignatureForValidatedDiagnostic } from "@/actions/doctor-signature";
import { verifyDocumentSeal, formatSealReference } from "@/lib/document-seal";
import { PrescriptionPrintDocument } from "@/components/diagnostics/prescription-print-document";
import { DiagnosticValidationPanel } from "@/components/diagnostics/diagnostic-validation-panel";
import { ComorbiditiesPanel } from "@/components/diagnostics/comorbidities-panel";
Expand All @@ -24,6 +25,11 @@ export default async function DiagnosticDetailPage({ params }: DiagnosticDetailP
// diagnostic n'est pas validé.
const signature = await getSignatureForValidatedDiagnostic(diagnostic.id);

// L'empreinte est recalculée à chaque affichage : une modification du contenu
// postérieure à la production du document devient visible, au lieu de passer
// sous une signature qui ne l'engage plus.
const sealStatus = verifyDocumentSeal(diagnostic, diagnostic.document_seal);

const STATUS_STYLES: Record<string, string> = {
draft: "bg-gray-100 text-gray-600",
pending_validation: "bg-amber-100 text-amber-700",
Expand Down Expand Up @@ -107,6 +113,10 @@ export default async function DiagnosticDetailPage({ params }: DiagnosticDetailP
<PrescriptionPrintDocument
diagnostic={diagnostic}
signatureDataUrl={signature?.signature_data_url}
sealStatus={sealStatus}
sealReference={
diagnostic.document_seal ? formatSealReference(diagnostic.document_seal) : undefined
}
/>
</div>
</div>
Expand Down
28 changes: 27 additions & 1 deletion components/diagnostics/prescription-print-document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

import { format, parseISO } from "date-fns";
import { fr } from "date-fns/locale";
import { Printer } from "lucide-react";
import { Printer, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { DiagnosticRecord } from "@/types";

/** Statut du sceau, calculé côté serveur : ce composant ne recalcule rien. */
type DocumentSealStatus = "sealed" | "unsealed" | "tampered";

const DOCUMENT_TYPE_LABELS: Record<string, string> = {
consultation: "Compte rendu de consultation",
prescription: "Ordonnance médicale",
Expand Down Expand Up @@ -36,13 +39,19 @@ interface PrescriptionPrintDocumentProps {
clinicName?: string;
clinicAddress?: string;
signatureDataUrl?: string;
/** Résultat de la vérification du sceau, calculée côté serveur. */
sealStatus?: DocumentSealStatus;
/** Référence courte imprimable du sceau, mise en forme côté serveur. */
sealReference?: string;
}

export function PrescriptionPrintDocument({
diagnostic,
clinicName = "Cabinet médical",
clinicAddress,
signatureDataUrl,
sealStatus = "unsealed",
sealReference,
}: PrescriptionPrintDocumentProps) {
const documentTitle = (diagnostic.document_type ? DOCUMENT_TYPE_LABELS[diagnostic.document_type] : null) ?? "Document médical";
const formattedDate = format(parseISO(diagnostic.created_at), "d MMMM yyyy", { locale: fr });
Expand All @@ -56,6 +65,20 @@ export function PrescriptionPrintDocument({
</Button>
</div>

{sealStatus === "tampered" && (
<div className="flex items-start gap-3 rounded-2xl border-2 border-red-300 bg-red-50 p-4">
<ShieldAlert className="w-5 h-5 text-red-600 shrink-0 mt-0.5" />
<div className="text-sm text-red-800">
<p className="font-bold">Document modifié après signature</p>
<p className="mt-0.5">
Le contenu ne correspond plus à celui scellé lors de la production du document. La
signature affichée ne l&apos;engage pas. Régénérez le document avant toute remise au
patient.
</p>
</div>
</div>
)}

{/* Document */}
<div className="bg-white text-gray-900 rounded-2xl border border-gray-200 shadow-sm print:shadow-none print:border-none print:rounded-none">

Expand Down Expand Up @@ -266,6 +289,9 @@ export function PrescriptionPrintDocument({
<div className="text-xs text-gray-600 space-y-1">
<p>Document généré le {formattedDate}</p>
<p className="font-mono">Réf: {diagnostic.id.slice(0, 8).toUpperCase()}</p>
{sealStatus === "sealed" && sealReference && (
<p className="font-mono text-gray-500">Sceau: {sealReference}</p>
)}
</div>
<div className="text-center space-y-2">
<p className="text-xs text-gray-600">Signature et cachet</p>
Expand Down
Loading
Loading