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

+1 -10
View File
@@ -28,15 +28,6 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
"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<Record<string, readonly string[]>> = {
};
/** 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"] })
+33 -10
View File
@@ -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);
},
);
+44 -9
View File
@@ -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<Response> {
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<Response> {
});
}
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);
}
+45 -12
View File
@@ -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",
+33 -7
View File
@@ -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<Response> {
});
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),
@@ -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,
});
});
@@ -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 });
});
@@ -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);
});
+31 -12
View File
@@ -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<ArrayBuffer> | null;
allergiesEncrypted?: Uint8Array<ArrayBuffer> | null;
coachFocusEncrypted?: Uint8Array<ArrayBuffer> | 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<string, number> = {
...(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 &&
@@ -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,
+14 -3
View File
@@ -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<Response> {
// 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<Response> {
(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) {
+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;
}
}