diff --git a/__tests__/lib/drug-interactions.test.ts b/__tests__/lib/drug-interactions.test.ts new file mode 100644 index 0000000..4a988fe --- /dev/null +++ b/__tests__/lib/drug-interactions.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { checkDrugInteractions } from "@/lib/who-drug-interactions"; + +function buildRxNavResponse(severity: string) { + return { + fullInteractionTypeGroup: [ + { + sourceDisclaimer: "", + sourceName: "DrugBank", + fullInteractionType: [ + { + minConcept: [], + interactionPair: [ + { + interactionConcept: [ + { + minConceptItem: { rxcui: "41493", name: "Warfarine", tty: "IN" }, + sourceConceptItem: { id: "", name: "", url: "", dictionaryTitle: "" }, + }, + { + minConceptItem: { rxcui: "1191", name: "Aspirine", tty: "IN" }, + sourceConceptItem: { id: "", name: "", url: "", dictionaryTitle: "" }, + }, + ], + severity, + description: "Risque hémorragique accru.", + }, + ], + }, + ], + }, + ], + }; +} + +function mockFetchResolving(response: Partial & { json?: () => Promise }) { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("checkDrugInteractions", () => { + it("signale le service indisponible quand RxNav répond en erreur", async () => { + mockFetchResolving({ ok: false, status: 503 }); + + const result = await checkDrugInteractions(["41493", "1191"]); + + // Le point critique : une panne ne doit jamais ressembler à « aucune + // interaction », sinon le prescripteur lit une confirmation infondée. + expect(result.status).toBe("unavailable"); + expect(result.interactions).toEqual([]); + }); + + it("signale le service indisponible quand la requête échoue", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("réseau injoignable"))); + + const result = await checkDrugInteractions(["41493", "1191"]); + + expect(result.status).toBe("unavailable"); + }); + + it("distingue une absence réelle d'interaction d'une indisponibilité", async () => { + mockFetchResolving({ ok: true, json: async () => ({}) }); + + const result = await checkDrugInteractions(["41493", "1191"]); + + expect(result.status).toBe("checked"); + expect(result.interactions).toEqual([]); + expect(result.hasCritical).toBe(false); + }); + + it("ne contacte pas RxNav avec moins de deux codes valides", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const result = await checkDrugInteractions(["41493", "pas-un-code", ""]); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.status).toBe("checked"); + expect(result.interactions).toEqual([]); + }); + + it("remonte les interactions et marque les sévérités hautes", async () => { + mockFetchResolving({ ok: true, json: async () => buildRxNavResponse("major") }); + + const result = await checkDrugInteractions(["41493", "1191"]); + + expect(result.status).toBe("checked"); + expect(result.interactions).toHaveLength(1); + expect(result.interactions[0]).toMatchObject({ + drug1Name: "Warfarine", + drug2Name: "Aspirine", + severity: "high", + source: "DrugBank", + }); + expect(result.hasCritical).toBe(true); + }); + + it("ne remonte qu'une fois une paire dupliquée dans deux sources", async () => { + const duplicated = buildRxNavResponse("moderate"); + duplicated.fullInteractionTypeGroup.push({ + ...duplicated.fullInteractionTypeGroup[0], + sourceName: "ONCHigh", + }); + mockFetchResolving({ ok: true, json: async () => duplicated }); + + const result = await checkDrugInteractions(["41493", "1191"]); + + expect(result.interactions).toHaveLength(1); + expect(result.hasCritical).toBe(false); + }); +}); diff --git a/actions/doctor-signature.ts b/actions/doctor-signature.ts index eeaa9ed..6dacbb6 100644 --- a/actions/doctor-signature.ts +++ b/actions/doctor-signature.ts @@ -81,14 +81,28 @@ export async function deleteDoctorSignature(): Promise<{ success: boolean; error return { success: true }; } -export async function getDoctorSignatureByUserId( - userId: string +/** + * Signature à apposer sur le document d'un diagnostic validé. + * + * Le validateur est résolu côté serveur à partir du diagnostic : aucune Server + * Action ne prend d'identifiant d'utilisateur libre, ce qui évite qu'un membre + * de la clinique puisse récupérer l'image de signature d'un confrère sans + * passer par un document réel. Tant que le diagnostic n'est pas validé, aucune + * signature n'est renvoyée. + */ +export async function getSignatureForValidatedDiagnostic( + diagnosticId: string ): Promise { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) return null; - const { data: callerData } = await (supabase as any) + // Les tables métier ne figurent pas dans les types générés : un seul cast + // local plutôt qu'un par requête. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = supabase as any; + + const { data: callerData } = await db .from("users") .select("clinic_id") .eq("id", user.id) @@ -96,10 +110,25 @@ export async function getDoctorSignatureByUserId( if (!callerData) return null; - const { data } = await (supabase as any) + // Le diagnostic est lu dans la clinique de l'appelant : un identifiant + // appartenant à une autre clinique ne remonte rien. + const { data: diagnostic } = await db + .from("diagnostics") + .select("validated_by_user_id, validation_status") + .eq("id", diagnosticId) + .eq("clinic_id", callerData.clinic_id) + .maybeSingle() as { + data: { validated_by_user_id: string | null; validation_status: string } | null; + }; + + if (!diagnostic?.validated_by_user_id || diagnostic.validation_status !== "validated") { + return null; + } + + const { data } = await db .from("doctor_signatures") .select("*") - .eq("user_id", userId) + .eq("user_id", diagnostic.validated_by_user_id) .eq("clinic_id", callerData.clinic_id) .maybeSingle() as { data: DoctorSignature | null }; diff --git a/app/(app)/app/diagnostics/[id]/page.tsx b/app/(app)/app/diagnostics/[id]/page.tsx index 1d907e9..88ae11f 100644 --- a/app/(app)/app/diagnostics/[id]/page.tsx +++ b/app/(app)/app/diagnostics/[id]/page.tsx @@ -2,7 +2,7 @@ import { notFound } from "next/navigation"; import Link from "next/link"; import { ArrowLeft, FileEdit, Stethoscope } from "lucide-react"; import { getDiagnosticById } from "@/actions/diagnostics"; -import { getDoctorSignature } from "@/actions/doctor-signature"; +import { getSignatureForValidatedDiagnostic } from "@/actions/doctor-signature"; import { PrescriptionPrintDocument } from "@/components/diagnostics/prescription-print-document"; import { DiagnosticValidationPanel } from "@/components/diagnostics/diagnostic-validation-panel"; import { ComorbiditiesPanel } from "@/components/diagnostics/comorbidities-panel"; @@ -13,13 +13,17 @@ interface DiagnosticDetailPageProps { export default async function DiagnosticDetailPage({ params }: DiagnosticDetailPageProps) { const { id } = await params; - const [diagnostic, signature] = await Promise.all([ - getDiagnosticById(id), - getDoctorSignature(), - ]); + const diagnostic = await getDiagnosticById(id); if (!diagnostic) notFound(); + // Le document porte la signature du médecin qui a validé le diagnostic, et non + // celle de la personne qui consulte la page : afficher la signature du lecteur + // sous le nom et le n° RPPS d'un autre praticien produirait un document faux. + // L'action résout elle-même le validateur et ne renvoie rien tant que le + // diagnostic n'est pas validé. + const signature = await getSignatureForValidatedDiagnostic(diagnostic.id); + const STATUS_STYLES: Record = { draft: "bg-gray-100 text-gray-600", pending_validation: "bg-amber-100 text-amber-700", diff --git a/app/api/who/drug-interactions/route.ts b/app/api/who/drug-interactions/route.ts index 7cc22a1..e74d440 100644 --- a/app/api/who/drug-interactions/route.ts +++ b/app/api/who/drug-interactions/route.ts @@ -29,5 +29,15 @@ export async function POST(request: Request) { const validRxcuis = rxcuis.filter((id) => typeof id === "string" && id.trim() !== ""); const result = await checkDrugInteractions(validRxcuis); + + // Une indisponibilité de RxNav est une erreur, pas un résultat : la renvoyer + // en 200 laisserait le client l'afficher comme « aucune interaction ». + if (result.status === "unavailable") { + return NextResponse.json( + { error: "Service d'interactions indisponible", status: result.status }, + { status: 502 } + ); + } + return NextResponse.json(result); } diff --git a/components/diagnostics/prescription-builder-step.tsx b/components/diagnostics/prescription-builder-step.tsx index 7f628af..3194a25 100644 --- a/components/diagnostics/prescription-builder-step.tsx +++ b/components/diagnostics/prescription-builder-step.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { Plus, Trash2, AlertTriangle, Pill, ClipboardList, ActivitySquare, Loader2 } from "lucide-react"; +import { Plus, Trash2, AlertTriangle, Pill, ClipboardList, ActivitySquare, Loader2, Check } from "lucide-react"; import { AtcDrugSearch } from "@/components/diagnostics/atc-drug-search"; import { IcfSearchField } from "@/components/diagnostics/icf-search-field"; import { DrugInteractionWarning } from "@/components/diagnostics/drug-interaction-warning"; @@ -98,6 +98,9 @@ function VigibaseSignalBadge({ signal }: { signal: PharmacovigilanceSignal }) { ); } +/** Issue du contrôle d'interactions, rendue visible dans tous les cas. */ +type InteractionCheckState = "idle" | "checking" | "clear" | "found" | "failed"; + interface PrescriptionBuilderStepProps { validatedDiagnosisName: string; patientAllergies: string[]; @@ -123,7 +126,12 @@ export function PrescriptionBuilderStep({ const [icfCodes, setIcfCodes] = useState([]); const [drugInteractions, setDrugInteractions] = useState([]); - const [interactionLoading, setInteractionLoading] = useState(false); + const [interactionCheckState, setInteractionCheckState] = useState("idle"); + const [uncodedDrugCount, setUncodedDrugCount] = useState(0); + // Identifiant monotone : deux contrôles peuvent se chevaucher après des + // modifications rapides, et une réponse ancienne ne doit jamais écraser une + // plus récente — un « aucune interaction » périmé masquerait une alerte réelle. + const latestInteractionCheckId = useRef(0); const [vigibaseSignals, setVigibaseSignals] = useState>(new Map()); const { register, handleSubmit, control, watch, formState: { errors } } = useForm>({ @@ -137,32 +145,64 @@ export function PrescriptionBuilderStep({ const selectedDocumentType = watch("document_type"); - // Check drug-drug interactions whenever treatments with rxcui change + // Contrôle des interactions à chaque changement de traitement. + // + // Chaque issue est rendue visible : un échec réseau ne doit jamais ressembler + // à « aucune interaction », sans quoi le prescripteur lit l'absence d'alerte + // comme un feu vert. Les médicaments sans code RxNorm sortent du contrôle et + // sont comptés pour être signalés explicitement. const checkInteractions = useCallback(async (currentTreatments: PrescriptionTreatment[]) => { - const rxcuis = currentTreatments + const namedTreatments = currentTreatments.filter( + (treatment) => treatment.drug_name && treatment.drug_name.trim() !== "" + ); + const rxcuis = namedTreatments .map((treatment) => treatment.rxcui) - .filter((code) => code && code.trim() !== "" && /^\d+$/.test(code)); + .filter((code): code is string => Boolean(code) && /^\d+$/.test(code!)); + + setUncodedDrugCount(namedTreatments.length - rxcuis.length); if (rxcuis.length < 2) { setDrugInteractions([]); + setInteractionCheckState("idle"); return; } - setInteractionLoading(true); + const checkId = ++latestInteractionCheckId.current; + const isStale = () => checkId !== latestInteractionCheckId.current; + + setInteractionCheckState("checking"); try { const response = await fetch("/api/who/drug-interactions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rxcuis }), }); - if (response.ok) { - const result: { interactions: DrugInteractionPair[] } = await response.json(); - setDrugInteractions(result.interactions ?? []); + if (isStale()) return; + + if (!response.ok) { + setDrugInteractions([]); + setInteractionCheckState("failed"); + return; } + + const result: unknown = await response.json(); + if (isStale()) return; + + // Une réponse dont la forme est inattendue est traitée comme un échec : + // la lire comme une liste vide afficherait une confirmation infondée. + const foundInteractions = (result as { interactions?: unknown })?.interactions; + if (!Array.isArray(foundInteractions)) { + setDrugInteractions([]); + setInteractionCheckState("failed"); + return; + } + + setDrugInteractions(foundInteractions as DrugInteractionPair[]); + setInteractionCheckState(foundInteractions.length > 0 ? "found" : "clear"); } catch { - // Silently ignore network errors — interactions are advisory only - } finally { - setInteractionLoading(false); + if (isStale()) return; + setDrugInteractions([]); + setInteractionCheckState("failed"); } }, []); @@ -419,17 +459,62 @@ export function PrescriptionBuilderStep({ ))} - {/* Drug-drug interactions */} - {(drugInteractions.length > 0 || interactionLoading) && ( + {/* Contrôle des interactions : chaque issue est affichée, y compris l'échec */} + {(interactionCheckState !== "idle" || uncodedDrugCount > 0) && (
- -

- Interactions médicamenteuses - {interactionLoading && } + +

+ {t("prescriptionStep.interactionsTitle")} + {interactionCheckState === "checking" && ( + + )}

- + + {interactionCheckState === "checking" && ( +

+ {t("prescriptionStep.interactionsChecking")} +

+ )} + + {interactionCheckState === "found" && ( + + )} + + {interactionCheckState === "clear" && ( +

+ + {t("prescriptionStep.interactionsClear")} +

+ )} + + {interactionCheckState === "failed" && ( +
+

+ {t("prescriptionStep.interactionsFailed")} +

+ +
+ )} + + {uncodedDrugCount > 0 && ( +

+ {t("prescriptionStep.interactionsUncoded", { count: uncodedDrugCount })} +

+ )}
)} diff --git a/lib/who-drug-interactions.ts b/lib/who-drug-interactions.ts index 670e54a..f96b46b 100644 --- a/lib/who-drug-interactions.ts +++ b/lib/who-drug-interactions.ts @@ -11,11 +11,27 @@ import type { DrugInteractionPair } from "@/types"; +/** + * Issue du contrôle d'interactions. + * + * `unavailable` doit rester distinct d'une liste vide : renvoyer « aucune + * interaction » quand RxNav est injoignable ferait lire au prescripteur une + * confirmation rassurante alors qu'aucun contrôle n'a eu lieu. + */ +export type DrugInteractionStatus = "checked" | "unavailable"; + export interface DrugInteractionResult { + status: DrugInteractionStatus; hasCritical: boolean; interactions: DrugInteractionPair[]; } +const UNAVAILABLE_RESULT: DrugInteractionResult = { + status: "unavailable", + hasCritical: false, + interactions: [], +}; + const RXNAV_BASE_URL = "https://rxnav.nlm.nih.gov/REST"; interface RxNormInteractionResponse { @@ -49,7 +65,7 @@ export async function checkDrugInteractions( ): Promise { const validRxcuis = rxcuis.filter((id) => id && id.trim() !== "" && /^\d+$/.test(id)); if (validRxcuis.length < 2) { - return { hasCritical: false, interactions: [] }; + return { status: "checked", hasCritical: false, interactions: [] }; } try { @@ -60,7 +76,7 @@ export async function checkDrugInteractions( }); if (!response.ok) { - return { hasCritical: false, interactions: [] }; + return UNAVAILABLE_RESULT; } const data: RxNormInteractionResponse = await response.json(); @@ -91,8 +107,8 @@ export async function checkDrugInteractions( } const hasCritical = interactions.some((i) => i.severity === "high"); - return { hasCritical, interactions }; + return { status: "checked", hasCritical, interactions }; } catch { - return { hasCritical: false, interactions: [] }; + return UNAVAILABLE_RESULT; } } diff --git a/messages/en.json b/messages/en.json index 159ce99..a256a2a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1161,7 +1161,13 @@ "back": "Back", "generating": "Generating...", "generateAndSave": "Generate and save prescription", - "allergyWarning": "Allergy detected — verify this medication" + "allergyWarning": "Allergy detected — verify this medication", + "interactionsTitle": "Drug interactions", + "interactionsChecking": "Checking…", + "interactionsClear": "No known interaction between the coded medications.", + "interactionsFailed": "Interaction check unavailable. Verify manually before prescribing.", + "interactionsRetry": "Run the check again", + "interactionsUncoded": "{count} medication(s) without an RxNorm code are not covered by the check." }, "patientStep": { "nextButton": "Next: Symptoms and vital signs" diff --git a/messages/fr.json b/messages/fr.json index 1d5557a..84482ca 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1161,7 +1161,13 @@ "back": "Retour", "generating": "Génération...", "generateAndSave": "Générer et enregistrer l'ordonnance", - "allergyWarning": "Allergie détectée — vérifiez ce médicament" + "allergyWarning": "Allergie détectée — vérifiez ce médicament", + "interactionsTitle": "Interactions médicamenteuses", + "interactionsChecking": "Contrôle en cours…", + "interactionsClear": "Aucune interaction connue entre les médicaments codés.", + "interactionsFailed": "Contrôle des interactions indisponible. Vérifiez manuellement avant de prescrire.", + "interactionsRetry": "Relancer le contrôle", + "interactionsUncoded": "{count} médicament(s) sans code RxNorm ne sont pas couverts par le contrôle." }, "patientStep": { "nextButton": "Suivant : Symptômes et signes vitaux" diff --git a/types/index.ts b/types/index.ts index 83f865f..a478cc1 100644 --- a/types/index.ts +++ b/types/index.ts @@ -179,6 +179,8 @@ export interface DiagnosticRecord { validated_diagnosis_code: string | null; validated_diagnosis_name: string | null; validated_by: string | null; + /** Compte réellement à l'origine de la validation (migration 010). */ + validated_by_user_id: string | null; validated_at: string | null; rejection_reason: string | null;