feat(anamnesis): serve the life circumstances, record AUDIT-C, and keep both off the closed legacy lists

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 00:54:38 +02:00
1 parent 1e7fc08f57
commit f2be772eaf
28 files changed
+2131 -220

No files matched your search

+3
View File
@@ -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),
+3
View File
@@ -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<SectionPart> {
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: {
+14 -15
View File
@@ -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()
+161
View File
@@ -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,
};
}
+204
View File
@@ -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<Record<string, AuditCWording>> = {
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<Record<string, string>> = {
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잔에 해당한다.",
};
+7 -1
View File
@@ -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<OwnedModuleKey, DomainProbe> = {
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 },
}),
),
+1
View File
@@ -60,6 +60,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
analytics: ["analytics.invalid_query"],
anamnesis: [
"anamnesis.auditC.invalid",
"anamnesis.auditC.notFound",
"anamnesis.auditC.rateLimited",
"anamnesis.fact.conflict",
"anamnesis.fact.currentExists",
+14 -11
View File
@@ -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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
healthProfileFactDtoSchema,
z.union([healthProfileFactDtoSchema, lifeFactRevisionSchema]),
"CreateAnamnesisFactResponse",
),
},
@@ -1578,7 +1581,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: healthProfileFactCorrectionSchema },
"application/json": { schema: factCorrectionSchema },
},
},
responses: {
@@ -1599,7 +1602,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
healthProfileFactDtoSchema,
z.union([healthProfileFactDtoSchema, lifeFactRevisionSchema]),
"CorrectAnamnesisFactResponse",
),
},
@@ -1629,7 +1632,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
removedHealthProfileFactSchema,
removedFactSchema,
"RemoveAnamnesisFactResponse",
),
},
+7 -28
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -249,7 +230,6 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -279,7 +259,6 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -295,6 +274,7 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -330,6 +309,7 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
+26
View File
@@ -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<Record<string, Readonly<Record<SourceCountry, string>>>> =
{
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];
}
+210 -56
View File
@@ -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<HealthProfileFactsPayload> {
// 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<ArrayBuffer> {
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<Prisma.TransactionClient, "healthProfileFactRevision">,
userId: string,
kind: StoredFactKind,
requested: Date | undefined,
now: Date,
): Promise<Date> {
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<FactRevisionRow> {
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<HealthProfileFactRevisionDto> {
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<LifeFactRevisionDto> {
return toLifeFactRevisionDto(
await createFactRevision(userId, kind, value, now, validFrom),
);
}
async function correctFactRevision(
userId: string,
revisionId: string,
value: HealthProfileFactValue,
now: Date = new Date(),
): Promise<HealthProfileFactRevisionDto | null> {
value: string | LifeFactValue,
now: Date,
validFrom?: Date,
): Promise<FactRevisionRow | null> {
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<HealthProfileFactRevisionDto | null> {
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<LifeFactRevisionDto | null> {
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(),
};
});
+617
View File
@@ -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<LifeAiSection, "SHIFT_SCHEDULE">;
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<StoredHealthProfileAiSection> {
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<string, Date> {
const out = new Map<string, Date>();
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 | undefined>): 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<AuditCResponses>;
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<FreeWeekdaysSpan[]> {
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<LifeCircumstancesDto> {
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 = <T>(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<string>("SMOKING_STATUS");
const tobacco = factOf<TobaccoDetailValue>("TOBACCO_DETAIL");
const activity = factOf<ActivityHabitValue>("ACTIVITY_HABIT");
const freeDays = factOf<FreeWeekdaysValue>("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<LifeAiSection, boolean>;
updatedAt: string;
};
function lifeSwitches(
stored: Set<StoredHealthProfileAiSection>,
): Record<LifeAiSection, boolean> {
return Object.fromEntries(
LIFE_AI_SECTIONS.map((section) => [section, stored.has(section)]),
) as Record<LifeAiSection, boolean>;
}
/**
* 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<Record<LifeAiSection, boolean>>,
base?: Date,
): Promise<LifeAiSectionsOutcome> {
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<string> {
const stamp = now.toISOString();
await prisma.$transaction(async (tx) => {
const row = await tx.userHealthProfile.findUnique({
where: { userId },
select: { lifeConfirmedJson: true },
});
const next: Record<string, string> = {};
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() };
}
+378
View File
@@ -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<LifeAiSection, "SHIFT_SCHEDULE">;
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<LifeFactKind, LifeAiSection>
> = {
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)];
}
@@ -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", () => {
+10 -6
View File
@@ -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",
+30 -25
View File
@@ -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 }),
});
+166
View File
@@ -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<typeof factWriteSchema>;
/**
* `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<typeof tobaccoDetailValueSchema>;
export type ActivityHabitValue = z.infer<typeof activityHabitValueSchema>;
export type WorkContextValue = z.infer<typeof workContextValueSchema>;
export type FreeWeekdaysValue = z.infer<typeof freeWeekdaysValueSchema>;
export type HabitsValue = z.infer<typeof habitsValueSchema>;
export type HealthGoalsValue = z.infer<typeof healthGoalsValueSchema>;
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;
}
}