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,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;
}
}