From b99b70127d316a7d6153c248751a63fdca73bcec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:36:42 +0200 Subject: [PATCH] fix(anamnesis): one newest AUDIT-C entry, scored against the wording offered The stored language is the wording the read route offers the account; a request naming another wording, or a takenAt more than five minutes ahead, is refused. Latest, offerDue, the Coach block and the doctor report all read the newest entry whose answers open. The old one-word alcohol pattern gives way to an AUDIT-C entry in the Coach block, and the alcohol section lists it with a remove action. --- docs/api/openapi.yaml | 15 ++- messages/de.json | 9 +- messages/en.json | 9 +- messages/es.json | 9 +- messages/fr.json | 9 +- messages/it.json | 9 +- messages/ko.json | 9 +- messages/pl.json | 9 +- src/app/api/anamnesis/audit-c/route.ts | 33 ++++++- .../__tests__/audit-c-legacy-pattern.test.tsx | 73 +++++++++++++++ src/components/records/audit-c-panel.tsx | 68 +++++++++++++- .../profile-ai-section-gating.test.ts | 81 ++++++++++++++-- src/lib/ai/coach/about-me.ts | 17 ++-- src/lib/openapi/routes/life-circumstances.ts | 14 ++- src/lib/profile/audit-c-entries.ts | 93 +++++++++++++++++++ src/lib/profile/life-circumstances.ts | 72 ++++---------- tests/integration/life-circumstances.test.ts | 73 +++++++++++++++ 17 files changed, 515 insertions(+), 87 deletions(-) create mode 100644 src/components/records/__tests__/audit-c-legacy-pattern.test.tsx create mode 100644 src/lib/profile/audit-c-entries.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f735b0d4e..6c1255829 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -24630,8 +24630,11 @@ paths: tags: - Anamnesis summary: Record an AUDIT-C (v1.43) - description: Scores the three items on the server. A replay with the same `externalId` answers 200 with the stored - result. 422 `anamnesis.auditC.invalid`; 429 `anamnesis.auditC.rateLimited` after 30 in an hour. + description: Scores the three items on the server. The entry is stored in the language of the wording the read route + offers this account (`auditC.instrument.locale`), and the cut-off follows that wording; `locale`, when sent, + must name that same wording. A replay with the same `externalId` answers 200 with the stored result. 422 + `anamnesis.auditC.invalid` (also for another wording than the one offered, and for a `takenAt` more than five + minutes ahead of the server clock); 429 `anamnesis.auditC.rateLimited` after 30 in an hour. parameters: - *a6 requestBody: @@ -38941,6 +38944,9 @@ components: id: type: string minLength: 1 + ref: + type: string + minLength: 1 name: type: string minLength: 1 @@ -40523,6 +40529,9 @@ components: id: type: string minLength: 1 + ref: + type: string + minLength: 1 panel: anyOf: - type: string @@ -86403,6 +86412,7 @@ components: anyOf: - $ref: "#/components/schemas/AuditCResult" - type: "null" + description: The newest entry whose answers this host can open; an entry it cannot open is listed in `history` only. history: type: array items: @@ -86426,6 +86436,7 @@ components: description: Newest first, at most 20. offerDue: type: boolean + description: No readable entry, or the newest one whose answers open (the one in `latest`) is 365 days old or older. instrument: type: object properties: diff --git a/messages/de.json b/messages/de.json index a9c1f83c5..d514020f9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "Der Eintrag verschwindet aus deinem Verlauf. Das lässt sich hier nicht rückgängig machen.", "removedToast": "Eintrag entfernt", "removeError": "Der Eintrag konnte nicht entfernt werden.", - "historyEntry": "{date}: {score} von 12" + "historyEntry": "{date}: {score} von 12", + "legacyLabel": "Frühere Angabe: {value}", + "legacyHint": "Aus der kurzen Alkoholfrage vor diesem Fragebogen. Sobald ein Fragebogen-Ergebnis vorliegt, liest der Coach sie nicht mehr.", + "legacyRemove": "Frühere Angabe entfernen", + "legacyRemoveConfirmTitle": "Frühere Angabe entfernen?", + "legacyRemoveConfirmBody": "Die frühere Angabe verschwindet aus deinen Daten. Fragebogen-Ergebnisse bleiben.", + "legacyRemovedToast": "Frühere Angabe entfernt", + "legacyRemoveError": "Die frühere Angabe konnte nicht entfernt werden." }, "takeOver": { "conditionsButton": "In Zustandstagebuch übernehmen", diff --git a/messages/en.json b/messages/en.json index ca6175a86..84b69140a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "The entry leaves your history. This cannot be undone here.", "removedToast": "Entry removed", "removeError": "The entry could not be removed.", - "historyEntry": "{date}: {score} of 12" + "historyEntry": "{date}: {score} of 12", + "legacyLabel": "Earlier answer: {value}", + "legacyHint": "From the short alcohol question asked before this questionnaire. Once a questionnaire result is on file, the Coach no longer reads it.", + "legacyRemove": "Remove earlier answer", + "legacyRemoveConfirmTitle": "Remove the earlier answer?", + "legacyRemoveConfirmBody": "The earlier answer leaves your record. Questionnaire results stay.", + "legacyRemovedToast": "Earlier answer removed", + "legacyRemoveError": "The earlier answer could not be removed." }, "takeOver": { "conditionsButton": "Add to condition journal", diff --git a/messages/es.json b/messages/es.json index 8b39b0e3f..7f428795b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "La entrada sale de tu historial. Aquí no se puede deshacer.", "removedToast": "Entrada eliminada", "removeError": "No se pudo eliminar la entrada.", - "historyEntry": "{date}: {score} de 12" + "historyEntry": "{date}: {score} de 12", + "legacyLabel": "Respuesta anterior: {value}", + "legacyHint": "De la pregunta breve sobre alcohol anterior a este cuestionario. En cuanto hay un resultado del cuestionario, el Coach deja de leerla.", + "legacyRemove": "Eliminar respuesta anterior", + "legacyRemoveConfirmTitle": "¿Eliminar la respuesta anterior?", + "legacyRemoveConfirmBody": "La respuesta anterior sale de tu registro. Los resultados del cuestionario se mantienen.", + "legacyRemovedToast": "Respuesta anterior eliminada", + "legacyRemoveError": "No se pudo eliminar la respuesta anterior." }, "takeOver": { "conditionsButton": "Pasar al diario de afecciones", diff --git a/messages/fr.json b/messages/fr.json index 92e11b7cd..8f7f8f69f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "L'entrée quitte votre historique. Impossible d'annuler ici.", "removedToast": "Entrée supprimée", "removeError": "Impossible de supprimer l'entrée.", - "historyEntry": "{date} : {score} sur 12" + "historyEntry": "{date} : {score} sur 12", + "legacyLabel": "Réponse précédente : {value}", + "legacyHint": "Issue de la courte question sur l’alcool posée avant ce questionnaire. Dès qu’un résultat du questionnaire existe, le Coach ne la lit plus.", + "legacyRemove": "Supprimer la réponse précédente", + "legacyRemoveConfirmTitle": "Supprimer la réponse précédente ?", + "legacyRemoveConfirmBody": "La réponse précédente quitte votre dossier. Les résultats du questionnaire restent.", + "legacyRemovedToast": "Réponse précédente supprimée", + "legacyRemoveError": "La réponse précédente n’a pas pu être supprimée." }, "takeOver": { "conditionsButton": "Reprendre dans le journal des affections", diff --git a/messages/it.json b/messages/it.json index c25858eae..4e3f7b5b5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "La voce esce dalla tua cronologia. Qui non si può annullare.", "removedToast": "Voce rimossa", "removeError": "Impossibile rimuovere la voce.", - "historyEntry": "{date}: {score} su 12" + "historyEntry": "{date}: {score} su 12", + "legacyLabel": "Risposta precedente: {value}", + "legacyHint": "Dalla breve domanda sull’alcol posta prima di questo questionario. Quando c’è un risultato del questionario, il Coach non la legge più.", + "legacyRemove": "Rimuovi risposta precedente", + "legacyRemoveConfirmTitle": "Rimuovere la risposta precedente?", + "legacyRemoveConfirmBody": "La risposta precedente esce dai tuoi dati. I risultati del questionario restano.", + "legacyRemovedToast": "Risposta precedente rimossa", + "legacyRemoveError": "Non è stato possibile rimuovere la risposta precedente." }, "takeOver": { "conditionsButton": "Riporta nel diario delle condizioni", diff --git a/messages/ko.json b/messages/ko.json index 56230ec63..95d537ce8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "기록이 내역에서 사라져요. 여기서는 되돌릴 수 없어요.", "removedToast": "기록을 삭제했어요", "removeError": "기록을 삭제하지 못했어요.", - "historyEntry": "{date}: 12점 중 {score}점" + "historyEntry": "{date}: 12점 중 {score}점", + "legacyLabel": "이전 답변: {value}", + "legacyHint": "이 설문 전에 묻던 짧은 음주 질문의 답변입니다. 설문 결과가 있으면 코치는 더 이상 이 답변을 읽지 않습니다.", + "legacyRemove": "이전 답변 삭제", + "legacyRemoveConfirmTitle": "이전 답변을 삭제할까요?", + "legacyRemoveConfirmBody": "이전 답변이 기록에서 삭제됩니다. 설문 결과는 그대로 남습니다.", + "legacyRemovedToast": "이전 답변을 삭제했습니다", + "legacyRemoveError": "이전 답변을 삭제하지 못했습니다." }, "takeOver": { "conditionsButton": "상태 일지로 옮기기", diff --git a/messages/pl.json b/messages/pl.json index 0a5590b85..5fe13ba60 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -11279,7 +11279,14 @@ "removeConfirmBody": "Wpis zniknie z historii. Tutaj nie da się tego cofnąć.", "removedToast": "Wpis usunięty", "removeError": "Nie udało się usunąć wpisu.", - "historyEntry": "{date}: {score} z 12" + "historyEntry": "{date}: {score} z 12", + "legacyLabel": "Wcześniejsza odpowiedź: {value}", + "legacyHint": "Z krótkiego pytania o alkohol sprzed tego kwestionariusza. Gdy jest wynik kwestionariusza, Coach przestaje ją czytać.", + "legacyRemove": "Usuń wcześniejszą odpowiedź", + "legacyRemoveConfirmTitle": "Usunąć wcześniejszą odpowiedź?", + "legacyRemoveConfirmBody": "Wcześniejsza odpowiedź zniknie z Twoich danych. Wyniki kwestionariusza zostają.", + "legacyRemovedToast": "Wcześniejsza odpowiedź usunięta", + "legacyRemoveError": "Nie udało się usunąć wcześniejszej odpowiedzi." }, "takeOver": { "conditionsButton": "Przenieś do dziennika stanów", diff --git a/src/app/api/anamnesis/audit-c/route.ts b/src/app/api/anamnesis/audit-c/route.ts index fa979b285..cae7171d6 100644 --- a/src/app/api/anamnesis/audit-c/route.ts +++ b/src/app/api/anamnesis/audit-c/route.ts @@ -26,10 +26,14 @@ import { import { auditLog } from "@/lib/auth/audit"; import { withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; +import { auditCInstrument } from "@/lib/mental-health/audit-c"; import { createAuditC } from "@/lib/profile/life-circumstances"; import { checkRateLimit } from "@/lib/rate-limit"; import { auditCRequestSchema } from "@/lib/validations/life-circumstances"; +/** How far ahead of the server clock a `takenAt` may lie. */ +const AUDIT_C_CLOCK_SKEW_MS = 5 * 60 * 1000; + export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC)); async function postAuditC(request: NextRequest): Promise { @@ -54,11 +58,36 @@ async function postAuditC(request: NextRequest): Promise { } const input = parsed.data; + + // The cut-off follows the language of the wording that was answered, and + // the only wording this account is ever shown is the one the read route + // delivers for its language. The stored language is that one, decided + // here; a client naming another wording answered something this server + // did not offer (or the account language changed under an open form), and + // is refused rather than scored against the wrong table. + const offered = auditCInstrument(user.locale ?? "en").locale; + if ( + input.locale !== undefined && + auditCInstrument(input.locale).locale !== offered + ) { + return apiError("The wording answered is not the one offered", 422, { + errorCode: "anamnesis.auditC.invalid", + }); + } + // An entry dated in the future would hold the "newest" place for as long + // as the clock takes to reach it. A few minutes of device clock skew pass. + const takenAt = input.takenAt ? new Date(input.takenAt) : undefined; + if (takenAt && takenAt.getTime() > Date.now() + AUDIT_C_CLOCK_SKEW_MS) { + return apiError("takenAt lies in the future", 422, { + errorCode: "anamnesis.auditC.invalid", + }); + } + const { result, replayed } = await createAuditC(user.id, { items: input.items, - takenAt: input.takenAt ? new Date(input.takenAt) : undefined, + takenAt, tz: input.tz, - locale: input.locale ?? user.locale ?? "en", + locale: offered, source: input.source, externalId: input.externalId, }); diff --git a/src/components/records/__tests__/audit-c-legacy-pattern.test.tsx b/src/components/records/__tests__/audit-c-legacy-pattern.test.tsx new file mode 100644 index 000000000..89a74a2a8 --- /dev/null +++ b/src/components/records/__tests__/audit-c-legacy-pattern.test.tsx @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const state = vi.hoisted(() => ({ + facts: null as unknown, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: state.facts }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiDelete: vi.fn(), +})); +vi.mock("@/components/outcome/outcome-toast", () => ({ + toastWrittenOutcome: vi.fn(), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { auditCInstrument } from "@/lib/mental-health/audit-c"; +import { AuditCPanel } from "../audit-c-panel"; + +function render() { + return renderToStaticMarkup( + + {}} + /> + , + ); +} + +beforeEach(() => { + state.facts = null; +}); + +describe("the old alcohol pattern in the alcohol section", () => { + it("lists the stored pattern with a way to remove it", () => { + state.facts = { + current: { + SMOKING_STATUS: null, + SHIFT_SCHEDULE: null, + ALCOHOL_PATTERN: { id: "fact-1", value: "WEEKLY" }, + }, + history: [], + }; + const html = render(); + expect(html).toContain('data-slot="audit-c-legacy-pattern"'); + expect(html).toContain("Earlier answer: Weekly"); + expect(html).toContain("audit-c-legacy-remove"); + }); + + it("shows nothing when there is no stored pattern", () => { + state.facts = { + current: { + SMOKING_STATUS: null, + SHIFT_SCHEDULE: null, + ALCOHOL_PATTERN: null, + }, + history: [], + }; + expect(render()).not.toContain("audit-c-legacy-pattern"); + }); +}); diff --git a/src/components/records/audit-c-panel.tsx b/src/components/records/audit-c-panel.tsx index 233d51e6e..4edc2a06c 100644 --- a/src/components/records/audit-c-panel.tsx +++ b/src/components/records/audit-c-panel.tsx @@ -10,7 +10,7 @@ * and its source are the server's too; nothing is scored here. */ import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Trash2 } from "lucide-react"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -18,9 +18,10 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { apiDelete, apiPost } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { HealthProfileFactsPayload } from "@/lib/profile/health-facts"; import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances"; import { queryKeys } from "@/lib/query-keys"; @@ -76,6 +77,34 @@ export function AuditCPanel({ const complete = answers.every((answer) => answer !== null); + // The one-word alcohol pattern asked before AUDIT-C. It still lives in the + // record, so it is shown here, where alcohol now lives, with a way out. + // The Coach stops reading it once an AUDIT-C entry exists. + const queryClient = useQueryClient(); + const facts = useQuery({ + queryKey: queryKeys.healthProfileFacts(), + queryFn: () => apiGet("/api/anamnesis/facts"), + }); + const legacy = facts.data?.current.ALCOHOL_PATTERN ?? null; + const removeLegacy = useMutation({ + mutationKey: queryKeys.healthProfileFacts(), + mutationFn: (id: string) => apiDelete(`/api/anamnesis/facts/${id}`), + onSuccess: () => { + toastWrittenOutcome( + "success", + t("records.life.auditC.legacyRemovedToast"), + ); + void queryClient.invalidateQueries({ + queryKey: queryKeys.healthProfileFacts(), + }); + onChanged(); + }, + onError: (error) => + showErrorToast(t("records.life.auditC.legacyRemoveError"), { + cause: error, + }), + }); + return (

{t("records.life.auditC.intro")}

@@ -154,6 +183,41 @@ export function AuditCPanel({
) : null} + {legacy?.value ? ( +
+
+

+ {t("records.life.auditC.legacyLabel", { + value: t( + `records.profileFacts.values.ALCOHOL_PATTERN.${legacy.value}`, + ), + })} +

+

+ {t("records.life.auditC.legacyHint")} +

+
+ removeLegacy.mutate(legacy.id)} + pending={removeLegacy.isPending} + disabled={removeLegacy.isPending} + variant="ghost" + size="icon" + className="text-destructive min-h-11 sm:min-h-9" + icon={} + slot="audit-c-legacy-remove" + /> +
+ ) : null} + {data.offerDue ? (

{t("records.life.auditC.offerDue")} diff --git a/src/lib/ai/coach/__tests__/profile-ai-section-gating.test.ts b/src/lib/ai/coach/__tests__/profile-ai-section-gating.test.ts index 2a3b575c1..e9c7dabf4 100644 --- a/src/lib/ai/coach/__tests__/profile-ai-section-gating.test.ts +++ b/src/lib/ai/coach/__tests__/profile-ai-section-gating.test.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ allergyFindMany: vi.fn(), familyFindMany: vi.fn(), factFindMany: vi.fn(), - auditFindFirst: vi.fn(), + auditFindMany: vi.fn(), decrypt: vi.fn(), })); @@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({ allergy: { findMany: mocks.allergyFindMany }, familyHistoryEntry: { findMany: mocks.familyFindMany }, healthProfileFactRevision: { findMany: mocks.factFindMany }, - mentalHealthAssessment: { findFirst: mocks.auditFindFirst }, + mentalHealthAssessment: { findMany: mocks.auditFindMany }, }, })); @@ -46,13 +46,15 @@ beforeEach(() => { mocks.allergyFindMany.mockResolvedValue([]); mocks.familyFindMany.mockResolvedValue([]); mocks.factFindMany.mockResolvedValue([]); - mocks.auditFindFirst.mockResolvedValue(null); + mocks.auditFindMany.mockResolvedValue([]); mocks.decrypt.mockImplementation((bytes: Uint8Array) => { if (bytes[0] === 1) return secret; if (bytes[0] === 2) return "Asthma"; if (bytes[0] === 3) return "FORMER"; if (bytes[0] === 4) return "OCCASIONAL"; if (bytes[0] === 5) return "ROTATING"; + if (bytes[0] === 6) return JSON.stringify({ schema: 1, items: [2, 2, 1] }); + if (bytes[0] === 7) throw new Error("unknown key id"); return "unknown"; }); }); @@ -204,6 +206,11 @@ describe("profile section access in AI prompt paths", () => { if (bytes[0] === 21) { return JSON.stringify({ schema: 1, goals: ["sleep"] }); } + if (bytes[0] === 4) return "OCCASIONAL"; + if (bytes[0] === 6) { + return JSON.stringify({ schema: 1, items: [2, 2, 1] }); + } + if (bytes[0] === 7) throw new Error("unknown key id"); return "unknown"; }); }); @@ -235,11 +242,16 @@ describe("profile section access in AI prompt paths", () => { }); it("gives AUDIT-C as a sum with its screening verdict under the alcohol switch only", async () => { - mocks.auditFindFirst.mockResolvedValue({ - totalScore: 5, - takenAt: new Date("2026-10-01T09:00:00.000Z"), - locale: "de", - }); + mocks.auditFindMany.mockResolvedValue([ + { + id: "audit-1", + totalScore: 5, + takenAt: new Date("2026-10-01T09:00:00.000Z"), + locale: "de", + tz: null, + responsesEncrypted: new Uint8Array([6]), + }, + ]); mocks.userFindUnique.mockResolvedValue({ gender: "MALE", locale: "de" }); mockIncluded(["ALCOHOL_PATTERN"]); @@ -248,10 +260,59 @@ describe("profile section access in AI prompt paths", () => { "AUDIT-C (Screening, keine Diagnose): 5 von 12 am 2026-10-01, Screening-Grenzwert 5 erreicht", ); - mocks.auditFindFirst.mockClear(); + mocks.auditFindMany.mockClear(); mockIncluded(["GOALS"]); await getSelfContextTextForUser("user-1", "de"); - expect(mocks.auditFindFirst).not.toHaveBeenCalled(); + expect(mocks.auditFindMany).not.toHaveBeenCalled(); + }); + + it("drops the old alcohol pattern once an AUDIT-C entry exists", async () => { + mockIncluded(["ALCOHOL_PATTERN"]); + mocks.factFindMany.mockResolvedValue([ + { kind: "ALCOHOL_PATTERN", valueEncrypted: new Uint8Array([4]) }, + ]); + const alone = await getSelfContextTextForUser("user-1", "en"); + expect(alone).toContain("Alcohol pattern: Occasional"); + + mocks.auditFindMany.mockResolvedValue([ + { + id: "audit-1", + totalScore: 5, + takenAt: new Date("2026-10-01T09:00:00.000Z"), + locale: "en", + tz: null, + responsesEncrypted: new Uint8Array([6]), + }, + ]); + const text = await getSelfContextTextForUser("user-1", "en"); + expect(text).toContain("AUDIT-C"); + expect(text).not.toContain("Alcohol pattern"); + }); + + it("reads the newest entry whose answers open, as the read route does", async () => { + mocks.userFindUnique.mockResolvedValue({ gender: null, locale: "en" }); + mocks.auditFindMany.mockResolvedValue([ + { + id: "audit-sealed", + totalScore: 11, + takenAt: new Date("2026-10-05T09:00:00.000Z"), + locale: "en", + tz: null, + responsesEncrypted: new Uint8Array([7]), + }, + { + id: "audit-open", + totalScore: 5, + takenAt: new Date("2026-10-01T09:00:00.000Z"), + locale: "en", + tz: null, + responsesEncrypted: new Uint8Array([6]), + }, + ]); + mockIncluded(["ALCOHOL_PATTERN"]); + const text = await getSelfContextTextForUser("user-1", "en"); + expect(text).toContain("5 of 12"); + expect(text).not.toContain("11 of 12"); }); }); }); diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts index 4b5423721..6d251e4ad 100644 --- a/src/lib/ai/coach/about-me.ts +++ b/src/lib/ai/coach/about-me.ts @@ -47,6 +47,7 @@ import { } from "@/lib/profile/health-facts"; import { LIFE_KIND_AI_SECTION } from "@/lib/profile/life-derived"; import { auditCCutoff } from "@/lib/mental-health/audit-c"; +import { loadNewestReadableAuditC } from "@/lib/profile/audit-c-entries"; import { LIFE_FACT_KINDS, isLifeFactKind, @@ -636,20 +637,16 @@ export function composeLifeCircumstancesText( /** * The newest AUDIT-C entry for a prompt: only when the alcohol section is - * released, and without decrypting anything (the sum is stored in the clear, - * like the screeners'). The profile sex for the cut-off is read only when - * there is an entry to judge. + * released. "Newest" is the newest entry whose answers open, the same entry + * the read route shows as the result (`audit-c-entries.ts`). The profile sex + * for the cut-off is read only when there is an entry to judge. */ async function loadAuditCForPrompt( userId: string, included: ReadonlySet, ): Promise { if (!included.has("ALCOHOL_PATTERN")) return null; - const audit = await prisma.mentalHealthAssessment.findFirst({ - where: { userId, instrument: "AUDIT_C", deletedAt: null }, - orderBy: [{ takenAt: "desc" }, { id: "desc" }], - select: { totalScore: true, takenAt: true, locale: true, tz: true }, - }); + const audit = await loadNewestReadableAuditC(userId); if (!audit) return null; const user = await prisma.user.findUnique({ where: { id: userId }, @@ -839,6 +836,10 @@ export async function getSelfContextTextForUser( const factsText = composeStructuredHealthFactsText( factRows .filter((row) => !isLifeFactKind(row.kind)) + // The old one-word alcohol pattern gives way to an AUDIT-C entry: + // the questionnaire is the measured answer to the same question, and + // the two side by side would let a years-old guess argue with it. + .filter((row) => !(auditC && row.kind === "ALCOHOL_PATTERN")) .flatMap((row) => { const decrypted = decryptHealthProfileFactValue( row.kind as HealthProfileFactKind, diff --git a/src/lib/openapi/routes/life-circumstances.ts b/src/lib/openapi/routes/life-circumstances.ts index 706107f1b..d63cefa81 100644 --- a/src/lib/openapi/routes/life-circumstances.ts +++ b/src/lib/openapi/routes/life-circumstances.ts @@ -118,7 +118,11 @@ const lifeCircumstances = z screeningHints: z.array(screeningHint), }), auditC: z.object({ - latest: auditCResult.nullable(), + latest: auditCResult + .nullable() + .describe( + "The newest entry whose answers this host can open; an entry it cannot open is listed in `history` only.", + ), history: z .array( z.object({ @@ -128,7 +132,11 @@ const lifeCircumstances = z }), ) .describe("Newest first, at most 20."), - offerDue: z.boolean(), + offerDue: z + .boolean() + .describe( + "No readable entry, or the newest one whose answers open (the one in `latest`) is 365 days old or older.", + ), instrument: z.object({ items: z .array( @@ -267,7 +275,7 @@ export const lifeCircumstancesPaths: NonNullable = { tags: ["Anamnesis"], summary: "Record an AUDIT-C (v1.43)", description: - "Scores the three items on the server. A replay with the same `externalId` answers 200 with the stored result. 422 `anamnesis.auditC.invalid`; 429 `anamnesis.auditC.rateLimited` after 30 in an hour.", + "Scores the three items on the server. The entry is stored in the language of the wording the read route offers this account (`auditC.instrument.locale`), and the cut-off follows that wording; `locale`, when sent, must name that same wording. A replay with the same `externalId` answers 200 with the stored result. 422 `anamnesis.auditC.invalid` (also for another wording than the one offered, and for a `takenAt` more than five minutes ahead of the server clock); 429 `anamnesis.auditC.rateLimited` after 30 in an hour.", parameters: [idempotencyKeyParameter], requestBody: { required: true, diff --git a/src/lib/profile/audit-c-entries.ts b/src/lib/profile/audit-c-entries.ts new file mode 100644 index 000000000..ce2d50862 --- /dev/null +++ b/src/lib/profile/audit-c-entries.ts @@ -0,0 +1,93 @@ +/** + * v1.43 (#614) — which AUDIT-C entry is "the newest" for every reader. + * + * The answers are sealed; the sum is stored in the clear. An entry whose + * answers this host can no longer open (a lost or retired key) still has a + * readable sum, so a reader that skipped decryption used to take it as the + * newest result while the read route, which needs the answers, showed an + * older one beside it and offered the questionnaire again on a date neither + * of them showed. One rule now holds everywhere a result is read (the read + * route's `latest` and `offerDue`, the Coach block, the doctor report and + * FHIR): the newest entry whose answers open. The history keeps listing every + * entry by its sum, so an unreadable one can still be found and removed. + */ +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; + +/** How many entries back a reader looks for one it can open. */ +export const AUDIT_C_HISTORY_LIMIT = 20; + +/** + * The sealed answers of one AUDIT-C entry. `schema` versions the blob the same + * way the screener responses are versioned. + */ +export interface AuditCResponses { + schema: 1; + items: [number, number, number]; +} + +/** Open the sealed AUDIT-C answers; null when this host cannot read them. */ +export function openAuditCResponses( + payload: Uint8Array, +): AuditCResponses | null { + try { + const parsed = JSON.parse( + decryptFromBytes(payload), + ) as Partial; + const items = parsed.items; + if ( + Array.isArray(items) && + items.length === 3 && + items.every((item) => Number.isInteger(item) && item >= 0 && item <= 4) + ) { + return { schema: 1, items: items as [number, number, number] }; + } + getEvent()?.addWarning("audit-c responses shape drifted"); + } catch (error) { + getEvent()?.addWarning( + `audit-c responses decrypt failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return null; +} + +/** The first entry of `rows` (newest first) whose answers open. */ +export function newestReadableAuditC< + T extends { responsesEncrypted: Uint8Array }, +>(rows: readonly T[]): { row: T; items: [number, number, number] } | null { + for (const row of rows) { + const opened = openAuditCResponses(row.responsesEncrypted); + if (opened) return { row, items: opened.items }; + } + return null; +} + +/** The newest readable entry of a record, for the readers that need one. */ +export async function loadNewestReadableAuditC(userId: string): Promise<{ + id: string; + takenAt: Date; + totalScore: number; + locale: string; + tz: string | null; +} | null> { + const rows = await prisma.mentalHealthAssessment.findMany({ + where: { userId, instrument: "AUDIT_C", deletedAt: null }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], + take: AUDIT_C_HISTORY_LIMIT, + select: { + id: true, + takenAt: true, + totalScore: true, + locale: true, + tz: true, + responsesEncrypted: true, + }, + }); + const newest = newestReadableAuditC(rows); + if (!newest) return null; + const { responsesEncrypted: _sealed, ...entry } = newest.row; + return entry; +} diff --git a/src/lib/profile/life-circumstances.ts b/src/lib/profile/life-circumstances.ts index 932fc5455..7eea2ff8a 100644 --- a/src/lib/profile/life-circumstances.ts +++ b/src/lib/profile/life-circumstances.ts @@ -15,6 +15,13 @@ import { Prisma } from "@/generated/prisma/client"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { deriveAgeYears } from "@/lib/ai/coach/about-me"; +import { + AUDIT_C_HISTORY_LIMIT, + loadNewestReadableAuditC, + newestReadableAuditC, + openAuditCResponses, + type AuditCResponses, +} from "@/lib/profile/audit-c-entries"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import { @@ -63,22 +70,11 @@ import { } from "@/lib/validations/life-circumstances"; const MS_PER_DAY = 86_400_000; -/** History entries the read route lists, newest first. */ -const AUDIT_C_HISTORY_LIMIT = 20; /** AUDIT-C is offered again once the newest entry is this old. */ const AUDIT_C_OFFER_AFTER_DAYS = 365; /** A step count this recent means activity is measured, not only reported. */ const MEASURED_STEPS_DAYS = 30; -/** - * The sealed answers of one AUDIT-C entry. `schema` versions the blob the same - * way the screener responses are versioned. - */ -interface AuditCResponses { - schema: 1; - items: [number, number, number]; -} - export interface AuditCResult { id: string; takenAt: string; @@ -160,31 +156,6 @@ function latest(...dates: Array): Date | null { return best; } -/** Open the sealed AUDIT-C answers; null when this host cannot read them. */ -function openAuditCResponses(payload: Uint8Array): AuditCResponses | null { - try { - const parsed = JSON.parse( - decryptFromBytes(payload), - ) as Partial; - const items = parsed.items; - if ( - Array.isArray(items) && - items.length === 3 && - items.every((item) => Number.isInteger(item) && item >= 0 && item <= 4) - ) { - return { schema: 1, items: items as [number, number, number] }; - } - getEvent()?.addWarning("audit-c responses shape drifted"); - } catch (error) { - getEvent()?.addWarning( - `audit-c responses decrypt failed: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - return null; -} - /** One stored entry scored for a profile sex; the source line follows `labelLocale`. */ export function toAuditCResult( row: { id: string; takenAt: Date; totalScore: number; locale: string }, @@ -327,17 +298,16 @@ export async function buildLifeCircumstances( const factOf = (kind: string): T | null => (facts.find((fact) => fact.kind === kind)?.value as T | undefined) ?? null; - // AUDIT-C: the newest entry whose answers this host can open is the one - // shown in full; the history lists totals, which need no decryption. - let latestAuditC: AuditCResult | null = null; - for (const row of auditRows) { - const opened = openAuditCResponses(row.responsesEncrypted); - if (opened) { - latestAuditC = toAuditCResult(row, opened.items, sex, locale); - break; - } - } + // AUDIT-C: the newest entry whose answers this host can open is the + // result, and the offer is timed from it, as every other reader times it + // (`audit-c-entries.ts`). The history lists totals, which need no + // decryption, so an unreadable entry can still be removed. + const readable = newestReadableAuditC(auditRows); + const latestAuditC: AuditCResult | null = readable + ? toAuditCResult(readable.row, readable.items, sex, locale) + : null; const newestAuditC = auditRows[0]?.takenAt ?? null; + const newestReadableAt = readable?.row.takenAt ?? null; const sections = LIFE_SECTIONS.map((section) => { const sectionFacts = facts.filter((fact) => @@ -398,8 +368,8 @@ export async function buildLifeCircumstances( totalScore: row.totalScore, })), offerDue: - newestAuditC === null || - now.getTime() - newestAuditC.getTime() >= + newestReadableAt === null || + now.getTime() - newestReadableAt.getTime() >= AUDIT_C_OFFER_AFTER_DAYS * MS_PER_DAY, instrument: auditCInstrument(locale), }, @@ -646,11 +616,7 @@ export async function loadLifeClinicalSummary( }, select: { kind: true, valueEncrypted: true }, }), - prisma.mentalHealthAssessment.findFirst({ - where: { userId, instrument: "AUDIT_C", deletedAt: null }, - orderBy: [{ takenAt: "desc" }, { id: "desc" }], - select: { id: true, takenAt: true, totalScore: true, locale: true }, - }), + loadNewestReadableAuditC(userId), ]); const value = (kind: string): T | null => { const row = rows.find((candidate) => candidate.kind === kind); diff --git a/tests/integration/life-circumstances.test.ts b/tests/integration/life-circumstances.test.ts index fa5088705..46fe9c8de 100644 --- a/tests/integration/life-circumstances.test.ts +++ b/tests/integration/life-circumstances.test.ts @@ -396,6 +396,79 @@ describe("AUDIT-C", () => { }); }); + it("scores against the wording offered and refuses another one or a future date", async () => { + // An English account is shown the English wording; a German cut-off for + // it would be the stricter S3 table applied to questions never asked. + const man = await makeUser({ locale: "en", gender: "MALE" }); + await signIn(man.id); + const wrong = await postAuditC({ + items: [2, 1, 1], + locale: "de", + source: "IOS", + }); + expect(wrong.status).toBe(422); + const { result } = await data<{ + result: { locale: string; cutoff: { source: string } | null }; + }>(await postAuditC({ items: [2, 1, 1], source: "IOS" }), 201); + expect(result.locale).toBe("en"); + expect(result.cutoff?.source).toBe("BRADLEY_2007"); + + // French has no validated wording, so the English one is offered and + // stored, whatever the account language says. + const french = await makeUser({ locale: "fr", gender: "MALE" }); + await signIn(french.id); + const fr = await data<{ result: { locale: string } }>( + await postAuditC({ items: [0, 0, 1], locale: "fr", source: "WEB" }), + 201, + ); + expect(fr.result.locale).toBe("en"); + + const future = await postAuditC({ + items: [0, 0, 0], + source: "WEB", + takenAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + expect(future.status).toBe(422); + const skew = await postAuditC({ + items: [0, 0, 0], + source: "WEB", + takenAt: new Date(Date.now() + 60 * 1000).toISOString(), + }); + expect(skew.status).toBe(201); + }); + + it("reads latest and the offer from the newest entry whose answers open", async () => { + const user = await makeUser(); + await signIn(user.id); + const older = await data<{ result: { id: string } }>( + await postAuditC({ + items: [1, 0, 0], + source: "WEB", + takenAt: "2025-01-10T09:00:00.000Z", + }), + 201, + ); + // A newer entry this host cannot open (its key is gone). + const sealed = await getPrismaClient().mentalHealthAssessment.create({ + data: { + userId: user.id, + instrument: "AUDIT_C", + locale: "en", + responsesEncrypted: new Uint8Array([9, 9, 9, 9]), + totalScore: 11, + severityBand: "unbanded", + takenAt: new Date(), + source: "WEB", + }, + }); + const life = await readLife(); + expect(life.auditC.latest?.id).toBe(older.result.id); + // Timed from the result shown, not from the entry nobody can read. + expect(life.auditC.offerDue).toBe(true); + // Still listed, so it can be removed. + expect(life.auditC.history.map((h) => h.id)).toContain(sealed.id); + }); + it("soft-deletes an entry once, and only an AUDIT-C entry of the record", async () => { const user = await makeUser(); await signIn(user.id);