From 73bb13a982badbdf4bb9c3e503d5182431024ddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 01:24:56 +0200 Subject: [PATCH] feat(anamnesis): soft-delete AUDIT-C in its route, admit both AUDIT-C verbs at manage level, and settle the guards --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/pl.json | 4 +- prisma/schema.prisma | 8 +- .../destructive-control-guard.test.ts | 19 ++- src/__tests__/sharing-surface-guard.test.ts | 28 +++- .../success-affordance-guard.test.ts | 12 +- src/__tests__/v137-wave-zero-fixtures.test.ts | 2 +- src/app/api/anamnesis/audit-c/[id]/route.ts | 15 +- .../anamnesis/facts/__tests__/route.test.ts | 10 +- .../health-record/__tests__/route.test.ts | 2 + .../assessments/__tests__/route.test.ts | 7 +- .../anamnesis-about-me-relocation.test.tsx | 4 +- .../anamnesis-ai-inclusion-gate.test.tsx | 4 +- .../profile-ai-section-gating.test.ts | 3 + src/lib/ai/coach/about-me.ts | 143 ++++++++---------- src/lib/ai/coach/tools/environment-read.ts | 17 +++ .../__tests__/leaf-gating-sweep.test.ts | 6 + .../profile/__tests__/health-facts.test.ts | 1 + src/lib/profile/life-circumstances.ts | 100 ++++++------ src/lib/record-activity/activity-verb.ts | 4 + tests/fixtures/v137/sharing-matrix.ts | 16 ++ 26 files changed, 275 insertions(+), 154 deletions(-) diff --git a/messages/de.json b/messages/de.json index d718f2aed..af5a377af 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12429,7 +12429,9 @@ "lifeEventUpdate": "{name} hat ein Lebensereignis geändert", "lifeEventDelete": "{name} hat ein Lebensereignis entfernt", "biomarkerMerge": "{name} hat Biomarker zusammengeführt", - "medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert" + "medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert", + "auditCCreate": "{name} hat den AUDIT-C-Fragebogen ausgefüllt", + "auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht" }, "section": { "measurements": { diff --git a/messages/en.json b/messages/en.json index dbb8fa089..22382231d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12429,7 +12429,9 @@ "lifeEventUpdate": "{name} changed a life event", "lifeEventDelete": "{name} removed a life event", "biomarkerMerge": "{name} merged biomarkers", - "medicationIngredientsUpdate": "{name} changed what a supplement contains" + "medicationIngredientsUpdate": "{name} changed what a supplement contains", + "auditCCreate": "{name} answered the AUDIT-C questions", + "auditCDelete": "{name} removed an AUDIT-C entry" }, "section": { "measurements": { diff --git a/messages/es.json b/messages/es.json index dec9728fc..eaf00fc92 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12429,7 +12429,9 @@ "lifeEventUpdate": "{name} cambió un acontecimiento vital", "lifeEventDelete": "{name} eliminó un acontecimiento vital", "biomarkerMerge": "{name} combinó biomarcadores", - "medicationIngredientsUpdate": "{name} cambió la composición de un suplemento" + "medicationIngredientsUpdate": "{name} cambió la composición de un suplemento", + "auditCCreate": "{name} respondió el cuestionario AUDIT-C", + "auditCDelete": "{name} eliminó una entrada del AUDIT-C" }, "section": { "measurements": { diff --git a/messages/fr.json b/messages/fr.json index 7914bc9aa..f47a29511 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12429,7 +12429,9 @@ "lifeEventUpdate": "{name} a modifié un événement de vie", "lifeEventDelete": "{name} a supprimé un événement de vie", "biomarkerMerge": "{name} a fusionné des biomarqueurs", - "medicationIngredientsUpdate": "{name} a modifié la composition d’un complément" + "medicationIngredientsUpdate": "{name} a modifié la composition d’un complément", + "auditCCreate": "{name} a répondu au questionnaire AUDIT-C", + "auditCDelete": "{name} a supprimé une entrée AUDIT-C" }, "section": { "measurements": { diff --git a/messages/it.json b/messages/it.json index 5a0d6a2d7..314907aad 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12429,7 +12429,9 @@ "lifeEventUpdate": "{name} ha modificato un evento della vita", "lifeEventDelete": "{name} ha rimosso un evento della vita", "biomarkerMerge": "{name} ha unito dei biomarcatori", - "medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore" + "medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore", + "auditCCreate": "{name} ha compilato il questionario AUDIT-C", + "auditCDelete": "{name} ha eliminato una voce AUDIT-C" }, "section": { "measurements": { diff --git a/messages/ko.json b/messages/ko.json index adaa582cd..2fc9dbe35 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12429,7 +12429,9 @@ "lifeEventUpdate": "{name} 님이 삶의 사건을 수정했어요", "lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요", "biomarkerMerge": "{name} 님이 바이오마커를 합쳤어요", - "medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요" + "medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요", + "auditCCreate": "{name} 님이 AUDIT-C 문항에 답했어요", + "auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요" }, "section": { "measurements": { diff --git a/messages/pl.json b/messages/pl.json index 6440e85b0..66ec208c3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12429,7 +12429,9 @@ "lifeEventUpdate": "{name} zmienił wydarzenie życiowe", "lifeEventDelete": "{name} usunął wydarzenie życiowe", "biomarkerMerge": "{name} scalił biomarkery", - "medicationIngredientsUpdate": "{name} zmienił skład suplementu" + "medicationIngredientsUpdate": "{name} zmienił skład suplementu", + "auditCCreate": "{name} wypełnił kwestionariusz AUDIT-C", + "auditCDelete": "{name} usunął wpis AUDIT-C" }, "section": { "measurements": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b58af23c3..d482c6cca 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7034,7 +7034,7 @@ model UserHealthProfile { emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted") emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted") emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted") - /// @pending-consumer(#v143-B6) v1.43 (#614) — "still true" confirmations of the life + /// v1.43 (#614) — "still true" confirmations of the life /// circumstance sections: `{ "
": "" }`. Written by /// `POST /api/anamnesis/life-circumstances/confirm`, read as /// `lastConfirmedAt` / `reviewDue` by the life-circumstances read route. @@ -7130,7 +7130,11 @@ enum HealthProfileFactKind { /// v1.43 (#614) — life-circumstance facts. The value is a sealed JSON object /// with `schema: 1`, one Zod schema per kind. `GET /api/anamnesis/facts` /// never returns these (a closed decoder in older app builds); they are read - /// through `GET /api/anamnesis/life-circumstances`. + /// through `GET /api/anamnesis/life-circumstances`. Readers: the coach + /// self-context (all six, per released AI section), the doctor report and + /// FHIR (tobacco, activity, work), the chronotype (free weekdays), the + /// environment read (work). `HABITS` and `HEALTH_GOALS` are coach-only by + /// design: diet and goals are context for a conversation, not findings. TOBACCO_DETAIL ACTIVITY_HABIT WORK_CONTEXT diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index d1f96bf77..a40c6ce5a 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -403,8 +403,23 @@ const REGISTRY: DestructiveEntry[] = [ confirm: ["DeleteButton"], }, { - file: "components/records/health-profile-facts-manager.tsx", - destroys: "the current value of one effective-dated health-profile fact", + file: "components/records/life-circumstances-manager.tsx", + destroys: + "the current answer of one life-circumstance fact, when a section is saved with it set back to no answer", + recovery: "tombstoned-no-restore", + confirm: [], + unconfirmed: [ + { + control: + "Save section with a field set back to no answer — life-circumstances-manager.tsx", + reason: + "Setting a question back to no answer and pressing Save is the deliberate act itself, made field by field inside a form the person is editing, with the old answer visible until they change it. The delete closes the answer's interval rather than erasing it, so the dated history keeps it, and answering again takes one select. A second dialog on top of an explicit form edit would make correcting an answer dearer than giving it.", + }, + ], + }, + { + file: "components/records/audit-c-panel.tsx", + destroys: "one AUDIT-C entry from the alcohol history", recovery: "tombstoned-no-restore", confirm: ["ConfirmButton"], }, diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 36a5fa2a1..5f851c312 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -893,7 +893,15 @@ const DELEGABLE_ROUTES: Record = { }, "app/api/anamnesis/life-circumstances/route.ts": { domain: "profile", - why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches, confirmations and AUDIT-C are written through their own routes, none of them delegable.", + why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches and confirmations are written through their own owner-only routes; AUDIT-C through the manage-level pair below.", + }, + "app/api/anamnesis/audit-c/route.ts": { + domain: "profile", + why: "Recording an AUDIT-C (v1.43) into the record, the alcohol section of the life circumstances. Reached only through its MANAGE arm, the level allergies and family history are written at; the entry is scored on the server against the record's profile.", + }, + "app/api/anamnesis/audit-c/[id]/route.ts": { + domain: "profile", + why: "Removing one AUDIT-C entry of the record, scoped `userId: user.id` and `instrument: AUDIT_C` in the guarded update, so a foreign id or a screener id is a 404. Reached only through its MANAGE arm.", }, "app/api/profile/summary/route.ts": { domain: "profile", @@ -1630,6 +1638,16 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "Recording an allergy. Reachable through the record's settings on a managed profile, dormant on an invited one, and argued as such.", }, + "app/api/anamnesis/audit-c/route.ts": { + domain: "profile", + conditions: ["C1"], + why: "Recording an AUDIT-C (v1.43) in the record a manager runs, like an allergy. C1: the 30-an-hour bucket keys on the actor. Arithmetic on three answers, no generation; the trail files the entry id and instrument, never the answers.", + }, + "app/api/anamnesis/audit-c/[id]/route.ts": { + domain: "profile", + conditions: [], + why: "Removing an AUDIT-C entry is a soft delete (`deletedAt`), so the row stays to reconstruct and the delete needs no C3; it is the counterpart of the create beside it.", + }, "app/api/allergies/[id]/route.ts": { domain: "profile", conditions: ["C3", "C4"], @@ -2142,8 +2160,12 @@ const ACTOR_ROUTES: Record = { * v1.43 -- a supplement's composition joins the record list and the manage * literal: its PUT replaces the composition every taken dose counts with. * 260 -> 262. + * + * v1.43 -- AUDIT-C's create and soft delete join the record list and the + * manage literal under `profile`, the level allergies are written at. + * 262 -> 266. */ -const FROZEN_ENTRY_COUNT = 262; +const FROZEN_ENTRY_COUNT = 266; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2904,7 +2926,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(90); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(92); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 72619b731..ab7455c66 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -498,7 +498,17 @@ const RESULT_SURFACES: ReadonlyArray<{ forbidsMarkers: true, }, { - path: "src/components/records/health-profile-facts-manager.tsx", + path: "src/components/records/life-circumstances-manager.tsx", + through: /toastWrittenOutcome/, + forbidsMarkers: true, + }, + { + path: "src/components/records/audit-c-panel.tsx", + through: /toastWrittenOutcome/, + forbidsMarkers: true, + }, + { + path: "src/components/records/free-text-take-over.tsx", through: /toastWrittenOutcome/, forbidsMarkers: true, }, diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index dd85c335d..50ea4b712 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(90); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(92); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/anamnesis/audit-c/[id]/route.ts b/src/app/api/anamnesis/audit-c/[id]/route.ts index fb3432d2c..7795a6c70 100644 --- a/src/app/api/anamnesis/audit-c/[id]/route.ts +++ b/src/app/api/anamnesis/audit-c/[id]/route.ts @@ -10,8 +10,9 @@ import { NextRequest } from "next/server"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; -import { removeAuditC } from "@/lib/profile/life-circumstances"; +import { invalidateHealthProfileFactConsumers } from "@/lib/profile/health-facts"; type RouteParams = { params: Promise<{ id: string }> }; @@ -20,12 +21,20 @@ export const DELETE = apiHandler( const { user } = await requireRecordAuth("manage", "profile"); const { id } = await params; - const removed = await removeAuditC(user.id, id); - if (!removed) { + // Soft delete, scoped to a live AUDIT-C entry of the record: a foreign + // id, a screener's id or an entry already removed is the same 404. + const now = new Date(); + const result = await prisma.mentalHealthAssessment.updateMany({ + where: { id, userId: user.id, instrument: "AUDIT_C", deletedAt: null }, + data: { deletedAt: now, syncVersion: { increment: 1 } }, + }); + if (result.count !== 1) { return apiError("AUDIT-C entry not found", 404, { errorCode: "anamnesis.auditC.notFound", }); } + await invalidateHealthProfileFactConsumers(user.id, prisma); + const removed = { id, removedAt: now.toISOString() }; await auditLog("anamnesis.audit_c.delete", { userId: user.id, diff --git a/src/app/api/anamnesis/facts/__tests__/route.test.ts b/src/app/api/anamnesis/facts/__tests__/route.test.ts index f9fa02399..6b24c487c 100644 --- a/src/app/api/anamnesis/facts/__tests__/route.test.ts +++ b/src/app/api/anamnesis/facts/__tests__/route.test.ts @@ -106,6 +106,8 @@ describe("anamnesis facts API", () => { "user-1", "SMOKING_STATUS", "FORMER", + expect.any(Date), + undefined, ); expect(mocks.audit).toHaveBeenCalledOnce(); }); @@ -135,7 +137,13 @@ describe("anamnesis facts API", () => { where: expect.objectContaining({ id: "fact-1", userId: "user-1" }), }), ); - expect(mocks.correct).toHaveBeenCalledWith("user-1", "fact-1", "NEVER"); + expect(mocks.correct).toHaveBeenCalledWith( + "user-1", + "fact-1", + "NEVER", + expect.any(Date), + undefined, + ); expect(mocks.audit).toHaveBeenCalledOnce(); }); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 81ee918fd..a141cccbf 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -36,6 +36,8 @@ vi.mock("@/lib/db", () => ({ familyHistoryEntry: { findMany: vi.fn() }, userHealthProfile: { findUnique: vi.fn() }, healthProfileFactRevision: { findMany: vi.fn() }, + // v1.43 — the anamnesis leaf also carries the newest AUDIT-C entry. + mentalHealthAssessment: { findFirst: vi.fn(async () => null) }, user: { findUnique: vi.fn(), update: vi.fn() }, // The whole-record gate reads the session's own stamps: a sign-in a // moment ago is the proof, on an account without a second factor. diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts index b6311acfc..715d25a99 100644 --- a/src/app/api/mental-health/assessments/__tests__/route.test.ts +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -250,7 +250,12 @@ describe("POST /api/mental-health/assessments", () => { expect(body.data.assessment.id).toBe("mha_dup"); expect(prisma.mentalHealthAssessment.findFirst).toHaveBeenCalledWith( expect.objectContaining({ - where: { userId: "user-1", externalId: "outbox-7f3c" }, + // Screeners only: an AUDIT-C entry (v1.43) is never a dedup match. + where: { + userId: "user-1", + externalId: "outbox-7f3c", + instrument: { in: ["PHQ9", "GAD7", "WHO5", "SCI"] }, + }, }), ); expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled(); diff --git a/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx b/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx index f6355390b..40ae5dc22 100644 --- a/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx +++ b/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx @@ -35,8 +35,8 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({ vi.mock("@/components/records/conditions-manager", () => ({ ConditionsManager: () => null, })); -vi.mock("@/components/records/health-profile-facts-manager", () => ({ - HealthProfileFactsManager: () => null, +vi.mock("@/components/records/life-circumstances-manager", () => ({ + LifeCircumstancesManager: () => null, })); vi.mock("@/components/records/allergy-manager", () => ({ AllergyManager: ({ diff --git a/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx b/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx index 126b4197b..83b4a1e14 100644 --- a/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx +++ b/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx @@ -18,8 +18,8 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({ vi.mock("@/components/records/conditions-manager", () => ({ ConditionsManager: () =>
, })); -vi.mock("@/components/records/health-profile-facts-manager", () => ({ - HealthProfileFactsManager: () => null, +vi.mock("@/components/records/life-circumstances-manager", () => ({ + LifeCircumstancesManager: () => null, })); vi.mock("@/components/records/allergy-manager", () => ({ AllergyManager: () => null, 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 2c74aee7f..e78c906e3 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,6 +6,7 @@ const mocks = vi.hoisted(() => ({ allergyFindMany: vi.fn(), familyFindMany: vi.fn(), factFindMany: vi.fn(), + auditFindFirst: vi.fn(), decrypt: vi.fn(), })); @@ -16,6 +17,7 @@ vi.mock("@/lib/db", () => ({ allergy: { findMany: mocks.allergyFindMany }, familyHistoryEntry: { findMany: mocks.familyFindMany }, healthProfileFactRevision: { findMany: mocks.factFindMany }, + mentalHealthAssessment: { findFirst: mocks.auditFindFirst }, }, })); @@ -44,6 +46,7 @@ beforeEach(() => { mocks.allergyFindMany.mockResolvedValue([]); mocks.familyFindMany.mockResolvedValue([]); mocks.factFindMany.mockResolvedValue([]); + mocks.auditFindFirst.mockResolvedValue(null); mocks.decrypt.mockImplementation((bytes: Uint8Array) => { if (bytes[0] === 1) return secret; if (bytes[0] === 2) return "Asthma"; diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts index 1e53342d7..bb6f0395b 100644 --- a/src/lib/ai/coach/about-me.ts +++ b/src/lib/ai/coach/about-me.ts @@ -48,6 +48,7 @@ import { LIFE_KIND_AI_SECTION } from "@/lib/profile/life-derived"; import { auditCCutoff } from "@/lib/mental-health/audit-c"; import { LIFE_FACT_KINDS, + isLifeFactKind, type ActivityHabitValue, type FreeWeekdaysValue, type HabitsValue, @@ -631,65 +632,36 @@ export function composeLifeCircumstancesText( } /** - * Load the life-circumstance context a prompt may see. Only the kinds whose - * AI section is switched on are read, so a section left out is never - * decrypted for a prompt; AUDIT-C rides the alcohol switch and needs no - * decryption (the sum is stored in the clear, like the screeners'). + * 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. */ -async function loadLifeContextForPrompt( +async function loadAuditCForPrompt( userId: string, included: ReadonlySet, -): Promise<{ facts: LifeFactForPrompt[]; auditC: AuditCForPrompt | null }> { - const kinds = LIFE_FACT_KINDS.filter((kind) => - included.has(LIFE_KIND_AI_SECTION[kind]), - ); - const [rows, audit, user] = await Promise.all([ - kinds.length > 0 - ? prisma.healthProfileFactRevision.findMany({ - where: { - userId, - kind: { in: kinds }, - validUntil: null, - supersededByRevisionId: null, - }, - orderBy: { kind: "asc" }, - select: { kind: true, valueEncrypted: true, validFrom: true }, - }) - : Promise.resolve([]), - included.has("ALCOHOL_PATTERN") - ? prisma.mentalHealthAssessment.findFirst({ - where: { userId, instrument: "AUDIT_C", deletedAt: null }, - orderBy: [{ takenAt: "desc" }, { id: "desc" }], - select: { totalScore: true, takenAt: true, locale: true }, - }) - : Promise.resolve(null), - included.has("ALCOHOL_PATTERN") - ? prisma.user.findUnique({ - where: { id: userId }, - select: { gender: true, locale: true }, - }) - : Promise.resolve(null), - ]); - const facts = rows.flatMap((row) => { - const kind = row.kind as LifeFactKind; - const opened = decryptLifeFactValue(kind, row.valueEncrypted); - return opened.value - ? [{ kind, value: opened.value, validFrom: row.validFrom }] - : []; +): 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 }, }); - const cutoff = audit - ? auditCCutoff(audit.locale, user?.gender ?? null, user?.locale ?? "en") - : null; + if (!audit) return null; + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { gender: true, locale: true }, + }); + const cutoff = auditCCutoff( + audit.locale, + user?.gender ?? null, + user?.locale ?? "en", + ); return { - facts, - auditC: audit - ? { - totalScore: audit.totalScore, - takenAt: audit.takenAt, - cutoff: cutoff?.value ?? null, - aboveCutoff: cutoff ? audit.totalScore >= cutoff.value : null, - } - : null, + totalScore: audit.totalScore, + takenAt: audit.takenAt, + cutoff: cutoff?.value ?? null, + aboveCutoff: cutoff ? audit.totalScore >= cutoff.value : null, }; } @@ -785,11 +757,19 @@ export async function getSelfContextTextForUser( const includedStored: ReadonlySet = new Set( controlRow?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS, ); - const includedFactKinds = ( - ["SMOKING_STATUS", "ALCOHOL_PATTERN", "SHIFT_SCHEDULE"] as const - ).filter((kind) => included.has(kind)); + // One read for every fact kind a released section covers: the three + // closed kinds and the life-circumstance kinds (v1.43). A kind whose + // section is not released is never read, so it is never decrypted. + const includedFactKinds = [ + ...(["SMOKING_STATUS", "ALCOHOL_PATTERN", "SHIFT_SCHEDULE"] as const), + ...LIFE_FACT_KINDS, + ].filter((kind) => + includedStored.has( + isLifeFactKind(kind) ? LIFE_KIND_AI_SECTION[kind] : kind, + ), + ); - const [ctx, user, allergyRows, familyRows, factRows, life] = + const [ctx, user, allergyRows, familyRows, factRows, auditC] = await Promise.all([ getSelfContextForUser(userId, prisma, included), included.has("ABOUT_ME") @@ -827,10 +807,10 @@ export async function getSelfContextTextForUser( supersededByRevisionId: null, }, orderBy: { kind: "asc" }, - select: { kind: true, valueEncrypted: true }, + select: { kind: true, valueEncrypted: true, validFrom: true }, }) : Promise.resolve([]), - loadLifeContextForPrompt(userId, includedStored), + loadAuditCForPrompt(userId, includedStored), ]); const selfText = composeSelfContextText( @@ -853,27 +833,32 @@ export async function getSelfContextTextForUser( locale, ); const factsText = composeStructuredHealthFactsText( - factRows.flatMap((row) => { - const decrypted = decryptHealthProfileFactValue( - row.kind as HealthProfileFactKind, - row.valueEncrypted, - ); - return decrypted.value - ? [ - { - kind: row.kind as HealthProfileFactKind, - value: decrypted.value, - }, - ] - : []; - }), - locale, - ); - const lifeText = composeLifeCircumstancesText( - life.facts, - life.auditC, + factRows + .filter((row) => !isLifeFactKind(row.kind)) + .flatMap((row) => { + const decrypted = decryptHealthProfileFactValue( + row.kind as HealthProfileFactKind, + row.valueEncrypted, + ); + return decrypted.value + ? [ + { + kind: row.kind as HealthProfileFactKind, + value: decrypted.value, + }, + ] + : []; + }), locale, ); + const lifeFacts = factRows.flatMap((row) => { + if (!isLifeFactKind(row.kind)) return []; + const opened = decryptLifeFactValue(row.kind, row.valueEncrypted); + return opened.value + ? [{ kind: row.kind, value: opened.value, validFrom: row.validFrom }] + : []; + }); + const lifeText = composeLifeCircumstancesText(lifeFacts, auditC, locale); if (!selfText && !recordsText && !factsText && !lifeText) return null; return [selfText, recordsText, factsText, lifeText] .filter(Boolean) diff --git a/src/lib/ai/coach/tools/environment-read.ts b/src/lib/ai/coach/tools/environment-read.ts index 751b6e2f5..6aad4559c 100644 --- a/src/lib/ai/coach/tools/environment-read.ts +++ b/src/lib/ai/coach/tools/environment-read.ts @@ -40,6 +40,7 @@ import { import type { CoachScopeWindow } from "@/lib/ai/coach/types"; import { shiftDateKey } from "@/lib/tz/format"; import { decryptLifeFactValue } from "@/lib/profile/health-facts"; +import { getEvent } from "@/lib/logging/context"; import type { WorkContextValue } from "@/lib/validations/life-circumstances"; /** Days listed one by one; the summary covers the whole window. */ @@ -173,6 +174,22 @@ const RELEVANT_EXPOSURES = ["heat", "dust", "fumes_solvents"] as const; */ async function loadWorkContextForEnvironment( userId: string, +): Promise { + try { + return await readWorkContext(userId); + } catch (error) { + // Context, not data: a failed read leaves the note out, never the days. + getEvent()?.addWarning( + `environment work context read failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return null; + } +} + +async function readWorkContext( + userId: string, ): Promise { const profile = await prisma.userHealthProfile.findUnique({ where: { userId }, diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index 61d5abe4f..9ce9fc22e 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -54,6 +54,10 @@ const { calls, rows, prismaMock } = vi.hoisted(() => { calls.push(name); return Promise.resolve(rows[`${name}Unique`]?.[0] ?? null); }, + findFirst: () => { + calls.push(name); + return Promise.resolve(rows[`${name}First`]?.[0] ?? null); + }, }); return { calls, @@ -463,6 +467,8 @@ describe("zero read for unchosen leaves", () => { ["ANAMNESIS", "userHealthProfile"], ["EMERGENCY", "userHealthProfile"], ["ANAMNESIS", "healthProfileFactRevision"], + // v1.43 — the newest AUDIT-C entry rides the anamnesis leaf. + ["ANAMNESIS", "mentalHealthAssessment"], ]; // ANAMNESIS and EMERGENCY both read `userHealthProfile` (and VISITS and diff --git a/src/lib/profile/__tests__/health-facts.test.ts b/src/lib/profile/__tests__/health-facts.test.ts index 3cca03757..336190530 100644 --- a/src/lib/profile/__tests__/health-facts.test.ts +++ b/src/lib/profile/__tests__/health-facts.test.ts @@ -28,6 +28,7 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ })); vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserInsights: mocks.invalidateInsights, + invalidateUserDashboardSnapshot: vi.fn(), })); vi.mock("@/lib/logging/context", () => ({ getEvent: vi.fn(() => null) })); diff --git a/src/lib/profile/life-circumstances.ts b/src/lib/profile/life-circumstances.ts index 3a039e657..932fc5455 100644 --- a/src/lib/profile/life-circumstances.ts +++ b/src/lib/profile/life-circumstances.ts @@ -28,6 +28,7 @@ import { } from "@/lib/mental-health/audit-c"; import { isModuleEnabled } from "@/lib/modules/gate"; import { + decryptHealthProfileFactValue, decryptLifeFactValue, invalidateHealthProfileFactConsumers, toLifeFactRevisionDto, @@ -55,6 +56,7 @@ import { type ActivityHabitValue, type FreeWeekdaysValue, type LifeAiSection, + type LifeFactKind, type LifeSectionKey, type TobaccoDetailValue, type WorkContextValue, @@ -203,28 +205,42 @@ export function toAuditCResult( }; } -/** The dated free-weekday answers of a record, oldest first, for the night-by-night day type. */ +/** + * The dated free-weekday answers of a record, oldest first, for the + * night-by-night day type. Fail-soft: the answers refine a chronotype the + * weekend default already produces, so a failed read falls back to that + * default with a warning instead of taking the sleep surfaces down with it. + */ export async function loadFreeWeekdaySpans( userId: string, ): Promise { - const rows = await prisma.healthProfileFactRevision.findMany({ - where: { userId, kind: "FREE_WEEKDAYS" }, - orderBy: { validFrom: "asc" }, - select: { validFrom: true, validUntil: true, valueEncrypted: true }, - }); - return rows.flatMap((row) => { - const opened = decryptLifeFactValue("FREE_WEEKDAYS", row.valueEncrypted); - const value = opened.value as FreeWeekdaysValue | null; - return value - ? [ - { - validFrom: row.validFrom, - validUntil: row.validUntil, - days: value.days, - }, - ] - : []; - }); + try { + const rows = await prisma.healthProfileFactRevision.findMany({ + where: { userId, kind: "FREE_WEEKDAYS" }, + orderBy: { validFrom: "asc" }, + select: { validFrom: true, validUntil: true, valueEncrypted: true }, + }); + return rows.flatMap((row) => { + const opened = decryptLifeFactValue("FREE_WEEKDAYS", row.valueEncrypted); + const value = opened.value as FreeWeekdaysValue | null; + return value + ? [ + { + validFrom: row.validFrom, + validUntil: row.validUntil, + days: value.days, + }, + ] + : []; + }); + } catch (error) { + getEvent()?.addWarning( + `free weekdays read failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return []; + } } /** Assemble the whole read model for one record. */ @@ -480,17 +496,17 @@ export async function confirmLifeSection( where: { userId }, select: { lifeConfirmedJson: true }, }); - const next: Record = {}; + const confirmedAt: Record = {}; for (const [key, at] of confirmations(row?.lifeConfirmedJson)) { if ((LIFE_SECTION_KEYS as readonly string[]).includes(key)) { - next[key] = at.toISOString(); + confirmedAt[key] = at.toISOString(); } } - next[section] = stamp; + confirmedAt[section] = stamp; await tx.userHealthProfile.upsert({ where: { userId }, - create: { userId, lifeConfirmedJson: next }, - update: { lifeConfirmedJson: next }, + create: { userId, lifeConfirmedJson: confirmedAt }, + update: { lifeConfirmedJson: confirmedAt }, }); }); return stamp; @@ -603,21 +619,6 @@ export async function createAuditC( } } -/** Soft-delete one entry of the record; null when there is no live one with that id. */ -export async function removeAuditC( - userId: string, - id: string, - now: Date = new Date(), -): Promise<{ id: string; removedAt: string } | null> { - const removed = await prisma.mentalHealthAssessment.updateMany({ - where: { id, userId, instrument: "AUDIT_C", deletedAt: null }, - data: { deletedAt: now, syncVersion: { increment: 1 } }, - }); - if (removed.count !== 1) return null; - await invalidateHealthProfileFactConsumers(userId, prisma); - return { id, removedAt: now.toISOString() }; -} - // ── Clinical summary (doctor report, FHIR) ─────────────────────────────────── export async function loadLifeClinicalSummary( @@ -643,14 +644,7 @@ export async function loadLifeClinicalSummary( validUntil: null, supersededByRevisionId: null, }, - select: { - id: true, - kind: true, - valueEncrypted: true, - validFrom: true, - provenance: true, - createdAt: true, - }, + select: { kind: true, valueEncrypted: true }, }), prisma.mentalHealthAssessment.findFirst({ where: { userId, instrument: "AUDIT_C", deletedAt: null }, @@ -658,9 +652,15 @@ export async function loadLifeClinicalSummary( select: { id: true, takenAt: true, totalScore: true, locale: true }, }), ]); - const facts = rows.map(toLifeFactRevisionDto); - const value = (kind: string): T | null => - (facts.find((fact) => fact.kind === kind)?.value as T | undefined) ?? null; + const value = (kind: string): T | null => { + const row = rows.find((candidate) => candidate.kind === kind); + if (!row) return null; + const opened = + kind === "SMOKING_STATUS" + ? decryptHealthProfileFactValue("SMOKING_STATUS", row.valueEncrypted) + : decryptLifeFactValue(kind as LifeFactKind, row.valueEncrypted); + return (opened.value as T | null) ?? null; + }; const packYears = computePackYears( value("SMOKING_STATUS"), value("TOBACCO_DETAIL"), diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index 0e146170a..1b37e5254 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -70,6 +70,10 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.allergyCreate", { name }); case "family-history.create": return t("recordSharing.activityVerb.familyHistoryCreate", { name }); + case "anamnesis.audit_c.create": + return t("recordSharing.activityVerb.auditCCreate", { name }); + case "anamnesis.audit_c.delete": + return t("recordSharing.activityVerb.auditCDelete", { name }); case "illness.episode.create": return t("recordSharing.activityVerb.illnessEpisodeCreate", { name }); case "symptoms.event.create": diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 09bfd395d..f1e55a2d1 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -570,6 +570,22 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "delete", ), + handler( + "app/api/anamnesis/audit-c/route.ts", + "/api/anamnesis/audit-c", + "POST", + "profile", + "manage", + "create", + ), + handler( + "app/api/anamnesis/audit-c/[id]/route.ts", + "/api/anamnesis/audit-c/[id]", + "DELETE", + "profile", + "manage", + "delete", + ), handler( "app/api/medications/[id]/ingredients/route.ts", "/api/medications/[id]/ingredients",