From f2be772eaf5e828f5d963c90693189fc331ccf9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 00:54:38 +0200 Subject: [PATCH] feat(anamnesis): serve the life circumstances, record AUDIT-C, and keep both off the closed legacy lists --- src/__tests__/contract-stub-inventory.test.ts | 11 +- src/app/api/anamnesis/audit-c/[id]/route.ts | 43 +- src/app/api/anamnesis/audit-c/route.ts | 53 +- src/app/api/anamnesis/facts/[id]/route.ts | 57 +- src/app/api/anamnesis/facts/route.ts | 40 +- .../life-circumstances/ai-sections/route.ts | 44 +- .../life-circumstances/confirm/route.ts | 21 +- .../api/anamnesis/life-circumstances/route.ts | 19 +- src/app/api/coach/about-me/route.ts | 43 +- .../mental-health/assessments/[id]/route.ts | 8 +- .../api/mental-health/assessments/route.ts | 17 +- src/lib/day/day-index.ts | 3 + src/lib/day/records.ts | 3 + src/lib/export/profile-backup.ts | 29 +- src/lib/mental-health/audit-c.ts | 161 +++++ src/lib/mental-health/instruments.ts | 204 ++++++ src/lib/modules/domain-data.ts | 8 +- src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/coach.ts | 25 +- src/lib/openapi/routes/life-circumstances.ts | 35 +- src/lib/profile/country-label.ts | 26 + src/lib/profile/health-facts.ts | 266 ++++++-- src/lib/profile/life-circumstances.ts | 617 ++++++++++++++++++ src/lib/profile/life-derived.ts | 378 +++++++++++ .../__tests__/health-profile-facts.test.ts | 2 +- src/lib/validations/backup.ts | 16 +- src/lib/validations/health-profile-facts.ts | 55 +- src/lib/validations/life-circumstances.ts | 166 +++++ 28 files changed, 2131 insertions(+), 220 deletions(-) create mode 100644 src/lib/mental-health/audit-c.ts create mode 100644 src/lib/profile/country-label.ts create mode 100644 src/lib/profile/life-circumstances.ts create mode 100644 src/lib/profile/life-derived.ts diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts index 3c6b7c5b1..ad17f21c9 100644 --- a/src/__tests__/contract-stub-inventory.test.ts +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -28,15 +28,6 @@ const CONTRACT_STUBS: Readonly> = { "admin.pulse_day_mean.get", "admin.pulse_day_mean.post", ], - "anamnesis/audit-c/[id]/route.ts": ["anamnesis.audit_c.delete"], - "anamnesis/audit-c/route.ts": ["anamnesis.audit_c.create"], - "anamnesis/life-circumstances/ai-sections/route.ts": [ - "anamnesis.life_circumstances.ai_sections.patch", - ], - "anamnesis/life-circumstances/confirm/route.ts": [ - "anamnesis.life_circumstances.confirm", - ], - "anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"], "dawarich/connect/route.ts": ["dawarich.connect"], "dawarich/disconnect/route.ts": ["dawarich.disconnect"], "dawarich/places/route.ts": ["dawarich.places.get"], @@ -53,7 +44,7 @@ const CONTRACT_STUBS: Readonly> = { }; /** The size of the register when it was written; see the header. */ -const REGISTER_SIZE = 15; +const REGISTER_SIZE = 10; function routeFiles(): string[] { return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] }) diff --git a/src/app/api/anamnesis/audit-c/[id]/route.ts b/src/app/api/anamnesis/audit-c/[id]/route.ts index 1315b01ee..fb3432d2c 100644 --- a/src/app/api/anamnesis/audit-c/[id]/route.ts +++ b/src/app/api/anamnesis/audit-c/[id]/route.ts @@ -1,21 +1,44 @@ /** * `DELETE /api/anamnesis/audit-c/{id}` — remove an AUDIT-C entry (#614). - * Soft delete; answers `{ id, removedAt }`, 404 `anamnesis.auditC.notFound`. - * - * Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C - * lands the stub resolves the caller's own record only. + * Soft delete (`deletedAt`); answers `{ id, removedAt }`, 404 + * `anamnesis.auditC.notFound` for an id that is not a live AUDIT-C entry of + * the record. Authority `requireRecordAuth("manage", "profile")`, like the + * create. */ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate } from "@/lib/logging/context"; +import { removeAuditC } from "@/lib/profile/life-circumstances"; type RouteParams = { params: Promise<{ id: string }> }; export const DELETE = apiHandler( - async (_request: NextRequest, { params }: RouteParams) => { - await requireAuth(); - await params; - return notImplemented("anamnesis.audit_c.delete"); + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + const { id } = await params; + + const removed = await removeAuditC(user.id, id); + if (!removed) { + return apiError("AUDIT-C entry not found", 404, { + errorCode: "anamnesis.auditC.notFound", + }); + } + + await auditLog("anamnesis.audit_c.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { assessmentId: removed.id, instrument: "AUDIT_C" }, + }); + annotate({ + action: { + name: "anamnesis.audit_c.delete", + entity_type: "mental_health_assessment", + entity_id: removed.id, + }, + }); + return apiSuccess(removed); }, ); diff --git a/src/app/api/anamnesis/audit-c/route.ts b/src/app/api/anamnesis/audit-c/route.ts index f95cf5dc0..fa979b285 100644 --- a/src/app/api/anamnesis/audit-c/route.ts +++ b/src/app/api/anamnesis/audit-c/route.ts @@ -6,26 +6,36 @@ * the entry's language and the profile's sex (null without one); a replay * with the same `externalId` answers 200 with the same result. Stored beside * the mental-health screeners but never listed by - * `/api/mental-health/assessments`. At most 30 an hour. + * `/api/mental-health/assessments`. At most 30 an hour, counted against the + * person acting. * - * Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C - * lands the stub resolves the caller's own record only. + * Authority: `requireRecordAuth("manage", "profile")`, the level allergies and + * family history are written at; the entry lands in the record and the trail + * names the manager as actor. */ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; import { withIdempotency } from "@/lib/idempotency"; +import { annotate } from "@/lib/logging/context"; +import { createAuditC } from "@/lib/profile/life-circumstances"; import { checkRateLimit } from "@/lib/rate-limit"; import { auditCRequestSchema } from "@/lib/validations/life-circumstances"; export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC)); async function postAuditC(request: NextRequest): Promise { - const { user } = await requireAuth(); + const { user, actor } = await requireRecordAuth("manage", "profile"); - const rl = await checkRateLimit(`audit-c:${user.id}`, 30, 60 * 60 * 1000); + const rl = await checkRateLimit(`audit-c:${actor.id}`, 30, 60 * 60 * 1000); if (!rl.allowed) { return apiError("Too many entries, try again later", 429, { errorCode: "anamnesis.auditC.rateLimited", @@ -43,5 +53,30 @@ async function postAuditC(request: NextRequest): Promise { }); } - return notImplemented("anamnesis.audit_c.create"); + const input = parsed.data; + const { result, replayed } = await createAuditC(user.id, { + items: input.items, + takenAt: input.takenAt ? new Date(input.takenAt) : undefined, + tz: input.tz, + locale: input.locale ?? user.locale ?? "en", + source: input.source, + externalId: input.externalId, + }); + + annotate({ + action: { + name: replayed ? "anamnesis.audit_c.replay" : "anamnesis.audit_c.create", + entity_type: "mental_health_assessment", + entity_id: result.id, + }, + }); + if (replayed) return apiSuccess({ result }, 200); + + // The id and the instrument only; the answers and the sum stay out of the trail. + await auditLog("anamnesis.audit_c.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { assessmentId: result.id, instrument: "AUDIT_C" }, + }); + return apiSuccess({ result }, 201); } diff --git a/src/app/api/anamnesis/facts/[id]/route.ts b/src/app/api/anamnesis/facts/[id]/route.ts index f5c1f7970..0e7f4d227 100644 --- a/src/app/api/anamnesis/facts/[id]/route.ts +++ b/src/app/api/anamnesis/facts/[id]/route.ts @@ -13,16 +13,28 @@ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { correctHealthProfileFact, + correctLifeFact, removeHealthProfileFact, } from "@/lib/profile/health-facts"; +import { dayStartUtc } from "@/lib/profile/life-derived"; import { - healthProfileFactCorrectionSchema, validateCorrectedFactValue, type HealthProfileFactKind, } from "@/lib/validations/health-profile-facts"; +import { + factCorrectionSchema, + isLifeFactKind, + parseLifeFactValue, +} from "@/lib/validations/life-circumstances"; type RouteParams = { params: Promise<{ id: string }> }; +function invalidValue(): Response { + return apiError("Value is not valid for this fact kind", 422, { + errorCode: "anamnesis.fact.invalidValue", + }); +} + export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -32,7 +44,7 @@ export const PATCH = apiHandler( }); if (error) return error; - const parsed = healthProfileFactCorrectionSchema.safeParse(rawBody); + const parsed = factCorrectionSchema.safeParse(rawBody); if (!parsed.success) return returnAllZodIssues(parsed.error, 422); const current = await prisma.healthProfileFactRevision.findFirst({ @@ -46,17 +58,38 @@ export const PATCH = apiHandler( }); if (!current) return apiError("Current profile fact not found", 404); - const value = validateCorrectedFactValue( - current.kind as HealthProfileFactKind, - parsed.data.value, - ); - if (!value) { - return apiError("Value is not valid for this fact kind", 422, { - errorCode: "anamnesis.fact.invalidValue", - }); + // The value's form follows the stored kind: a string from the closed set + // for the three original kinds, an object with `schema: 1` for the life + // circumstances (v1.43, #614). + const now = new Date(); + const validFrom = parsed.data.validFrom + ? dayStartUtc(parsed.data.validFrom) + : undefined; + let corrected: { id: string; kind: string } | null; + if (isLifeFactKind(current.kind)) { + const value = + typeof parsed.data.value === "string" + ? null + : parseLifeFactValue(current.kind, parsed.data.value); + if (!value) return invalidValue(); + corrected = await correctLifeFact(user.id, id, value, now, validFrom); + } else { + const value = + typeof parsed.data.value === "string" + ? validateCorrectedFactValue( + current.kind as HealthProfileFactKind, + parsed.data.value, + ) + : null; + if (!value) return invalidValue(); + corrected = await correctHealthProfileFact( + user.id, + id, + value, + now, + validFrom, + ); } - - const corrected = await correctHealthProfileFact(user.id, id, value); if (!corrected) { return apiError("Profile fact changed since it was loaded", 409, { errorCode: "anamnesis.fact.conflict", diff --git a/src/app/api/anamnesis/facts/route.ts b/src/app/api/anamnesis/facts/route.ts index 55c75ad0d..31f91b2f2 100644 --- a/src/app/api/anamnesis/facts/route.ts +++ b/src/app/api/anamnesis/facts/route.ts @@ -14,10 +14,18 @@ import { annotate } from "@/lib/logging/context"; import { isP2002 } from "@/lib/prisma-errors"; import { createHealthProfileFact, + createLifeFact, readHealthProfileFacts, } from "@/lib/profile/health-facts"; -import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts"; +import { dayStartUtc } from "@/lib/profile/life-derived"; +import { factWriteSchema } from "@/lib/validations/life-circumstances"; +/** + * `GET` lists the three closed kinds only (`SMOKING_STATUS`, `ALCOHOL_PATTERN`, + * `SHIFT_SCHEDULE`): older app builds decode this list closed. `POST` also + * takes the six life-circumstance kinds (v1.43, #614), which are read back + * through `GET /api/anamnesis/life-circumstances`. + */ export const GET = apiHandler(async () => { const { user } = await requireRecordAuth("read", "profile"); const payload = await readHealthProfileFacts(user.id); @@ -38,15 +46,33 @@ async function postFact(request: NextRequest): Promise { }); if (error) return error; - const parsed = healthProfileFactWriteSchema.safeParse(rawBody); + const parsed = factWriteSchema.safeParse(rawBody); if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const now = new Date(); + const validFrom = parsed.data.validFrom + ? dayStartUtc(parsed.data.validFrom) + : undefined; try { - const fact = await createHealthProfileFact( - user.id, - parsed.data.kind, - parsed.data.value, - ); + const input = parsed.data; + const fact = + input.kind === "SMOKING_STATUS" || + input.kind === "ALCOHOL_PATTERN" || + input.kind === "SHIFT_SCHEDULE" + ? await createHealthProfileFact( + user.id, + input.kind, + input.value, + now, + validFrom, + ) + : await createLifeFact( + user.id, + input.kind, + input.value, + now, + validFrom, + ); await auditLog("anamnesis.fact.create", { userId: user.id, ipAddress: getClientIp(request), diff --git a/src/app/api/anamnesis/life-circumstances/ai-sections/route.ts b/src/app/api/anamnesis/life-circumstances/ai-sections/route.ts index b630a0c70..d0dd292bf 100644 --- a/src/app/api/anamnesis/life-circumstances/ai-sections/route.ts +++ b/src/app/api/anamnesis/life-circumstances/ai-sections/route.ts @@ -6,17 +6,26 @@ * key, over `SMOKING_STATUS`, `ALCOHOL_PATTERN`, `SHIFT_SCHEDULE`, `ACTIVITY`, * `WORK`, `HABITS`, `GOALS`. Sections not named keep their state. A stale * `baseUpdatedAt` is 409 `about_me_conflict`, the token `about-me` uses. - * Own account only. + * Own account only: which of a person's answers a model may read is that + * person's decision. */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { returnAllZodIssues, safeJson } from "@/lib/api-response"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate } from "@/lib/logging/context"; +import { updateLifeAiSections } from "@/lib/profile/life-circumstances"; import { lifeAiSectionsPatchSchema } from "@/lib/validations/life-circumstances"; export const PATCH = apiHandler(async (request: NextRequest) => { - await requireAuth(); + const { user } = await requireAuth(); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 4 * 1024, @@ -29,5 +38,30 @@ export const PATCH = apiHandler(async (request: NextRequest) => { }); } - return notImplemented("anamnesis.life_circumstances.ai_sections.patch"); + const outcome = await updateLifeAiSections( + user.id, + parsed.data.sections, + parsed.data.baseUpdatedAt ? new Date(parsed.data.baseUpdatedAt) : undefined, + ); + if (outcome.conflict) { + annotate({ action: { name: "anamnesis.life_sections.conflict" } }); + return apiError("Self-context changed since it was loaded", 409, { + errorCode: "about_me_conflict", + }); + } + + // Section names and switch states only; nothing about the answers. + await auditLog("anamnesis.life_sections.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { sections: parsed.data.sections }, + }); + annotate({ + action: { name: "anamnesis.life_sections.update" }, + meta: { changed: Object.keys(parsed.data.sections).length }, + }); + return apiSuccess({ + aiSections: outcome.aiSections, + updatedAt: outcome.updatedAt, + }); }); diff --git a/src/app/api/anamnesis/life-circumstances/confirm/route.ts b/src/app/api/anamnesis/life-circumstances/confirm/route.ts index 65652c842..5ea35ed85 100644 --- a/src/app/api/anamnesis/life-circumstances/confirm/route.ts +++ b/src/app/api/anamnesis/life-circumstances/confirm/route.ts @@ -1,17 +1,20 @@ /** * `POST /api/anamnesis/life-circumstances/confirm` — "still true" for one * section (#614). `{ section }`; stores the confirmation instant only, never a - * new revision, and answers `{ section, lastConfirmedAt }`. Own account only. + * new revision, and answers `{ section, lastConfirmedAt }`. Own account only, + * like the answers' AI switches: it vouches for the person's own account of + * their life, which a delegate cannot do for them. */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { returnAllZodIssues, safeJson } from "@/lib/api-response"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { confirmLifeSection } from "@/lib/profile/life-circumstances"; import { lifeConfirmSchema } from "@/lib/validations/life-circumstances"; export const POST = apiHandler(async (request: NextRequest) => { - await requireAuth(); + const { user } = await requireAuth(); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 1024, @@ -24,5 +27,13 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - return notImplemented("anamnesis.life_circumstances.confirm"); + const lastConfirmedAt = await confirmLifeSection( + user.id, + parsed.data.section, + ); + annotate({ + action: { name: "anamnesis.life_section.confirm" }, + meta: { section: parsed.data.section }, + }); + return apiSuccess({ section: parsed.data.section, lastConfirmedAt }); }); diff --git a/src/app/api/anamnesis/life-circumstances/route.ts b/src/app/api/anamnesis/life-circumstances/route.ts index 5731e1245..db6a1ddda 100644 --- a/src/app/api/anamnesis/life-circumstances/route.ts +++ b/src/app/api/anamnesis/life-circumstances/route.ts @@ -9,11 +9,24 @@ * read it under the `profile` grant, like the facts. */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { buildLifeCircumstances } from "@/lib/profile/life-circumstances"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - await requireRecordAuth("read", "profile"); - return notImplemented("anamnesis.life_circumstances.get"); + const { user } = await requireRecordAuth("read", "profile"); + const payload = await buildLifeCircumstances(user.id); + annotate({ + action: { name: "anamnesis.life_circumstances.get" }, + meta: { + fact_count: payload.sections.reduce( + (sum, section) => sum + section.facts.length, + 0, + ), + audit_c_count: payload.auditC.history.length, + }, + }); + return apiSuccess(payload); }); diff --git a/src/app/api/coach/about-me/route.ts b/src/app/api/coach/about-me/route.ts index f6891bd25..5cc84ed6c 100644 --- a/src/app/api/coach/about-me/route.ts +++ b/src/app/api/coach/about-me/route.ts @@ -64,7 +64,9 @@ import { } from "@/lib/validations/about-me"; import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS, - type HealthProfileAiSection, + HEALTH_PROFILE_AI_SECTIONS, + aboutMeAiSections, + type StoredHealthProfileAiSection, } from "@/lib/validations/health-profile-facts"; const PUT_RATE_LIMIT = 30; @@ -101,9 +103,13 @@ export const GET = apiHandler(async () => { }), getAiCapability("aboutMeQuestions"), ]); - const aiIncludedSections = - (row?.aiIncludedSections as HealthProfileAiSection[] | undefined) ?? - DEFAULT_HEALTH_PROFILE_AI_SECTIONS; + // The eight sections this wire has always carried. The life-circumstance + // sections (v1.43) live in the same column but are switched and read + // through `/api/anamnesis/life-circumstances`; older app builds decode this + // list closed. + const aiIncludedSections = aboutMeAiSections( + row?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + ); // A stored set may be model-written. While the capability is unavailable // an outstanding set is swapped for the deterministic hints; an answered // (empty) set stays empty. @@ -201,7 +207,7 @@ export const PUT = apiHandler(async (req: Request) => { conditionsEncrypted?: Uint8Array | null; allergiesEncrypted?: Uint8Array | null; coachFocusEncrypted?: Uint8Array | null; - aiIncludedSections?: HealthProfileAiSection[]; + aiIncludedSections?: StoredHealthProfileAiSection[]; } = {}; const aboutMePayload = encryptOptional(parsed.data.aboutMe); if (aboutMePayload !== undefined) { @@ -219,9 +225,6 @@ export const PUT = apiHandler(async (req: Request) => { if (coachFocusPayload !== undefined) { update.coachFocusEncrypted = coachFocusPayload; } - if (parsed.data.aiIncludedSections !== undefined) { - update.aiIncludedSections = parsed.data.aiIncludedSections; - } const fieldLengths: Record = { ...(text !== undefined ? { aboutMe: text.length } : {}), ...(parsed.data.conditions !== undefined @@ -240,6 +243,23 @@ export const PUT = apiHandler(async (req: Request) => { // commit while an hour-fresh advisor payload still references excluded // profile content. const persistence = await prisma.$transaction(async (tx) => { + // A PUT here replaces the eight sections it lists and leaves the + // life-circumstance switches (v1.43) exactly as stored. + if (parsed.data.aiIncludedSections !== undefined) { + const stored = await tx.userHealthProfile.findUnique({ + where: { userId: user.id }, + select: { aiIncludedSections: true }, + }); + update.aiIncludedSections = [ + ...parsed.data.aiIncludedSections, + ...(stored?.aiIncludedSections ?? []).filter( + (section) => + !(HEALTH_PROFILE_AI_SECTIONS as readonly string[]).includes( + section, + ), + ), + ]; + } let updatedAtIso: string; let savedProfileUpdatedAt: Date | null = null; @@ -334,10 +354,9 @@ export const PUT = apiHandler(async (req: Request) => { select: { aiIncludedSections: true }, }), ]); - const aiIncludedSections = (controlRow?.aiIncludedSections as - HealthProfileAiSection[] | undefined) ?? [ - ...DEFAULT_HEALTH_PROFILE_AI_SECTIONS, - ]; + const aiIncludedSections = aboutMeAiSections( + controlRow?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + ); const aiContext = filterSelfContextForAi(ctx, aiIncludedSections); const aiContextIsEmpty = aiContext.aboutMe === null && diff --git a/src/app/api/mental-health/assessments/[id]/route.ts b/src/app/api/mental-health/assessments/[id]/route.ts index 3472b760a..a2a720862 100644 --- a/src/app/api/mental-health/assessments/[id]/route.ts +++ b/src/app/api/mental-health/assessments/[id]/route.ts @@ -30,6 +30,7 @@ */ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; +import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate, getEvent } from "@/lib/logging/context"; @@ -122,7 +123,12 @@ export const GET = apiHandler( // Fetch-then-guard against the resolved user (the by-id sibling pattern); // a soft-deleted row 404s like it does on the list. const row = await prisma.mentalHealthAssessment.findFirst({ - where: { id, deletedAt: null }, + // Screeners only; an AUDIT-C entry (v1.43) is a 404 here. + where: { + id, + deletedAt: null, + instrument: { in: [...SCREENER_INSTRUMENTS] }, + }, select: { id: true, userId: true, diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index c1ad8cc72..5fa11e524 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -36,6 +36,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { + SCREENER_INSTRUMENTS, createAssessmentSchema, listAssessmentsSchema, } from "@/lib/validations/mental-health"; @@ -121,7 +122,9 @@ export const GET = apiHandler(async (request: NextRequest) => { where: { userId: user.id, deletedAt: null, - ...(instrument ? { instrument } : {}), + // Screeners only: the table also stores AUDIT-C (v1.43), which older + // app builds cannot decode in this list. + instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] }, }, // Unique tiebreaker — `takenAt` is day-resolution and two instruments // taken in one sitting tie. @@ -245,7 +248,11 @@ async function postAssessment(request: NextRequest): Promise { // per user, not globally. if (externalId) { const existing = await prisma.mentalHealthAssessment.findFirst({ - where: { userId: user.id, externalId }, + where: { + userId: user.id, + externalId, + instrument: { in: [...SCREENER_INSTRUMENTS] }, + }, select: { id: true, instrument: true, @@ -332,7 +339,11 @@ async function postAssessment(request: NextRequest): Promise { (err as { code?: unknown }).code === "P2002" ) { const winner = await prisma.mentalHealthAssessment.findFirst({ - where: { userId: user.id, externalId }, + where: { + userId: user.id, + externalId, + instrument: { in: [...SCREENER_INSTRUMENTS] }, + }, select: assessmentSelect, }); if (winner) { diff --git a/src/lib/day/day-index.ts b/src/lib/day/day-index.ts index fc6e7ce78..70b7f7504 100644 --- a/src/lib/day/day-index.ts +++ b/src/lib/day/day-index.ts @@ -29,6 +29,7 @@ import { readLocalDaysWithReadings } from "@/lib/day/daily-stats"; import { loadNotableRange } from "@/lib/day/notable"; import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections"; import { prisma } from "@/lib/db"; +import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health"; import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; import { effectiveMoodTz } from "@/lib/mood/date-key"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; @@ -341,6 +342,8 @@ const INDEX_READERS: Partial< where: { userId: frame.userId, deletedAt: null, + // AUDIT-C (v1.43) is not a screener and marks no day. + instrument: { in: [...SCREENER_INSTRUMENTS] }, takenAt: { gte: new Date(frame.start.getTime() - 14 * 3_600_000), lt: new Date(frame.end.getTime() + 14 * 3_600_000), diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index 0c09f9325..e065d9d97 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -29,6 +29,7 @@ import type { DaySectionKey, } from "@/lib/day/contract"; import { prisma } from "@/lib/db"; +import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health"; import { listTargetsBySource } from "@/lib/links/link-service"; import { getEvent } from "@/lib/logging/context"; import { lastDayCovered } from "@/lib/life-events/dates"; @@ -851,6 +852,8 @@ async function assessmentsPart(frame: DayFrame): Promise { where: { userId: frame.userId, deletedAt: null, + // AUDIT-C (v1.43) lives in the life circumstances, not the day's screeners. + instrument: { in: [...SCREENER_INSTRUMENTS] }, takenAt: zoneSafeWindow(frame), }, select: { diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index 289e54d23..c9fd6119d 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -29,10 +29,12 @@ import { encryptNote } from "@/lib/crypto/note-cipher"; import { getEvent } from "@/lib/logging/context"; import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS, - isHealthProfileFactValue, - type HealthProfileAiSection, - type HealthProfileFactKind, + type StoredHealthProfileAiSection, } from "@/lib/validations/health-profile-facts"; +import { + isStoredFactPlaintext, + type StoredFactKind, +} from "@/lib/validations/life-circumstances"; import type { AdvanceDirectiveStatusValue, EmergencyBloodTypeValue, @@ -77,7 +79,7 @@ export interface HealthProfileBackupEntry { conditionsEncrypted?: string | null; allergiesEncrypted?: string | null; coachFocusEncrypted?: string | null; - aiIncludedSections: HealthProfileAiSection[]; + aiIncludedSections: StoredHealthProfileAiSection[]; /** * Emergency ("Notfalldaten") profile. The three enums are plaintext closed * sets, carried by value in both purposes exactly like `aiIncludedSections`. @@ -116,7 +118,7 @@ export interface HealthProfileBackupEntry { export interface HealthProfileFactBackupEntry { id: string; - kind: HealthProfileFactKind; + kind: StoredFactKind; /** Portable exports carry readable plaintext; DR payloads leave this null. */ value: string | null; /** Base64 encrypted envelope, present only in disaster-recovery payloads. */ @@ -345,7 +347,7 @@ export async function buildProfileBackupSection( allergies: null, coachFocus: null, aiIncludedSections: (profileRow.aiIncludedSections as - HealthProfileAiSection[] | undefined) ?? [ + StoredHealthProfileAiSection[] | undefined) ?? [ ...DEFAULT_HEALTH_PROFILE_AI_SECTIONS, ], lifeConfirmedJson: lifeConfirmed(profileRow.lifeConfirmedJson), @@ -394,7 +396,7 @@ export async function buildProfileBackupSection( decryptFailures, ), aiIncludedSections: (profileRow.aiIncludedSections as - HealthProfileAiSection[] | undefined) ?? [ + StoredHealthProfileAiSection[] | undefined) ?? [ ...DEFAULT_HEALTH_PROFILE_AI_SECTIONS, ], lifeConfirmedJson: lifeConfirmed(profileRow.lifeConfirmedJson), @@ -424,7 +426,7 @@ export async function buildProfileBackupSection( for (const fact of factRows) { healthProfileFacts.push({ id: fact.id, - kind: fact.kind as HealthProfileFactKind, + kind: fact.kind as StoredFactKind, value: null, valueEncrypted: toBase64(fact.valueEncrypted)!, validFrom: fact.validFrom.toISOString(), @@ -443,10 +445,10 @@ export async function buildProfileBackupSection( `fact.${fact.kind}`, decryptFailures, ); - const kind = fact.kind as HealthProfileFactKind; + const kind = fact.kind as StoredFactKind; if ( value === null || - !isHealthProfileFactValue(kind, value) || + !isStoredFactPlaintext(kind, value) || (fact.validUntil !== null && fact.validUntil <= fact.validFrom) ) { continue; @@ -658,10 +660,7 @@ function resolveFactValue( if (entry.valueEncrypted !== undefined) { return decodeEncryptedField(entry.valueEncrypted, `fact.${entry.kind}`); } - if ( - entry.value === null || - !isHealthProfileFactValue(entry.kind, entry.value) - ) { + if (entry.value === null || !isStoredFactPlaintext(entry.kind, entry.value)) { throw new Error( `Health profile fact '${entry.kind}' has no valid readable value`, ); @@ -755,7 +754,7 @@ export async function restoreProfileData( }); const liveSections = ( (liveProfileScope?.aiIncludedSections as - HealthProfileAiSection[] | undefined) ?? + StoredHealthProfileAiSection[] | undefined) ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS ) .slice() diff --git a/src/lib/mental-health/audit-c.ts b/src/lib/mental-health/audit-c.ts new file mode 100644 index 000000000..58836ff1f --- /dev/null +++ b/src/lib/mental-health/audit-c.ts @@ -0,0 +1,161 @@ +/** + * v1.43 (#614) — AUDIT-C, the alcohol section of the life circumstances. + * + * Three items of the WHO AUDIT, 0 to 4 points each, total 0 to 12. A + * screening result, never a diagnosis: the read route shows the sum and, when + * the profile states a sex, whether it reaches the screening cut-off of the + * entry's country, with that source named. + * + * Cut-offs follow the language of the entry, because the validated wording a + * person answered is the one the national cut-off was set against: + * - `de`: German S3 guideline on alcohol-related disorders (AWMF 076-001): + * men 5, women 4. + * - `ko`: Korean AUDIT-C cut-offs from KNHANES data: men 5, women 4. + * - every other language: Bradley et al. 2007 (US primary care): men 4, + * women 3. + * A profile without a sex, or with `OTHER`, gets no cut-off at all: the + * tables have no third row, and picking one of the two would be a guess. + * + * The item wording lives in `instruments.ts` (`AUDIT_C_ITEMS`), with the + * source of every translation beside it. + */ +import { binaryReferenceSex } from "@/lib/profile/sex"; +import { countryLabel } from "@/lib/profile/country-label"; +import { + AUDIT_C_ATTRIBUTION, + AUDIT_C_ITEMS, + AUDIT_C_STANDARD_DRINK, +} from "@/lib/mental-health/instruments"; + +export type AuditCCutoffSource = + "S3_ALCOHOL_DE" | "KNHANES_KR" | "BRADLEY_2007"; + +export interface AuditCCutoff { + value: number; + sex: "male" | "female"; + source: AuditCCutoffSource; + sourceLabel: string; + sourceUrl: string; +} + +const CUTOFFS: Readonly< + Record< + AuditCCutoffSource, + { + male: number; + female: number; + citation: string; + country: "DE" | "KR" | "US"; + url: string; + } + > +> = { + S3_ALCOHOL_DE: { + male: 5, + female: 4, + citation: + "S3-Leitlinie Screening, Diagnose und Behandlung alkoholbezogener Störungen (AWMF 076-001)", + country: "DE", + url: "https://alkoholleitlinie.de/diagnostik/test-audit-c/", + }, + KNHANES_KR: { + male: 5, + female: 4, + citation: + "AUDIT-C cut-offs from the Korea National Health and Nutrition Examination Survey (KNHANES)", + country: "KR", + url: "https://pmc.ncbi.nlm.nih.gov/articles/PMC6039362", + }, + BRADLEY_2007: { + male: 4, + female: 3, + citation: "Bradley KA et al., Alcohol Clin Exp Res 2007;31(7):1208-1217", + country: "US", + url: "https://onlinelibrary.wiley.com/doi/10.1111/j.1530-0277.2007.00403.x", + }, +}; + +/** The cut-off table an entry in this language is read against. */ +export function auditCCutoffSource(locale: string): AuditCCutoffSource { + if (locale === "de") return "S3_ALCOHOL_DE"; + if (locale === "ko") return "KNHANES_KR"; + return "BRADLEY_2007"; +} + +/** + * The cut-off for an entry's language and the profile's sex; null when the + * profile states no binary sex. `labelLocale` is the language the source line + * is written for (the account's), so the country reads in that language. + */ +export function auditCCutoff( + entryLocale: string, + sex: string | null, + labelLocale: string, +): AuditCCutoff | null { + const binary = binaryReferenceSex(sex); + if (!binary) return null; + const source = auditCCutoffSource(entryLocale); + const row = CUTOFFS[source]; + const key = binary === "MALE" ? "male" : "female"; + return { + value: row[key], + sex: key, + source, + sourceLabel: `${row.citation} (${countryLabel(row.country, labelLocale)})`, + sourceUrl: row.url, + }; +} + +export function auditCTotal(items: readonly number[]): number { + return items.reduce((sum, item) => sum + item, 0); +} + +/** The app languages; anything else is read as English. */ +const APP_LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const; + +/** Normalise a requested or stored locale onto the app languages. */ +export function normaliseAuditCLocale( + locale: string | null | undefined, +): string { + const base = (locale ?? "en").slice(0, 2).toLowerCase(); + return (APP_LOCALES as readonly string[]).includes(base) ? base : "en"; +} + +/** Whether `locale` has a validated translation of the three items. */ +export function isValidatedAuditCLocale(locale: string): boolean { + return locale in AUDIT_C_ITEMS; +} + +export interface AuditCInstrument { + items: Array<{ + stem: string; + options: Array<{ value: number; label: string }>; + }>; + locale: string; + validatedLocale: boolean; + standardDrink: string; + attribution: string; +} + +/** + * The instrument as a client renders it: the three items verbatim in the + * account language when a validated translation exists, the WHO English + * original otherwise (`validatedLocale: false`). Never a translation of our + * own. The standard-drink sentence is help text in the account language. + */ +export function auditCInstrument( + accountLocale: string | null | undefined, +): AuditCInstrument { + const locale = normaliseAuditCLocale(accountLocale); + const wording = AUDIT_C_ITEMS[locale] ?? AUDIT_C_ITEMS.en; + return { + items: wording.items.map((item) => ({ + stem: item.stem, + options: item.options.map((label, value) => ({ value, label })), + })), + locale: wording.locale, + validatedLocale: wording.locale === locale, + standardDrink: AUDIT_C_STANDARD_DRINK[locale] ?? AUDIT_C_STANDARD_DRINK.en, + attribution: AUDIT_C_ATTRIBUTION, + }; +} diff --git a/src/lib/mental-health/instruments.ts b/src/lib/mental-health/instruments.ts index 73b04c3f9..54a4f5720 100644 --- a/src/lib/mental-health/instruments.ts +++ b/src/lib/mental-health/instruments.ts @@ -357,3 +357,207 @@ export function isSafetyFlagged( if (idx === null) return false; return (items[idx] ?? 0) > 0; } + +// ── AUDIT-C (v1.43, #614) ──────────────────────────────────────────────────── +// +// The first three questions of the WHO AUDIT, served verbatim by +// `GET /api/anamnesis/life-circumstances` (`auditC.instrument`) so the web and +// the app render one wording. Not part of `INSTRUMENTS`: AUDIT-C is stored +// beside the screeners but never listed or scored by a mental-health surface +// (older app builds decode that instrument list closed). Scoring and the +// national cut-offs live in `audit-c.ts`. +// +// Licence: "As a WHO-approved instrument, the AUDIT is in the public domain" +// (auditscreen.org FAQ); reproduction is free as long as the wording is not +// materially changed and the WHO note is shown. Adapting the standard drink to +// the country is foreseen by the WHO manual, which is why that sentence is +// help text per language and not part of the items. +// +// Wording is taken from primary sources only. A language without a verified, +// published validated translation ships the WHO English original with +// `validatedLocale: false`, never a translation of our own: +// - en: Babor et al., AUDIT manual, WHO/MSD/MSB/01.6a (2001), Box 4. +// https://www.paho.org/sites/default/files/Auditmanual_ENG.pdf +// - de: AUDIT Lübeck (Rumpf et al.), the version attached to the German S3 +// guideline (AWMF 076-001), as distributed on auditscreen.org: +// https://auditscreen.org/cmsb/uploads/audit-german-l-beck.pdf +// (the guideline PDF's own annex misprints item 2 as "7 oder 8", so +// the Lübeck sheet is the one reproduced). +// - ko: AUDIT-KR, Kim CG, Kim JS et al., Korean J Fam Med 2014;35:2-10, +// Appendix 1. https://www.kjfm.or.kr/upload/pdf/kjfm-35-2.pdf +// Item 2 prints two answer rows (soju bottles and standard glasses of +// other drinks); each option carries both, in the source's order. Item 3 +// uses the Korean threshold (more than one bottle of soju, or five +// standard glasses), which the KNHANES cut-offs were set against. +// - pl: Klimkiewicz A et al., "Psychometric properties of the Polish version +// of the AUDIT", Drug Alcohol Depend 2021; interview form as published +// on auditscreen.org: +// https://auditscreen.org/cmsb/uploads/audit_-polish_-interview_new.pdf +// - es, fr, it: English with `validatedLocale: false`. Published official +// wordings exist (WHO manual in Spanish; HAS 2023 guide in French; an +// ISS leaflet in Italian), but no validation paper for them was read +// and the Spanish and Italian sources carry misprints in the options. +// Enabling one needs the wording read from its validation source. + +export const AUDIT_C_ATTRIBUTION = + "As a WHO-approved instrument, the AUDIT is in the public domain."; + +interface AuditCItemWording { + stem: string; + /** Answer labels in score order, 0 to 4. */ + options: readonly [string, string, string, string, string]; +} + +interface AuditCWording { + locale: string; + items: readonly [AuditCItemWording, AuditCItemWording, AuditCItemWording]; +} + +/** Validated item wording by language. A language missing here renders `en`. */ +export const AUDIT_C_ITEMS: Readonly> = { + en: { + locale: "en", + items: [ + { + stem: "How often do you have a drink containing alcohol?", + options: [ + "Never", + "Monthly or less", + "2 to 4 times a month", + "2 to 3 times a week", + "4 or more times a week", + ], + }, + { + stem: "How many drinks containing alcohol do you have on a typical day when you are drinking?", + options: ["1 or 2", "3 or 4", "5 or 6", "7, 8, or 9", "10 or more"], + }, + { + stem: "How often do you have six or more drinks on one occasion?", + options: [ + "Never", + "Less than monthly", + "Monthly", + "Weekly", + "Daily or almost daily", + ], + }, + ], + }, + de: { + locale: "de", + items: [ + { + stem: "Wie oft nehmen Sie ein alkoholisches Getränk zu sich?", + options: [ + "Niemals", + "1 mal im Monat oder seltener", + "2 bis 4 mal im Monat", + "2 bis 3 mal pro Woche", + "4 mal oder mehrmals in der Woche", + ], + }, + { + stem: "Wenn Sie alkoholische Getränke zu sich nehmen, wieviel trinken Sie dann typischerweise an einem Tag?", + options: ["1 - 2", "3 - 4", "5 - 6", "7 - 9", "10 oder mehr"], + }, + { + stem: "Wie oft trinken Sie 6 oder mehr Gläser Alkohol bei einer Gelegenheit?", + options: [ + "Niemals", + "Seltener als 1 mal im Monat", + "1 mal im Monat", + "1 mal pro Woche", + "Täglich oder fast täglich", + ], + }, + ], + }, + ko: { + locale: "ko", + items: [ + { + stem: "술을 마시는 횟수는 어느 정도입니까?", + options: [ + "전혀 안 마신다", + "한 달에 1회 이하", + "한 달에 2–4회", + "일주일에 2–3회", + "일주일에 4회 이상", + ], + }, + { + stem: "술을 마시는 날은 보통 어느 정도를 마십니까?", + options: [ + "소주: 반 병 이하 / 기타 술: 1–2잔", + "소주: 1병 이하 / 기타 술: 3–4잔", + "소주: 1.5병 정도 / 기타 술: 5–6잔", + "소주: 2병 정도 / 기타 술: 7–9잔", + "소주: 2.5병 이상 / 기타 술: 10잔 이상", + ], + }, + { + stem: "한 번의 술좌석에서 소주 1병을 초과해서 마시는 횟수는 어느 정도입니까?(기타 술은 표준 5잔 이상)", + options: [ + "전혀 없다", + "한 달에 1회 미만", + "한 달에 1회정도", + "일주일에 1회 정도", + "거의 매일", + ], + }, + ], + }, + pl: { + locale: "pl", + items: [ + { + stem: "Jak często pije Pani/Pan napoje zawierające alkohol?", + options: [ + "nigdy", + "raz w miesiącu lub rzadziej", + "2 do 4 razy w miesiącu", + "2 do 3 razy w tygodniu", + "4 razy w tygodniu lub częściej", + ], + }, + { + stem: "Ile standardowych porcji alkoholu wypija Pani/Pan podczas typowego dnia, w którym spożywa Pani/Pan alkohol?", + options: [ + "1 lub 2", + "3 lub 4", + "5 lub 6", + "7, 8 lub 9", + "10 lub więcej", + ], + }, + { + stem: "Jak często wypija Pani/Pan sześć lub więcej porcji alkoholu przy jednej okazji?", + options: [ + "nigdy", + "rzadziej niż raz w miesiącu", + "raz w miesiącu", + "raz w tygodniu", + "codziennie lub prawie codziennie", + ], + }, + ], + }, +}; + +/** + * One sentence on what counts as one drink, in each app language. Help text, + * not part of the validated items. German and French follow their national + * sources (alkoholleitlinie.de: about 10 to 12 g; HAS: 10 g), Korean quotes + * the AUDIT-KR footnote, and the languages that show the English items use + * the WHO manual's 10 g so the note matches the wording beside it. + */ +export const AUDIT_C_STANDARD_DRINK: Readonly> = { + en: "One drink means a standard drink of about 10 g of pure alcohol, for example 330 ml of beer at 5%, 140 ml of wine at 12% or 40 ml of spirits at 40%.", + de: "Ein Glas Alkohol entspricht etwa 10 bis 12 g reinem Alkohol, zum Beispiel 0,25 l Bier, 0,125 l Wein oder Sekt oder 4 cl Spirituosen.", + es: "Una bebida equivale a una bebida estándar de unos 10 g de alcohol puro, la definición de la OMS que siguen estas preguntas.", + fr: "Un verre standard contient 10 g d'alcool pur, par exemple 25 cl de bière à 5°, 10 cl de vin à 12° ou 3 cl d'alcool à 40°.", + it: "Una bevanda corrisponde a una bevanda standard di circa 10 g di alcol puro, la definizione dell'OMS su cui si basano queste domande.", + pl: "Standardowa porcja alkoholu to około 10 g czystego alkoholu, zgodnie z definicją WHO, na której opierają się te pytania.", + ko: "표준 1잔: 양주잔으로 양주 1잔, 와인잔으로 와인 1잔, 막걸리 1사발이 각각 표준 1잔이다. 맥주는 캔맥주 1캔 또는 작은 병맥주 1병이며 생맥주는 500 mL가 표준 1잔이다. 소주는 1/4병이 표준 1잔에 해당한다.", +}; diff --git a/src/lib/modules/domain-data.ts b/src/lib/modules/domain-data.ts index 66c941250..65556d5d3 100644 --- a/src/lib/modules/domain-data.ts +++ b/src/lib/modules/domain-data.ts @@ -25,6 +25,7 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { measurementTypesOwnedBy } from "./measurement-scope"; import type { OwnedModuleKey } from "./registry"; +import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health"; /** The delegates this file reads. Narrow so a worker's client fits too. */ type DomainDataClient = Pick< @@ -183,7 +184,12 @@ const MODULE_DOMAIN_PROBES: Record = { mentalHealth: (client, recordId) => anyRow( client.mentalHealthAssessment.findFirst({ - where: { userId: recordId, deletedAt: null }, + // AUDIT-C (v1.43) belongs to the life circumstances, not this module. + where: { + userId: recordId, + deletedAt: null, + instrument: { in: [...SCREENER_INSTRUMENTS] }, + }, select: { id: true }, }), ), diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 8560c5a3f..a2a5969bf 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -60,6 +60,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = analytics: ["analytics.invalid_query"], anamnesis: [ "anamnesis.auditC.invalid", + "anamnesis.auditC.notFound", "anamnesis.auditC.rateLimited", "anamnesis.fact.conflict", "anamnesis.fact.currentExists", diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 9c81aa134..0b2d67cdd 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -13,12 +13,15 @@ import { } from "@/lib/validations/about-me"; import { healthProfileAiSectionSchema, - healthProfileFactCorrectionSchema, healthProfileFactDtoSchema, healthProfileFactsResponseSchema, - healthProfileFactWriteSchema, - removedHealthProfileFactSchema, } from "@/lib/validations/health-profile-facts"; +import { + factCorrectionSchema, + factWriteSchema, + lifeFactRevisionSchema, + removedFactSchema, +} from "@/lib/validations/life-circumstances"; import { emergencyProfileDtoSchema, emergencyProfileUpdateSchema, @@ -1526,7 +1529,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Read effective-dated anamnesis facts", description: - "Returns the current value and immutable revision history for the caller's smoking status, alcohol pattern and shift schedule. Values are decrypted server-side; unreadable ciphertext returns `value: null` with `unreadable: true`.", + "Returns the current value and immutable revision history for the caller's smoking status, alcohol pattern and shift schedule, and only those three kinds: the life-circumstance kinds are read through `GET /api/anamnesis/life-circumstances`. Values are decrypted server-side; unreadable ciphertext returns `value: null` with `unreadable: true`.", responses: { ...recordRefusal(), "200": { @@ -1547,12 +1550,12 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Create an anamnesis fact", description: - "Creates the first current revision for one closed fact kind. The answer is encrypted before persistence. A second current value for the same kind returns 409; corrections use the revision endpoint.", + "Creates the first current revision for one fact kind. The answer is encrypted before persistence. The three closed kinds take their string value and answer the closed revision shape; the six life-circumstance kinds (v1.43, send only when `features.lifeCircumstances` is true) take an object with `schema: 1` and answer a `LifeFactRevision`. Optional `validFrom` (a calendar day) dates the answer back. A second current value for the same kind returns 409; corrections use the revision endpoint.", parameters: [idempotencyKeyParameter], requestBody: { required: true, content: { - "application/json": { schema: healthProfileFactWriteSchema }, + "application/json": { schema: factWriteSchema }, }, }, responses: { @@ -1562,7 +1565,7 @@ export const coachPaths: NonNullable = { content: { "application/json": { schema: dataEnvelope( - healthProfileFactDtoSchema, + z.union([healthProfileFactDtoSchema, lifeFactRevisionSchema]), "CreateAnamnesisFactResponse", ), }, @@ -1578,7 +1581,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Correct a current anamnesis fact", description: - "Closes the current revision at the correction instant and creates a successor. Prior ciphertext and its half-open validity interval remain in history.", + "Closes the current revision at the correction instant (or at the optional `validFrom` day, never earlier than the revision it replaces) and creates a successor. The value takes the stored kind's form: a string for the closed kinds, an object with `schema: 1` for the life-circumstance kinds; any other is 422 `anamnesis.fact.invalidValue`. Prior ciphertext and its half-open validity interval remain in history.", parameters: [ { name: "id", @@ -1590,7 +1593,7 @@ export const coachPaths: NonNullable = { requestBody: { required: true, content: { - "application/json": { schema: healthProfileFactCorrectionSchema }, + "application/json": { schema: factCorrectionSchema }, }, }, responses: { @@ -1599,7 +1602,7 @@ export const coachPaths: NonNullable = { content: { "application/json": { schema: dataEnvelope( - healthProfileFactDtoSchema, + z.union([healthProfileFactDtoSchema, lifeFactRevisionSchema]), "CorrectAnamnesisFactResponse", ), }, @@ -1629,7 +1632,7 @@ export const coachPaths: NonNullable = { content: { "application/json": { schema: dataEnvelope( - removedHealthProfileFactSchema, + removedFactSchema, "RemoveAnamnesisFactResponse", ), }, diff --git a/src/lib/openapi/routes/life-circumstances.ts b/src/lib/openapi/routes/life-circumstances.ts index dd936caf6..706107f1b 100644 --- a/src/lib/openapi/routes/life-circumstances.ts +++ b/src/lib/openapi/routes/life-circumstances.ts @@ -14,8 +14,8 @@ import { z } from "zod/v4"; import { AUDIT_C_CUTOFF_SOURCES, LIFE_AI_SECTIONS, - LIFE_FACT_KINDS, LIFE_SECTION_KEYS, + lifeFactRevisionSchema, auditCRequestSchema, lifeAiSectionsPatchSchema, lifeConfirmSchema, @@ -26,32 +26,14 @@ import { errorEnvelope, idempotencyKeyParameter, idempotentWrite, - notImplementedResponse, recordRefusal, stdResponses, } from "./shared"; -const lifeFactRevision = z - .object({ - id: z.string(), - kind: z.enum(["SMOKING_STATUS", "SHIFT_SCHEDULE", ...LIFE_FACT_KINDS]), - value: z - .unknown() - .describe( - "`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`.", - ), - unreadable: z - .boolean() - .describe("The sealed value could not be opened on this host."), - validFrom: z.iso.datetime({ offset: true }), - validUntil: z.null(), - provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]), - createdAt: z.iso.datetime({ offset: true }), - }) - .meta({ - id: "LifeFactRevision", - description: "The current revision of one life-circumstance fact.", - }); +const lifeFactRevision = lifeFactRevisionSchema.meta({ + id: "LifeFactRevision", + description: "The current revision of one life-circumstance fact.", +}); const auditCCutoff = z.object({ value: z.number().int(), @@ -210,7 +192,6 @@ export const lifeCircumstancesPaths: NonNullable = { }, }, ...stdResponses, - ...notImplementedResponse, }, }, }, @@ -249,7 +230,6 @@ export const lifeCircumstancesPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, - ...notImplementedResponse, }, }, }, @@ -279,7 +259,6 @@ export const lifeCircumstancesPaths: NonNullable = { }, }, ...stdResponses, - ...notImplementedResponse, }, }, }, @@ -295,6 +274,7 @@ export const lifeCircumstancesPaths: NonNullable = { content: { "application/json": { schema: auditCRequest } }, }, responses: { + ...recordRefusal(), ...idempotentWrite(), "200": { description: "A replay of an entry already stored.", @@ -319,7 +299,6 @@ export const lifeCircumstancesPaths: NonNullable = { }, }, ...stdResponses, - ...notImplementedResponse, }, }, }, @@ -330,6 +309,7 @@ export const lifeCircumstancesPaths: NonNullable = { description: "Soft delete. 404 `anamnesis.auditC.notFound`.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "The removed entry.", content: { @@ -349,7 +329,6 @@ export const lifeCircumstancesPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, - ...notImplementedResponse, }, }, }, diff --git a/src/lib/profile/country-label.ts b/src/lib/profile/country-label.ts new file mode 100644 index 000000000..c6258a74d --- /dev/null +++ b/src/lib/profile/country-label.ts @@ -0,0 +1,26 @@ +/** + * Country names in the app languages, for the source lines of the life + * circumstances (screening hints, AUDIT-C cut-offs): a rule always says whose + * rule it is, in the language of the account reading it. + * + * Client-safe, no imports. + */ +export type SourceCountry = "DE" | "KR" | "US"; + +const NAMES: Readonly>>> = + { + de: { DE: "Deutschland", KR: "Südkorea", US: "USA" }, + en: { DE: "Germany", KR: "South Korea", US: "United States" }, + es: { DE: "Alemania", KR: "Corea del Sur", US: "Estados Unidos" }, + fr: { DE: "Allemagne", KR: "Corée du Sud", US: "États-Unis" }, + it: { DE: "Germania", KR: "Corea del Sud", US: "Stati Uniti" }, + pl: { DE: "Niemcy", KR: "Korea Południowa", US: "Stany Zjednoczone" }, + ko: { DE: "독일", KR: "대한민국", US: "미국" }, + }; + +export function countryLabel( + country: SourceCountry, + locale: string | null | undefined, +): string { + return (NAMES[locale ?? "en"] ?? NAMES.en)[country]; +} diff --git a/src/lib/profile/health-facts.ts b/src/lib/profile/health-facts.ts index c2201a961..df645e2c4 100644 --- a/src/lib/profile/health-facts.ts +++ b/src/lib/profile/health-facts.ts @@ -11,6 +11,12 @@ import { type HealthProfileFactKind, type HealthProfileFactValue, } from "@/lib/validations/health-profile-facts"; +import { + parseLifeFactValue, + type LifeFactKind, + type LifeFactValue, + type StoredFactKind, +} from "@/lib/validations/life-circumstances"; export interface HealthProfileFactRevisionDto { id: string; @@ -26,7 +32,7 @@ export interface HealthProfileFactRevisionDto { interface FactRevisionRow { id: string; - kind: HealthProfileFactKind; + kind: StoredFactKind; valueEncrypted: Uint8Array; validFrom: Date; validUntil: Date | null; @@ -42,7 +48,7 @@ export interface HealthProfileFactsPayload { export interface RemovedHealthProfileFactDto { id: string; - kind: HealthProfileFactKind; + kind: StoredFactKind; removedAt: string; } @@ -89,10 +95,11 @@ export function decryptHealthProfileFactValue( export function toHealthProfileFactDto( row: FactRevisionRow, ): HealthProfileFactRevisionDto { - const decrypted = decryptHealthProfileFactValue(row.kind, row.valueEncrypted); + const kind = row.kind as HealthProfileFactKind; + const decrypted = decryptHealthProfileFactValue(kind, row.valueEncrypted); return { id: row.id, - kind: row.kind, + kind, value: decrypted.value, unreadable: decrypted.unreadable, validFrom: row.validFrom.toISOString(), @@ -106,8 +113,11 @@ export function toHealthProfileFactDto( export async function readHealthProfileFacts( userId: string, ): Promise { + // The three closed kinds only. The table also holds the life-circumstance + // kinds since v1.43, and older app builds decode this list against exactly + // three values; those kinds are read through the life-circumstances route. const rows = (await prisma.healthProfileFactRevision.findMany({ - where: { userId }, + where: { userId, kind: { in: [...HEALTH_PROFILE_FACT_KINDS] } }, orderBy: [{ kind: "asc" }, { validFrom: "desc" }], select: { id: true, @@ -137,43 +147,165 @@ export async function readHealthProfileFacts( return { current, history }; } +const FACT_ROW_SELECT = { + id: true, + kind: true, + valueEncrypted: true, + validFrom: true, + validUntil: true, + provenance: true, + supersededByRevisionId: true, + createdAt: true, +} as const; + +/** The plaintext a fact's ciphertext seals: the closed value, or the life value as JSON. */ +function sealFactValue(value: string | LifeFactValue): Uint8Array { + return encryptToBytes( + typeof value === "string" ? value : JSON.stringify(value), + ); +} + +/** + * Open a life-circumstance value and check it against its kind's schema. + * Fail-closed like the closed kinds: ciphertext this host cannot open, or a + * value that no longer fits, reads as `unreadable` and never as an answer. + */ +export function decryptLifeFactValue( + kind: LifeFactKind, + payload: Uint8Array, +): { value: LifeFactValue | null; unreadable: boolean } { + try { + const value = parseLifeFactValue( + kind, + JSON.parse(decryptFromBytes(payload)), + ); + if (value) return { value, unreadable: false }; + getEvent()?.addWarning(`health profile fact has an invalid ${kind} value`); + } catch (error) { + getEvent()?.addWarning( + `health profile fact decrypt failed (${kind}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return { value: null, unreadable: true }; +} + +/** + * The instant a new answer starts. A requested day later than now is not an + * answer yet and starts now; one earlier than the end of the kind's previous + * answer would overlap that interval and starts at its end instead, so the + * history stays a sequence of half-open intervals. + */ +async function resolveStart( + tx: Pick, + userId: string, + kind: StoredFactKind, + requested: Date | undefined, + now: Date, +): Promise { + if (!requested || requested.getTime() >= now.getTime()) return now; + const previous = await tx.healthProfileFactRevision.findFirst({ + where: { userId, kind, validUntil: { not: null } }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + const floor = previous?.validUntil?.getTime() ?? Number.NEGATIVE_INFINITY; + return new Date(Math.max(requested.getTime(), floor)); +} + +async function createFactRevision( + userId: string, + kind: StoredFactKind, + value: string | LifeFactValue, + now: Date, + validFrom?: Date, +): Promise { + return prisma.$transaction(async (tx) => { + const start = await resolveStart(tx, userId, kind, validFrom, now); + const row = (await tx.healthProfileFactRevision.create({ + data: { + userId, + kind, + valueEncrypted: sealFactValue(value), + validFrom: start, + provenance: "USER_REPORTED", + }, + select: FACT_ROW_SELECT, + })) as FactRevisionRow; + await invalidateHealthProfileFactConsumers(userId, tx); + return row; + }); +} + export async function createHealthProfileFact( userId: string, kind: HealthProfileFactKind, value: HealthProfileFactValue, now: Date = new Date(), + validFrom?: Date, ): Promise { - return prisma.$transaction(async (tx) => { - const row = (await tx.healthProfileFactRevision.create({ - data: { - userId, - kind, - valueEncrypted: encryptToBytes(value), - validFrom: now, - provenance: "USER_REPORTED", - }, - select: { - id: true, - kind: true, - valueEncrypted: true, - validFrom: true, - validUntil: true, - provenance: true, - supersededByRevisionId: true, - createdAt: true, - }, - })) as FactRevisionRow; - await invalidateHealthProfileFactConsumers(userId, tx); - return toHealthProfileFactDto(row); - }); + return toHealthProfileFactDto( + await createFactRevision(userId, kind, value, now, validFrom), + ); } -export async function correctHealthProfileFact( +/** A life-circumstance revision as the life-circumstances read and the fact writes answer it. */ +export interface LifeFactRevisionDto { + id: string; + kind: "SMOKING_STATUS" | "SHIFT_SCHEDULE" | LifeFactKind; + value: string | LifeFactValue | null; + unreadable: boolean; + validFrom: string; + validUntil: null; + provenance: "USER_REPORTED" | "USER_CORRECTION"; + createdAt: string; +} + +export function toLifeFactRevisionDto(row: { + id: string; + kind: string; + valueEncrypted: Uint8Array; + validFrom: Date; + provenance: "USER_REPORTED" | "USER_CORRECTION"; + createdAt: Date; +}): LifeFactRevisionDto { + const kind = row.kind as LifeFactRevisionDto["kind"]; + const decrypted = + kind === "SMOKING_STATUS" || kind === "SHIFT_SCHEDULE" + ? decryptHealthProfileFactValue(kind, row.valueEncrypted) + : decryptLifeFactValue(kind, row.valueEncrypted); + return { + id: row.id, + kind, + value: decrypted.value, + unreadable: decrypted.unreadable, + validFrom: row.validFrom.toISOString(), + validUntil: null, + provenance: row.provenance, + createdAt: row.createdAt.toISOString(), + }; +} + +export async function createLifeFact( + userId: string, + kind: LifeFactKind, + value: LifeFactValue, + now: Date = new Date(), + validFrom?: Date, +): Promise { + return toLifeFactRevisionDto( + await createFactRevision(userId, kind, value, now, validFrom), + ); +} + +async function correctFactRevision( userId: string, revisionId: string, - value: HealthProfileFactValue, - now: Date = new Date(), -): Promise { + value: string | LifeFactValue, + now: Date, + validFrom?: Date, +): Promise { return prisma.$transaction(async (tx) => { const current = (await tx.healthProfileFactRevision.findFirst({ where: { @@ -182,21 +314,18 @@ export async function correctHealthProfileFact( validUntil: null, supersededByRevisionId: null, }, - select: { - id: true, - kind: true, - valueEncrypted: true, - validFrom: true, - validUntil: true, - provenance: true, - supersededByRevisionId: true, - createdAt: true, - }, + select: FACT_ROW_SELECT, })) as FactRevisionRow | null; if (!current) return null; + // The successor starts at the requested day when there is one, never + // later than now and never at or before the revision it replaces. + const requested = Math.min( + validFrom?.getTime() ?? now.getTime(), + now.getTime(), + ); const effectiveAt = new Date( - Math.max(now.getTime(), current.validFrom.getTime() + 1), + Math.max(requested, current.validFrom.getTime() + 1), ); const successorId = randomUUID(); const closed = await tx.healthProfileFactRevision.updateMany({ @@ -215,20 +344,11 @@ export async function correctHealthProfileFact( id: successorId, userId, kind: current.kind, - valueEncrypted: encryptToBytes(value), + valueEncrypted: sealFactValue(value), validFrom: effectiveAt, provenance: "USER_CORRECTION", }, - select: { - id: true, - kind: true, - valueEncrypted: true, - validFrom: true, - validUntil: true, - provenance: true, - supersededByRevisionId: true, - createdAt: true, - }, + select: FACT_ROW_SELECT, })) as FactRevisionRow; await tx.healthProfileFactRevision.update({ @@ -237,10 +357,44 @@ export async function correctHealthProfileFact( }); await invalidateHealthProfileFactConsumers(userId, tx); - return toHealthProfileFactDto(successor); + return successor; }); } +export async function correctHealthProfileFact( + userId: string, + revisionId: string, + value: HealthProfileFactValue, + now: Date = new Date(), + validFrom?: Date, +): Promise { + const row = await correctFactRevision( + userId, + revisionId, + value, + now, + validFrom, + ); + return row ? toHealthProfileFactDto(row) : null; +} + +export async function correctLifeFact( + userId: string, + revisionId: string, + value: LifeFactValue, + now: Date = new Date(), + validFrom?: Date, +): Promise { + const row = await correctFactRevision( + userId, + revisionId, + value, + now, + validFrom, + ); + return row ? toLifeFactRevisionDto(row) : null; +} + /** * Close the current effective interval without deleting or superseding its * revision. The closed row remains in dated history, while every current-value @@ -282,7 +436,7 @@ export async function removeHealthProfileFact( return { id: current.id, - kind: current.kind as HealthProfileFactKind, + kind: current.kind as StoredFactKind, removedAt: effectiveAt.toISOString(), }; }); diff --git a/src/lib/profile/life-circumstances.ts b/src/lib/profile/life-circumstances.ts new file mode 100644 index 000000000..28d71b46a --- /dev/null +++ b/src/lib/profile/life-circumstances.ts @@ -0,0 +1,617 @@ +/** + * v1.43 (#614) — the life circumstances of a record: the read model behind + * `GET /api/anamnesis/life-circumstances`, the AI switches, the "still true" + * confirmations and the AUDIT-C entries. + * + * The answers are fact revisions (`HealthProfileFactRevision`, sealed values, + * dated, corrected by a successor) and AUDIT-C entries + * (`MentalHealthAssessment` with `instrument = AUDIT_C`, sealed item answers, + * soft delete). Everything a client shows is resolved here: pack-years, + * minutes per week, free weekdays, screening hints, the AUDIT-C cut-off, and + * the instrument wording in the account language. + * + * Server-only. + */ +import { Prisma } from "@/generated/prisma/client"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { deriveAgeYears } from "@/lib/ai/coach/about-me"; +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { + auditCCutoff, + auditCInstrument, + auditCTotal, + isValidatedAuditCLocale, + normaliseAuditCLocale, + type AuditCCutoff, + type AuditCInstrument, +} from "@/lib/mental-health/audit-c"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { + decryptLifeFactValue, + invalidateHealthProfileFactConsumers, + toLifeFactRevisionDto, + type LifeFactRevisionDto, +} from "@/lib/profile/health-facts"; +import { + LIFE_SECTIONS, + activityMinutesPerWeek, + computePackYears, + DEFAULT_FREE_WEEKDAYS, + isReviewDue, + screeningHints, + type FreeWeekdaysSpan, + type PackYears, + type ScreeningHint, +} from "@/lib/profile/life-derived"; +import { + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + type StoredHealthProfileAiSection, +} from "@/lib/validations/health-profile-facts"; +import { + LIFE_AI_SECTIONS, + LIFE_SECTION_KEYS, + type ActivityHabitValue, + type FreeWeekdaysValue, + type LifeAiSection, + type LifeSectionKey, + type TobaccoDetailValue, +} 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; + items: [number, number, number]; + totalScore: number; + locale: string; + cutoff: AuditCCutoff | null; + aboveCutoff: boolean | null; + validatedLocale: boolean; +} + +export interface LifeCircumstancesDto { + sections: Array<{ + key: LifeSectionKey; + aiSection: Exclude; + aiIncluded: boolean; + lastConfirmedAt: string | null; + reviewDue: boolean; + facts: LifeFactRevisionDto[]; + }>; + extraAiSections: { SHIFT_SCHEDULE: boolean }; + derived: { + packYears: PackYears; + activityMinutesPerWeek: { + value: number | null; + meetsWho150: boolean | null; + measuredStepsAvailable: boolean; + }; + freeWeekdays: { days: number[]; source: "fact" | "default_weekend" }; + screeningHints: ScreeningHint[]; + }; + auditC: { + latest: AuditCResult | null; + history: Array<{ id: string; takenAt: string; totalScore: number }>; + offerDue: boolean; + instrument: AuditCInstrument; + }; + takeOver: Array<{ + field: "conditions" | "allergies"; + freeTextPresent: boolean; + structuredCount: number; + available: boolean; + }>; +} + +/** The kinds the read route shows: the six new ones and the two closed ones living in a section. */ +const SECTION_KINDS = [ + ...new Set(LIFE_SECTIONS.flatMap((section) => section.kinds)), +]; + +function storedSections( + stored: readonly string[] | null | undefined, +): Set { + return new Set( + (stored ?? + DEFAULT_HEALTH_PROFILE_AI_SECTIONS) as StoredHealthProfileAiSection[], + ); +} + +/** The stored confirmations, read leniently: a stamp that does not parse counts as none. */ +function confirmations( + value: Prisma.JsonValue | null | undefined, +): Map { + const out = new Map(); + if (!value || typeof value !== "object" || Array.isArray(value)) return out; + for (const [key, stamp] of Object.entries(value)) { + if (typeof stamp !== "string") continue; + const at = new Date(stamp); + if (!Number.isNaN(at.getTime())) out.set(key, at); + } + return out; +} + +function latest(...dates: Array): Date | null { + let best: Date | null = null; + for (const date of dates) { + if (date && (!best || date > best)) best = date; + } + 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 }, + items: [number, number, number], + sex: string | null, + labelLocale: string, +): AuditCResult { + const cutoff = auditCCutoff(row.locale, sex, labelLocale); + return { + id: row.id, + takenAt: row.takenAt.toISOString(), + items, + totalScore: row.totalScore, + locale: row.locale, + cutoff, + aboveCutoff: cutoff ? row.totalScore >= cutoff.value : null, + validatedLocale: isValidatedAuditCLocale(row.locale), + }; +} + +/** The dated free-weekday answers of a record, oldest first, for the night-by-night day type. */ +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, + }, + ] + : []; + }); +} + +/** Assemble the whole read model for one record. */ +export async function buildLifeCircumstances( + userId: string, + now: Date = new Date(), +): Promise { + const [ + user, + profile, + factRows, + auditRows, + steps, + illnessCount, + allergyCount, + illnessOn, + ] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { dateOfBirth: true, gender: true, locale: true }, + }), + prisma.userHealthProfile.findUnique({ + where: { userId }, + select: { + aiIncludedSections: true, + lifeConfirmedJson: true, + conditionsEncrypted: true, + allergiesEncrypted: true, + }, + }), + prisma.healthProfileFactRevision.findMany({ + where: { + userId, + kind: { in: SECTION_KINDS }, + validUntil: null, + supersededByRevisionId: null, + }, + select: { + id: true, + kind: true, + valueEncrypted: true, + validFrom: true, + provenance: true, + createdAt: true, + }, + }), + 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, + responsesEncrypted: true, + }, + }), + prisma.measurement.findFirst({ + where: { + userId, + type: "ACTIVITY_STEPS", + deletedAt: null, + measuredAt: { + gte: new Date(now.getTime() - MEASURED_STEPS_DAYS * MS_PER_DAY), + }, + }, + select: { id: true }, + }), + prisma.illnessEpisode.count({ + where: { userId, lifecycle: "CHRONIC_ONGOING", deletedAt: null }, + }), + prisma.allergy.count({ + where: { userId, status: "ACTIVE", deletedAt: null }, + }), + isModuleEnabled(userId, "illness"), + ]); + + const locale = user?.locale ?? "en"; + const sex = user?.gender ?? null; + const included = storedSections(profile?.aiIncludedSections); + const confirmed = confirmations(profile?.lifeConfirmedJson); + const facts = factRows.map(toLifeFactRevisionDto); + 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; + } + } + const newestAuditC = auditRows[0]?.takenAt ?? null; + + const sections = LIFE_SECTIONS.map((section) => { + const sectionFacts = facts.filter((fact) => + (section.kinds as readonly string[]).includes(fact.kind), + ); + const lastAnswer = latest( + ...sectionFacts.map((fact) => new Date(fact.createdAt)), + section.key === "alcohol" ? newestAuditC : null, + ); + const lastConfirmed = latest(lastAnswer, confirmed.get(section.key)); + return { + key: section.key, + aiSection: section.aiSection, + aiIncluded: included.has(section.aiSection), + lastConfirmedAt: lastConfirmed?.toISOString() ?? null, + reviewDue: isReviewDue(lastConfirmed, now), + facts: sectionFacts, + }; + }); + + const smokingStatus = factOf("SMOKING_STATUS"); + const tobacco = factOf("TOBACCO_DETAIL"); + const activity = factOf("ACTIVITY_HABIT"); + const freeDays = factOf("FREE_WEEKDAYS"); + const packYears = computePackYears(smokingStatus, tobacco, now); + const minutes = activityMinutesPerWeek( + activity?.moderateDaysPerWeek ?? null, + activity?.moderateMinutesPerDay ?? null, + ); + + return { + sections, + extraAiSections: { SHIFT_SCHEDULE: included.has("SHIFT_SCHEDULE") }, + derived: { + packYears, + activityMinutesPerWeek: { + ...minutes, + measuredStepsAvailable: steps !== null, + }, + freeWeekdays: freeDays + ? { days: [...freeDays.days].sort((a, b) => a - b), source: "fact" } + : { days: [...DEFAULT_FREE_WEEKDAYS], source: "default_weekend" }, + screeningHints: screeningHints({ + ageYears: deriveAgeYears(user?.dateOfBirth ?? null, now), + sex, + smokingStatus, + detail: tobacco, + packYears, + locale, + now, + }), + }, + auditC: { + latest: latestAuditC, + history: auditRows.map((row) => ({ + id: row.id, + takenAt: row.takenAt.toISOString(), + totalScore: row.totalScore, + })), + offerDue: + newestAuditC === null || + now.getTime() - newestAuditC.getTime() >= + AUDIT_C_OFFER_AFTER_DAYS * MS_PER_DAY, + instrument: auditCInstrument(locale), + }, + takeOver: [ + { + field: "conditions", + freeTextPresent: profile?.conditionsEncrypted != null, + structuredCount: illnessCount, + available: illnessOn, + }, + { + field: "allergies", + freeTextPresent: profile?.allergiesEncrypted != null, + structuredCount: allergyCount, + available: true, + }, + ], + }; +} + +// ── AI switches ────────────────────────────────────────────────────────────── + +export type LifeAiSectionsOutcome = + | { conflict: true } + | { + conflict: false; + aiSections: Record; + updatedAt: string; + }; + +function lifeSwitches( + stored: Set, +): Record { + return Object.fromEntries( + LIFE_AI_SECTIONS.map((section) => [section, stored.has(section)]), + ) as Record; +} + +/** + * Switch life-circumstance sections in or out of the AI inclusion list. Only + * the named sections change; the other stored values, the five about-me + * sections among them, stay as they are. `base` is the about-me token + * (`UserHealthProfile.updatedAt`): a stale one is a conflict, as on about-me. + * Any change clears the cached briefing in the same transaction, because it + * may quote a section that just left. + */ +export async function updateLifeAiSections( + userId: string, + changes: Partial>, + base?: Date, +): Promise { + return prisma.$transaction(async (tx) => { + const row = await tx.userHealthProfile.findUnique({ + where: { userId }, + select: { aiIncludedSections: true, updatedAt: true }, + }); + if (row && base && row.updatedAt.getTime() !== base.getTime()) { + return { conflict: true as const }; + } + const next = storedSections(row?.aiIncludedSections); + for (const [section, on] of Object.entries(changes)) { + if (on) next.add(section as StoredHealthProfileAiSection); + else next.delete(section as StoredHealthProfileAiSection); + } + const aiIncludedSections = [...next]; + const saved = row + ? await tx.userHealthProfile.update({ + where: { userId }, + data: { aiIncludedSections }, + select: { updatedAt: true }, + }) + : await tx.userHealthProfile.create({ + data: { userId, aiIncludedSections }, + select: { updatedAt: true }, + }); + await invalidateHealthProfileFactConsumers(userId, tx); + return { + conflict: false as const, + aiSections: lifeSwitches(next), + updatedAt: saved.updatedAt.toISOString(), + }; + }); +} + +/** Store "still true" for one section: the instant only, never a revision. */ +export async function confirmLifeSection( + userId: string, + section: LifeSectionKey, + now: Date = new Date(), +): Promise { + const stamp = now.toISOString(); + await prisma.$transaction(async (tx) => { + const row = await tx.userHealthProfile.findUnique({ + where: { userId }, + select: { lifeConfirmedJson: true }, + }); + const next: Record = {}; + for (const [key, at] of confirmations(row?.lifeConfirmedJson)) { + if ((LIFE_SECTION_KEYS as readonly string[]).includes(key)) { + next[key] = at.toISOString(); + } + } + next[section] = stamp; + await tx.userHealthProfile.upsert({ + where: { userId }, + create: { userId, lifeConfirmedJson: next }, + update: { lifeConfirmedJson: next }, + }); + }); + return stamp; +} + +// ── AUDIT-C entries ────────────────────────────────────────────────────────── + +/** + * The client's `externalId` is stored with this prefix. The screeners share + * the table and a per-user unique index on `external_id`, so a sync client + * that happened to reuse an id across the two surfaces would otherwise get + * the wrong record back as its "existing" entry. + */ +const AUDIT_C_EXTERNAL_ID_PREFIX = "audit-c:"; + +export interface AuditCCreateInput { + items: [number, number, number]; + takenAt?: Date; + tz?: string; + locale: string; + source: "WEB" | "IOS"; + externalId?: string; +} + +const AUDIT_C_SELECT = { + id: true, + takenAt: true, + totalScore: true, + locale: true, + responsesEncrypted: true, +} as const; + +/** + * Store and score one entry. A repeat `externalId` returns the stored entry + * (`replayed: true`) and writes nothing. + */ +export async function createAuditC( + userId: string, + input: AuditCCreateInput, + now: Date = new Date(), +): Promise<{ result: AuditCResult; replayed: boolean }> { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { gender: true, locale: true }, + }); + const sex = user?.gender ?? null; + const labelLocale = user?.locale ?? "en"; + const externalId = input.externalId + ? `${AUDIT_C_EXTERNAL_ID_PREFIX}${input.externalId}` + : null; + + const replay = async () => { + if (!externalId) return null; + const existing = await prisma.mentalHealthAssessment.findFirst({ + where: { userId, instrument: "AUDIT_C", externalId }, + select: AUDIT_C_SELECT, + }); + if (!existing) return null; + const opened = openAuditCResponses(existing.responsesEncrypted); + return { + result: toAuditCResult( + existing, + opened?.items ?? input.items, + sex, + labelLocale, + ), + replayed: true, + }; + }; + + const prior = await replay(); + if (prior) return prior; + + const locale = normaliseAuditCLocale(input.locale); + const responses: AuditCResponses = { schema: 1, items: input.items }; + try { + const row = await prisma.mentalHealthAssessment.create({ + data: { + userId, + instrument: "AUDIT_C", + locale, + responsesEncrypted: encryptToBytes(JSON.stringify(responses)), + totalScore: auditCTotal(input.items), + // Not banded: the cut-off depends on the profile's sex at read time, + // so nothing derived from it is frozen into the row. + severityBand: "unbanded", + takenAt: input.takenAt ?? now, + tz: input.tz ?? null, + source: input.source, + externalId, + }, + select: AUDIT_C_SELECT, + }); + await invalidateHealthProfileFactConsumers(userId, prisma); + return { + result: toAuditCResult(row, input.items, sex, labelLocale), + replayed: false, + }; + } catch (error) { + // A concurrent replay of the same externalId lost the unique race. + if ( + externalId && + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + const winner = await replay(); + if (winner) return winner; + } + throw error; + } +} + +/** 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() }; +} diff --git a/src/lib/profile/life-derived.ts b/src/lib/profile/life-derived.ts new file mode 100644 index 000000000..6f8bfa317 --- /dev/null +++ b/src/lib/profile/life-derived.ts @@ -0,0 +1,378 @@ +/** + * v1.43 (#614) — the pure half of the life circumstances: which kinds belong + * to which section, and every number the read route derives from the answers + * (pack-years, activity minutes, free weekdays, the screening hints). The + * server resolves these once; the web and the app render them as delivered. + * + * Nothing here is ever inferred from anything but the person's own answers + * and the two profile columns it needs (birth date and sex): no smoking status + * from mood tags, no activity from steps. A missing input is an honest + * "missing", never a zero. + * + * Client-safe: no database, no crypto. + */ +import { countryLabel } from "@/lib/profile/country-label"; +import type { + LifeAiSection, + LifeFactKind, + LifeSectionKey, + TobaccoDetailValue, +} from "@/lib/validations/life-circumstances"; + +/** The kinds a section shows, in display order. Alcohol is AUDIT-C, not a fact. */ +export const LIFE_SECTIONS: ReadonlyArray<{ + key: LifeSectionKey; + aiSection: Exclude; + kinds: ReadonlyArray<"SMOKING_STATUS" | "SHIFT_SCHEDULE" | LifeFactKind>; +}> = [ + { + key: "tobacco", + aiSection: "SMOKING_STATUS", + kinds: ["SMOKING_STATUS", "TOBACCO_DETAIL"], + }, + { key: "alcohol", aiSection: "ALCOHOL_PATTERN", kinds: [] }, + { key: "activity", aiSection: "ACTIVITY", kinds: ["ACTIVITY_HABIT"] }, + { + key: "work", + aiSection: "WORK", + kinds: ["SHIFT_SCHEDULE", "WORK_CONTEXT", "FREE_WEEKDAYS"], + }, + { key: "habits", aiSection: "HABITS", kinds: ["HABITS"] }, + { key: "goals", aiSection: "GOALS", kinds: ["HEALTH_GOALS"] }, +]; + +/** + * The AI section each life kind is released under. A kind reaches a prompt + * only when this section is in the profile's inclusion list; otherwise it is + * never even decrypted for one. + */ +export const LIFE_KIND_AI_SECTION: Readonly< + Record +> = { + TOBACCO_DETAIL: "SMOKING_STATUS", + ACTIVITY_HABIT: "ACTIVITY", + WORK_CONTEXT: "WORK", + FREE_WEEKDAYS: "WORK", + HABITS: "HABITS", + HEALTH_GOALS: "GOALS", +}; + +/** A section asks "still true?" once its newest answer or confirmation is this old. */ +export const REVIEW_AFTER_DAYS = 365; +const MS_PER_DAY = 86_400_000; + +/** UTC midnight of a `YYYY-MM-DD` calendar day. */ +export function dayStartUtc(day: string): Date { + return new Date(`${day}T00:00:00.000Z`); +} + +/** Whether an instant is at least `REVIEW_AFTER_DAYS` before `now`. */ +export function isReviewDue(at: Date | null, now: Date): boolean { + return ( + at !== null && + now.getTime() - at.getTime() >= REVIEW_AFTER_DAYS * MS_PER_DAY + ); +} + +// ── Pack-years ─────────────────────────────────────────────────────────────── + +export interface PackYears { + value: number | null; + status: "computed" | "not_applicable" | "missing_inputs"; +} + +/** + * Cigarettes per day / 20 × years smoked, to one decimal. Years run from the + * start year to the quit year for a former smoker, to the current year + * otherwise. Only with both inputs; never estimated. + */ +export function computePackYears( + smokingStatus: string | null, + detail: TobaccoDetailValue | null, + now: Date, +): PackYears { + if (smokingStatus === "NEVER") + return { value: null, status: "not_applicable" }; + if ( + detail && + detail.products.length > 0 && + !detail.products.includes("cigarettes") + ) { + return { value: null, status: "not_applicable" }; + } + if ( + !detail || + detail.cigarettesPerDay === null || + detail.startYear === null + ) { + return { value: null, status: "missing_inputs" }; + } + const quitYear = detail.quitMonth + ? Number(detail.quitMonth.slice(0, 4)) + : null; + if (smokingStatus === "FORMER" && quitYear === null) { + return { value: null, status: "missing_inputs" }; + } + const endYear = + smokingStatus === "CURRENT" || quitYear === null + ? now.getUTCFullYear() + : quitYear; + const years = endYear - detail.startYear; + if (years < 0) return { value: null, status: "missing_inputs" }; + return { + value: Math.round((detail.cigarettesPerDay / 20) * years * 10) / 10, + status: "computed", + }; +} + +/** Whole years smoked, from the same inputs as pack-years; null when unknown. */ +export function yearsSmoked( + smokingStatus: string | null, + detail: TobaccoDetailValue | null, + now: Date, +): number | null { + if (!detail || detail.startYear === null) return null; + const quitYear = detail.quitMonth + ? Number(detail.quitMonth.slice(0, 4)) + : null; + const endYear = + smokingStatus === "CURRENT" || quitYear === null + ? now.getUTCFullYear() + : quitYear; + const years = endYear - detail.startYear; + return years >= 0 ? years : null; +} + +// ── Activity ───────────────────────────────────────────────────────────────── + +/** The Exercise Vital Sign product: days per week × minutes per day. */ +export function activityMinutesPerWeek( + daysPerWeek: number | null, + minutesPerDay: number | null, +): { value: number | null; meetsWho150: boolean | null } { + if (daysPerWeek === null || minutesPerDay === null) { + return { value: null, meetsWho150: null }; + } + const value = daysPerWeek * minutesPerDay; + return { value, meetsWho150: value >= 150 }; +} + +// ── Free weekdays ──────────────────────────────────────────────────────────── + +/** Saturday and Sunday, the calendar default when nobody said otherwise. */ +export const DEFAULT_FREE_WEEKDAYS: readonly number[] = [6, 7]; + +/** One dated answer to "which weekdays are free": `[validFrom, validUntil)`. */ +export interface FreeWeekdaysSpan { + validFrom: Date; + validUntil: Date | null; + days: readonly number[]; +} + +/** + * Day type of a night from its wake day (`YYYY-MM-DD`), read against the + * answer that was in force on that day. A night outside every answered span + * falls back to the weekend default, so a free-days answer given last month + * does not rewrite how last year's nights were read. + */ +export function dayTypeForNight( + night: string, + spans: readonly FreeWeekdaysSpan[], +): "work" | "free" { + const [y, m, d] = night.split("-").map(Number); + if (!y || !m || !d) return "work"; + const at = Date.UTC(y, m - 1, d, 12); + const jsDay = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0 = Sun + const isoDay = jsDay === 0 ? 7 : jsDay; + const span = spans.find( + (s) => + s.validFrom.getTime() <= at && + (s.validUntil === null || at < s.validUntil.getTime()), + ); + const free = span ? span.days : DEFAULT_FREE_WEEKDAYS; + return free.includes(isoDay) ? "free" : "work"; +} + +// ── Screening hints ────────────────────────────────────────────────────────── + +export type ScreeningRegion = "DE" | "US"; + +export interface ScreeningHint { + id: "lung_ldct" | "aaa_ultrasound"; + status: "suggested" | "not_eligible" | "unknown"; + reasonKeys: string[]; + region: ScreeningRegion; + sourceLabel: string; + sourceUrl: string; +} + +/** The region of the hints follows the account language: German gives the German rules. */ +export function screeningRegion( + locale: string | null | undefined, +): ScreeningRegion { + return locale === "de" ? "DE" : "US"; +} + +const SOURCES = { + lung_ldct: { + DE: { + label: + "G-BA, Beschluss vom 18.06.2025 zur Lungenkrebs-Früherkennung mittels Niedrigdosis-CT", + url: "https://www.g-ba.de/presse/pressemitteilungen-meldungen/1263/", + }, + US: { + label: "USPSTF, Lung Cancer: Screening (2021)", + url: "https://www.uspreventiveservicestaskforce.org/uspstf/recommendation/lung-cancer-screening", + }, + }, + aaa_ultrasound: { + DE: { + label: + "G-BA, Ultraschallscreening auf Bauchaortenaneurysmen (Beschluss vom 20.10.2016)", + url: "https://www.g-ba.de/institution/presse/pressemitteilungen/645/", + }, + US: { + label: "USPSTF, Abdominal Aortic Aneurysm: Screening (2019)", + url: "https://www.uspreventiveservicestaskforce.org/uspstf/recommendation/abdominal-aortic-aneurysm-screening", + }, + }, +} as const; + +export interface ScreeningInputs { + ageYears: number | null; + /** Profile sex as stored: MALE, FEMALE, OTHER or null. */ + sex: string | null; + smokingStatus: string | null; + detail: TobaccoDetailValue | null; + packYears: PackYears; + locale: string | null | undefined; + now: Date; +} + +function hint( + id: ScreeningHint["id"], + region: ScreeningRegion, + locale: string | null | undefined, + status: ScreeningHint["status"], + reasonKeys: string[], +): ScreeningHint { + const source = SOURCES[id][region]; + return { + id, + status, + reasonKeys, + region, + sourceLabel: `${source.label} (${countryLabel(region, locale)})`, + sourceUrl: source.url, + }; +} + +/** Whole years since the quit month, or null for a current smoker or without one. */ +function yearsSinceQuit( + detail: TobaccoDetailValue | null, + now: Date, +): number | null { + if (!detail?.quitMonth) return null; + const [y, m] = detail.quitMonth.split("-").map(Number); + const months = (now.getUTCFullYear() - y) * 12 + (now.getUTCMonth() + 1 - m); + return Math.max(0, Math.floor(months / 12)); +} + +/** + * Low-dose CT for lung cancer. + * DE (G-BA, 18.06.2025): 50 to 75 years, at least 25 years smoked, at least + * 15 pack-years, smoking now or stopped at most 10 years ago. + * US (USPSTF 2021): 50 to 80 years, at least 20 pack-years, smoking now or + * stopped within the past 15 years. + */ +function lungHint( + input: ScreeningInputs, + region: ScreeningRegion, +): ScreeningHint { + const rule = + region === "DE" + ? { minAge: 50, maxAge: 75, minPackYears: 15, minYears: 25, maxQuit: 10 } + : { + minAge: 50, + maxAge: 80, + minPackYears: 20, + minYears: null, + maxQuit: 15, + }; + if (input.smokingStatus === "NEVER") { + return hint("lung_ldct", region, input.locale, "not_eligible", [ + "never_smoked", + ]); + } + const failed: string[] = []; + const missing: string[] = []; + if (input.ageYears === null) missing.push("missing_birth_date"); + else if (input.ageYears < rule.minAge || input.ageYears > rule.maxAge) { + failed.push("age_out_of_range"); + } + if (input.smokingStatus === null) missing.push("missing_smoking_status"); + if (input.packYears.status === "not_applicable") failed.push("no_cigarettes"); + else if (input.packYears.value === null) + missing.push("missing_smoking_history"); + else if (input.packYears.value < rule.minPackYears) { + failed.push( + region === "DE" ? "pack_years_below_15" : "pack_years_below_20", + ); + } + if (rule.minYears !== null) { + const years = yearsSmoked(input.smokingStatus, input.detail, input.now); + if (years === null) { + if (!missing.includes("missing_smoking_history")) + missing.push("missing_smoking_history"); + } else if (years < rule.minYears) failed.push("smoked_years_below_25"); + } + if (input.smokingStatus === "FORMER") { + const since = yearsSinceQuit(input.detail, input.now); + if (since === null) missing.push("missing_quit_date"); + else if (since > rule.maxQuit) { + failed.push( + region === "DE" ? "quit_over_10_years" : "quit_over_15_years", + ); + } + } + if (failed.length > 0) + return hint("lung_ldct", region, input.locale, "not_eligible", failed); + if (missing.length > 0) + return hint("lung_ldct", region, input.locale, "unknown", missing); + return hint("lung_ldct", region, input.locale, "suggested", []); +} + +/** + * Ultrasound for an abdominal aortic aneurysm. + * DE (G-BA): men from 65, once. + * US (USPSTF 2019): men 65 to 75 who have ever smoked. + */ +function aaaHint( + input: ScreeningInputs, + region: ScreeningRegion, +): ScreeningHint { + const failed: string[] = []; + const missing: string[] = []; + if (input.sex === null) missing.push("missing_sex"); + else if (input.sex !== "MALE") failed.push("sex_not_male"); + if (input.ageYears === null) missing.push("missing_birth_date"); + else if (input.ageYears < 65 || (region === "US" && input.ageYears > 75)) { + failed.push("age_out_of_range"); + } + if (region === "US") { + if (input.smokingStatus === "NEVER") failed.push("never_smoked"); + else if (input.smokingStatus === null) + missing.push("missing_smoking_status"); + } + if (failed.length > 0) + return hint("aaa_ultrasound", region, input.locale, "not_eligible", failed); + if (missing.length > 0) + return hint("aaa_ultrasound", region, input.locale, "unknown", missing); + return hint("aaa_ultrasound", region, input.locale, "suggested", []); +} + +/** Both hints, always in this order. A hint, never a reminder: nothing is scheduled. */ +export function screeningHints(input: ScreeningInputs): ScreeningHint[] { + const region = screeningRegion(input.locale); + return [lungHint(input, region), aaaHint(input, region)]; +} diff --git a/src/lib/validations/__tests__/health-profile-facts.test.ts b/src/lib/validations/__tests__/health-profile-facts.test.ts index f01817334..91bae084e 100644 --- a/src/lib/validations/__tests__/health-profile-facts.test.ts +++ b/src/lib/validations/__tests__/health-profile-facts.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS, HEALTH_PROFILE_AI_SECTIONS, - removedHealthProfileFactSchema, } from "../health-profile-facts"; +import { removedFactSchema as removedHealthProfileFactSchema } from "../life-circumstances"; describe("health profile AI inclusion defaults", () => { it("keeps the legacy opt-in list independent from accepted section values", () => { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 4996ed7d4..ea820bf84 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -77,10 +77,12 @@ import { } from "@/generated/prisma/enums"; import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS, - healthProfileAiSectionSchema, - healthProfileFactKindSchema, - isHealthProfileFactValue, + storedHealthProfileAiSectionSchema, } from "@/lib/validations/health-profile-facts"; +import { + STORED_FACT_KINDS, + isStoredFactPlaintext, +} from "@/lib/validations/life-circumstances"; import { advanceDirectiveStatusSchema, emergencyBloodTypeSchema, @@ -830,8 +832,10 @@ const healthProfileBackupSchema = z conditions: z.string().nullable().default(null), allergies: z.string().nullable().default(null), coachFocus: z.string().nullable().default(null), + // Every stored section, the four life-circumstance ones included (v1.43): + // a file written by an account that opted one in must restore it opted in. aiIncludedSections: z - .array(healthProfileAiSectionSchema) + .array(storedHealthProfileAiSectionSchema) .default([...DEFAULT_HEALTH_PROFILE_AI_SECTIONS]), // v1.43 (#614) — section confirmations, section key to ISO instant. // Carried as stored; the read route judges each stamp, so an unreadable @@ -867,7 +871,7 @@ const healthProfileBackupSchema = z const healthProfileFactBackupSchema = z .object({ id: z.string().min(1), - kind: healthProfileFactKindSchema, + kind: z.enum(STORED_FACT_KINDS), value: z.string().nullable().default(null), valueEncrypted: base64BytesSchema.optional(), validFrom: isoDateTime, @@ -881,7 +885,7 @@ const healthProfileFactBackupSchema = z if (fact.valueEncrypted === undefined) { if ( fact.value === null || - !isHealthProfileFactValue(fact.kind, fact.value) + !isStoredFactPlaintext(fact.kind, fact.value) ) { ctx.addIssue({ code: "custom", diff --git a/src/lib/validations/health-profile-facts.ts b/src/lib/validations/health-profile-facts.ts index f2d7d26d9..b8bbf614c 100644 --- a/src/lib/validations/health-profile-facts.ts +++ b/src/lib/validations/health-profile-facts.ts @@ -27,6 +27,36 @@ export const DEFAULT_HEALTH_PROFILE_AI_SECTIONS = [ export const healthProfileAiSectionSchema = z.enum(HEALTH_PROFILE_AI_SECTIONS); +/** + * Every value the `ai_included_sections` column can hold: the eight above and + * the four life-circumstance sections (v1.43, #614). The eight-value schema is + * the wire form of `/api/coach/about-me`, which older app builds decode + * closed; this one is for the places that carry the column as stored, the + * backup file first among them, where dropping a value would silently take an + * opt-in back on restore. + */ +export const STORED_HEALTH_PROFILE_AI_SECTIONS = [ + ...HEALTH_PROFILE_AI_SECTIONS, + "ACTIVITY", + "WORK", + "HABITS", + "GOALS", +] as const; +export type StoredHealthProfileAiSection = + (typeof STORED_HEALTH_PROFILE_AI_SECTIONS)[number]; +export const storedHealthProfileAiSectionSchema = z.enum( + STORED_HEALTH_PROFILE_AI_SECTIONS, +); + +/** Narrow a stored section list to the eight the about-me wire carries. */ +export function aboutMeAiSections( + stored: readonly string[], +): HealthProfileAiSection[] { + return stored.filter((section): section is HealthProfileAiSection => + (HEALTH_PROFILE_AI_SECTIONS as readonly string[]).includes(section), + ); +} + export const SMOKING_STATUS_VALUES = ["NEVER", "FORMER", "CURRENT"] as const; export const ALCOHOL_PATTERN_VALUES = [ "NONE", @@ -74,25 +104,6 @@ export function isHealthProfileFactValue( return HEALTH_PROFILE_FACT_VALUES[kind][value] === true; } -export const healthProfileFactWriteSchema = z.discriminatedUnion("kind", [ - z.object({ - kind: z.literal("SMOKING_STATUS"), - value: z.enum(SMOKING_STATUS_VALUES), - }), - z.object({ - kind: z.literal("ALCOHOL_PATTERN"), - value: z.enum(ALCOHOL_PATTERN_VALUES), - }), - z.object({ - kind: z.literal("SHIFT_SCHEDULE"), - value: z.enum(SHIFT_SCHEDULE_VALUES), - }), -]); - -export const healthProfileFactCorrectionSchema = z.object({ - value: z.string().trim().min(1), -}); - export function validateCorrectedFactValue( kind: HealthProfileFactKind, value: string, @@ -120,9 +131,3 @@ export const healthProfileFactsResponseSchema = z.object({ }), history: z.array(healthProfileFactDtoSchema), }); - -export const removedHealthProfileFactSchema = z.object({ - id: z.string(), - kind: healthProfileFactKindSchema, - removedAt: z.iso.datetime({ offset: true }), -}); diff --git a/src/lib/validations/life-circumstances.ts b/src/lib/validations/life-circumstances.ts index 942c26743..ae5287b62 100644 --- a/src/lib/validations/life-circumstances.ts +++ b/src/lib/validations/life-circumstances.ts @@ -11,6 +11,13 @@ import { z } from "zod/v4"; import { assertStableExternalId } from "@/lib/validations/external-id"; +import { + ALCOHOL_PATTERN_VALUES, + HEALTH_PROFILE_FACT_KINDS, + SHIFT_SCHEDULE_VALUES, + SMOKING_STATUS_VALUES, + isHealthProfileFactValue, +} from "@/lib/validations/health-profile-facts"; // ── Fact kinds and their values ────────────────────────────────────────────── @@ -219,3 +226,162 @@ export const AUDIT_C_CUTOFF_SOURCES = [ "KNHANES_KR", "BRADLEY_2007", ] as const; + +// ── Writing facts through `/api/anamnesis/facts` ───────────────────────────── + +/** Every kind a fact revision row can carry: the three closed ones and the six above. */ +export const STORED_FACT_KINDS = [ + ...HEALTH_PROFILE_FACT_KINDS, + ...LIFE_FACT_KINDS, +] as const; +export type StoredFactKind = (typeof STORED_FACT_KINDS)[number]; + +export function isLifeFactKind(kind: string): kind is LifeFactKind { + return (LIFE_FACT_KINDS as readonly string[]).includes(kind); +} + +/** + * "Valid since", a calendar day. Optional: without it the answer holds from + * the moment it is saved. The web form sends the first of a month, because a + * month is as precise as anyone remembers when they stopped smoking. + */ +const validFromDay = z.iso + .date() + .optional() + .describe( + "The calendar day the answer holds from (UTC midnight). Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead.", + ); + +/** + * `POST /api/anamnesis/facts`. The three closed kinds keep their string value + * (older app builds send exactly that); the six life-circumstance kinds send + * their object value with `schema: 1`. + */ +export const factWriteSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("SMOKING_STATUS"), + value: z.enum(SMOKING_STATUS_VALUES), + validFrom: validFromDay, + }), + z.object({ + kind: z.literal("ALCOHOL_PATTERN"), + value: z.enum(ALCOHOL_PATTERN_VALUES), + validFrom: validFromDay, + }), + z.object({ + kind: z.literal("SHIFT_SCHEDULE"), + value: z.enum(SHIFT_SCHEDULE_VALUES), + validFrom: validFromDay, + }), + z.object({ + kind: z.literal("TOBACCO_DETAIL"), + value: tobaccoDetailValueSchema, + validFrom: validFromDay, + }), + z.object({ + kind: z.literal("ACTIVITY_HABIT"), + value: activityHabitValueSchema, + validFrom: validFromDay, + }), + z.object({ + kind: z.literal("WORK_CONTEXT"), + value: workContextValueSchema, + validFrom: validFromDay, + }), + z.object({ + kind: z.literal("FREE_WEEKDAYS"), + value: freeWeekdaysValueSchema, + validFrom: validFromDay, + }), + z.object({ + kind: z.literal("HABITS"), + value: habitsValueSchema, + validFrom: validFromDay, + }), + z.object({ + kind: z.literal("HEALTH_GOALS"), + value: healthGoalsValueSchema, + validFrom: validFromDay, + }), +]); +export type FactWriteInput = z.infer; + +/** + * `PATCH /api/anamnesis/facts/{id}`: the closed kinds take a string, the life + * kinds an object; which one the row needs is checked against the stored + * kind after the row is found. + */ +export const factCorrectionSchema = z.object({ + value: z.union([z.string().trim().min(1), z.record(z.string(), z.unknown())]), + validFrom: validFromDay, +}); + +/** Parse a life-circumstance value against its kind's schema; null when it does not fit. */ +export function parseLifeFactValue( + kind: LifeFactKind, + value: unknown, +): LifeFactValue | null { + const parsed = LIFE_FACT_VALUE_SCHEMAS[kind].safeParse(value); + return parsed.success ? (parsed.data as LifeFactValue) : null; +} + +export type TobaccoDetailValue = z.infer; +export type ActivityHabitValue = z.infer; +export type WorkContextValue = z.infer; +export type FreeWeekdaysValue = z.infer; +export type HabitsValue = z.infer; +export type HealthGoalsValue = z.infer; +export type LifeFactValue = + | TobaccoDetailValue + | ActivityHabitValue + | WorkContextValue + | FreeWeekdaysValue + | HabitsValue + | HealthGoalsValue; + +/** `DELETE /api/anamnesis/facts/{id}`: the revision whose interval was closed. */ +export const removedFactSchema = z.object({ + id: z.string(), + kind: z.enum(STORED_FACT_KINDS), + removedAt: z.iso.datetime({ offset: true }), +}); + +/** + * A life-circumstance revision as `/api/anamnesis/life-circumstances` and the + * fact writes answer it. `SMOKING_STATUS` and `SHIFT_SCHEDULE` ride along in + * their sections with their string value. + */ +export const lifeFactRevisionSchema = z.object({ + id: z.string(), + kind: z.enum(["SMOKING_STATUS", "SHIFT_SCHEDULE", ...LIFE_FACT_KINDS]), + value: z + .unknown() + .describe( + "`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`.", + ), + unreadable: z + .boolean() + .describe("The sealed value could not be opened on this host."), + validFrom: z.iso.datetime({ offset: true }), + validUntil: z.null(), + provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]), + createdAt: z.iso.datetime({ offset: true }), +}); + +/** + * Whether `raw` is a valid plaintext for a stored fact of `kind`: one of the + * closed values for the three original kinds, the JSON of a value that fits + * its schema for the life kinds. The portable backup carries exactly this + * plaintext, so its writer and its restore share this one check. + */ +export function isStoredFactPlaintext( + kind: StoredFactKind, + raw: string, +): boolean { + if (!isLifeFactKind(kind)) return isHealthProfileFactValue(kind, raw); + try { + return parseLifeFactValue(kind, JSON.parse(raw)) !== null; + } catch { + return false; + } +}