mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge branch 'v143/life' into release/v1.43.0
# Conflicts: # docs/api/openapi.yaml # src/__tests__/contract-stub-inventory.test.ts # tests/integration/sharing-manage-handler-matrix.test.ts
This commit is contained in:
commit
4daad8f2fb
88 files changed
+8313
-1386
No files matched your search
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* v1.43 fixed its API contract before the features behind it were built: the
|
||||
* routes exist, authenticate, gate and validate as documented, and answer
|
||||
* `501 not_implemented` (`src/lib/http/not-implemented.ts`) until their
|
||||
* feature lands. That is safe only while nothing reaches them, and nothing
|
||||
* `501 not_implemented` until their feature lands (the helper was removed
|
||||
* with the last stub; bring it back from history with the next one). That is safe only while nothing reaches them, and nothing
|
||||
* should: every client path sits behind a `features` flag that stays `false`.
|
||||
*
|
||||
* This list is the debt register. A feature that lands removes its route here
|
||||
@@ -23,20 +23,10 @@ import { walkSourceFiles } from "./helpers/source-files";
|
||||
const API_ROOT = join(process.cwd(), "src", "app", "api");
|
||||
|
||||
/** Route file (under `src/app/api`) to the operations it still stubs. */
|
||||
const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
"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"],
|
||||
};
|
||||
const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {};
|
||||
|
||||
/** The size of the register when it was written; see the header. */
|
||||
const REGISTER_SIZE = 5;
|
||||
const REGISTER_SIZE = 0;
|
||||
|
||||
function routeFiles(): string[] {
|
||||
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
|
||||
|
||||
@@ -403,8 +403,23 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
confirm: ["DeleteButton"],
|
||||
},
|
||||
{
|
||||
file: "components/records/health-profile-facts-manager.tsx",
|
||||
destroys: "the current value of one effective-dated health-profile fact",
|
||||
file: "components/records/life-circumstances-manager.tsx",
|
||||
destroys:
|
||||
"the current answer of one life-circumstance fact, when a section is saved with it set back to no answer",
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: [],
|
||||
unconfirmed: [
|
||||
{
|
||||
control:
|
||||
"Save section with a field set back to no answer — life-circumstances-manager.tsx",
|
||||
reason:
|
||||
"Setting a question back to no answer and pressing Save is the deliberate act itself, made field by field inside a form the person is editing, with the old answer visible until they change it. The delete closes the answer's interval rather than erasing it, so the dated history keeps it, and answering again takes one select. A second dialog on top of an explicit form edit would make correcting an answer dearer than giving it.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/records/audit-c-panel.tsx",
|
||||
destroys: "one AUDIT-C entry from the alcohol history",
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
|
||||
@@ -893,7 +893,15 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
},
|
||||
"app/api/anamnesis/life-circumstances/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches, confirmations and AUDIT-C are written through their own routes, none of them delegable.",
|
||||
why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches and confirmations are written through their own owner-only routes; AUDIT-C through the manage-level pair below.",
|
||||
},
|
||||
"app/api/anamnesis/audit-c/route.ts": {
|
||||
domain: "profile",
|
||||
why: "Recording an AUDIT-C (v1.43) into the record, the alcohol section of the life circumstances. Reached only through its MANAGE arm, the level allergies and family history are written at; the entry is scored on the server against the record's profile.",
|
||||
},
|
||||
"app/api/anamnesis/audit-c/[id]/route.ts": {
|
||||
domain: "profile",
|
||||
why: "Removing one AUDIT-C entry of the record, scoped `userId: user.id` and `instrument: AUDIT_C` in the guarded update, so a foreign id or a screener id is a 404. Reached only through its MANAGE arm.",
|
||||
},
|
||||
"app/api/profile/summary/route.ts": {
|
||||
domain: "profile",
|
||||
@@ -1630,6 +1638,16 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
conditions: [],
|
||||
why: "Recording an allergy. Reachable through the record's settings on a managed profile, dormant on an invited one, and argued as such.",
|
||||
},
|
||||
"app/api/anamnesis/audit-c/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: ["C1"],
|
||||
why: "Recording an AUDIT-C (v1.43) in the record a manager runs, like an allergy. C1: the 30-an-hour bucket keys on the actor. Arithmetic on three answers, no generation; the trail files the entry id and instrument, never the answers.",
|
||||
},
|
||||
"app/api/anamnesis/audit-c/[id]/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: [],
|
||||
why: "Removing an AUDIT-C entry is a soft delete (`deletedAt`), so the row stays to reconstruct and the delete needs no C3; it is the counterpart of the create beside it.",
|
||||
},
|
||||
"app/api/allergies/[id]/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: ["C3", "C4"],
|
||||
@@ -2142,8 +2160,12 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* v1.43 -- a supplement's composition joins the record list and the manage
|
||||
* literal: its PUT replaces the composition every taken dose counts with.
|
||||
* 260 -> 262.
|
||||
*
|
||||
* v1.43 -- AUDIT-C's create and soft delete join the record list and the
|
||||
* manage literal under `profile`, the level allergies are written at.
|
||||
* 262 -> 266.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 262;
|
||||
const FROZEN_ENTRY_COUNT = 266;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
@@ -2904,7 +2926,7 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
|
||||
it("keeps the admitted mutation inventory complete and discoverable", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(90);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(92);
|
||||
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ handlerModule, action, level }) =>
|
||||
|
||||
@@ -508,7 +508,17 @@ const RESULT_SURFACES: ReadonlyArray<{
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/records/health-profile-facts-manager.tsx",
|
||||
path: "src/components/records/life-circumstances-manager.tsx",
|
||||
through: /toastWrittenOutcome/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/records/audit-c-panel.tsx",
|
||||
through: /toastWrittenOutcome/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/records/free-text-take-over.tsx",
|
||||
through: /toastWrittenOutcome/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
|
||||
@@ -47,7 +47,6 @@ const REQUIRED_KEYS = [
|
||||
"insights.healthScore.versionAndNoise",
|
||||
"insights.healthScore.methodVersion",
|
||||
"insights.healthScore.source",
|
||||
"records.profileFacts.save",
|
||||
"records.allergies.add",
|
||||
"records.allergies.addFirst",
|
||||
"records.family.add",
|
||||
@@ -62,6 +61,9 @@ const OBSOLETE_KEYS = [
|
||||
"insights.healthScore.composition",
|
||||
"documents.detail.summary.regenerate",
|
||||
"records.profileFacts.correct",
|
||||
// v1.43 — the profile-facts card became the life-circumstances card, whose
|
||||
// sections save on their own.
|
||||
"records.profileFacts.save",
|
||||
"settings.nightscoutPrivateHost",
|
||||
"settings.nightscoutPrivateHostHelp",
|
||||
] as const;
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(90);
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(92);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
/**
|
||||
* `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 { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { invalidateHealthProfileFactConsumers } from "@/lib/profile/health-facts";
|
||||
|
||||
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;
|
||||
|
||||
// Soft delete, scoped to a live AUDIT-C entry of the record: a foreign
|
||||
// id, a screener's id or an entry already removed is the same 404.
|
||||
const now = new Date();
|
||||
const result = await prisma.mentalHealthAssessment.updateMany({
|
||||
where: { id, userId: user.id, instrument: "AUDIT_C", deletedAt: null },
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
if (result.count !== 1) {
|
||||
return apiError("AUDIT-C entry not found", 404, {
|
||||
errorCode: "anamnesis.auditC.notFound",
|
||||
});
|
||||
}
|
||||
await invalidateHealthProfileFactConsumers(user.id, prisma);
|
||||
const removed = { id, removedAt: now.toISOString() };
|
||||
|
||||
await auditLog("anamnesis.audit_c.delete", {
|
||||
userId: user.id,
|
||||
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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
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
|
||||
? dateOnlyAtNoonUtc(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",
|
||||
|
||||
@@ -106,6 +106,8 @@ describe("anamnesis facts API", () => {
|
||||
"user-1",
|
||||
"SMOKING_STATUS",
|
||||
"FORMER",
|
||||
expect.any(Date),
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.audit).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -135,7 +137,13 @@ describe("anamnesis facts API", () => {
|
||||
where: expect.objectContaining({ id: "fact-1", userId: "user-1" }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.correct).toHaveBeenCalledWith("user-1", "fact-1", "NEVER");
|
||||
expect(mocks.correct).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"fact-1",
|
||||
"NEVER",
|
||||
expect.any(Date),
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.audit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
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
|
||||
? dateOnlyAtNoonUtc(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);
|
||||
});
|
||||
@@ -90,7 +90,7 @@ const SERVER_FEATURES = {
|
||||
cumulativeHourBuckets: true,
|
||||
syncTriggerBackfill: true,
|
||||
biomarkerMerge: true,
|
||||
lifeCircumstances: false,
|
||||
lifeCircumstances: true,
|
||||
} as const;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
* map the shared snapshot builder uses (`disabledSummaryTypes`); the filter
|
||||
* runs OUTSIDE the cache because a module toggle does not evict it.
|
||||
*/
|
||||
import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -717,6 +718,8 @@ async function buildDashboardSummary(
|
||||
sleepPriorityJson,
|
||||
),
|
||||
sleepNeedMinutes(sleepProfile.ageYears),
|
||||
"COMPUTED",
|
||||
await loadFreeWeekdaySpans(userId),
|
||||
);
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
|
||||
@@ -36,6 +36,8 @@ vi.mock("@/lib/db", () => ({
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
healthProfileFactRevision: { findMany: vi.fn() },
|
||||
// v1.43 — the anamnesis leaf also carries the newest AUDIT-C entry.
|
||||
mentalHealthAssessment: { findFirst: vi.fn(async () => null) },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
// The whole-record gate reads the session's own stamps: a sign-in a
|
||||
// moment ago is the proof, on an account without a second factor.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -250,7 +250,12 @@ describe("POST /api/mental-health/assessments", () => {
|
||||
expect(body.data.assessment.id).toBe("mha_dup");
|
||||
expect(prisma.mentalHealthAssessment.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "user-1", externalId: "outbox-7f3c" },
|
||||
// Screeners only: an AUDIT-C entry (v1.43) is never a dedup match.
|
||||
where: {
|
||||
userId: "user-1",
|
||||
externalId: "outbox-7f3c",
|
||||
instrument: { in: ["PHQ9", "GAD7", "WHO5", "SCI"] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -280,7 +280,13 @@ export function ClinicianView({
|
||||
|
||||
{/* ── the fenced tier, as the owner chose it ───────────── */}
|
||||
<FamilyHistorySection t={t} report={report} scope={scope} />
|
||||
<AnamnesisSection t={t} report={report} scope={scope} />
|
||||
<AnamnesisSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtNum={fmtNum}
|
||||
fmtDate={fmtDate}
|
||||
/>
|
||||
<MoodSection t={t} report={report} scope={scope} fmtNum={fmtNum} />
|
||||
<CycleSection
|
||||
t={t}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* A pure server component: no client hooks, no session, no markdown — every
|
||||
* value renders as escaped React text.
|
||||
*/
|
||||
import { lifeReportRows } from "@/lib/profile/life-report-rows";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
@@ -473,10 +474,14 @@ export function AnamnesisSection({
|
||||
t,
|
||||
report,
|
||||
scope,
|
||||
fmtNum,
|
||||
fmtDate,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtNum: (n: number) => string;
|
||||
fmtDate: (iso: string) => string;
|
||||
}) {
|
||||
const anamnesis = report.anamnesis ?? null;
|
||||
|
||||
@@ -518,6 +523,11 @@ export function AnamnesisSection({
|
||||
label={t("doctorReport.anamnesisShiftSchedule")}
|
||||
value={factValue("SHIFT_SCHEDULE", anamnesis?.shiftSchedule ?? null)}
|
||||
/>
|
||||
{lifeReportRows(anamnesis?.life, t, fmtNum, fmtDate).map(
|
||||
([label, value]) => (
|
||||
<StatRow key={label} label={label} value={value} />
|
||||
),
|
||||
)}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { HealthProfileFactsManager } from "../health-profile-facts-manager";
|
||||
|
||||
const {
|
||||
MockApiError,
|
||||
apiDelete,
|
||||
apiPatch,
|
||||
apiPost,
|
||||
toastError,
|
||||
toastWritten,
|
||||
confirmButtons,
|
||||
renderedButtons,
|
||||
selects,
|
||||
removedAt,
|
||||
} = vi.hoisted(() => {
|
||||
const removedAt = "2026-07-28T12:00:00.000Z";
|
||||
class MockApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
MockApiError,
|
||||
apiDelete: vi.fn(async () => ({
|
||||
id: "fact-1",
|
||||
kind: "SMOKING_STATUS" as const,
|
||||
removedAt,
|
||||
})),
|
||||
apiPatch: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastWritten: vi.fn(),
|
||||
confirmButtons: [] as Record<string, unknown>[],
|
||||
renderedButtons: [] as Record<string, unknown>[],
|
||||
selects: [] as Record<string, unknown>[],
|
||||
removedAt,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
ApiError: MockApiError,
|
||||
apiDelete,
|
||||
apiGet: vi.fn(),
|
||||
apiPatch,
|
||||
apiPost,
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { error: toastError } }));
|
||||
vi.mock("@/components/outcome/outcome-toast", () => ({
|
||||
toastWrittenOutcome: toastWritten,
|
||||
}));
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: (props: Record<string, unknown>) => {
|
||||
renderedButtons.push(props);
|
||||
return <button />;
|
||||
},
|
||||
}));
|
||||
|
||||
// The real Radix Select hides its `onValueChange` behind the trigger, so the
|
||||
// draft edits the interactions drive can't reach it. A thin mock exposes the
|
||||
// callback (kinds render in `HEALTH_PROFILE_FACT_KINDS` order) so a test can set
|
||||
// a draft the one footer Save then writes.
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: (props: Record<string, unknown>) => {
|
||||
selects.push(props);
|
||||
return <div>{props.children as ReactNode}</div>;
|
||||
},
|
||||
SelectContent: (props: Record<string, unknown>) => (
|
||||
<>{props.children as ReactNode}</>
|
||||
),
|
||||
SelectItem: (props: Record<string, unknown>) => (
|
||||
<>{props.children as ReactNode}</>
|
||||
),
|
||||
SelectTrigger: (props: Record<string, unknown>) => (
|
||||
<>{props.children as ReactNode}</>
|
||||
),
|
||||
SelectValue: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/confirm-button", () => ({
|
||||
ConfirmButton: (props: Record<string, unknown>) => {
|
||||
confirmButtons.push(props);
|
||||
return (
|
||||
<button data-slot={String(props.slot)}>{String(props.label)}</button>
|
||||
);
|
||||
},
|
||||
ConfirmDialog: () => null,
|
||||
}));
|
||||
|
||||
const fact = {
|
||||
id: "fact-1",
|
||||
kind: "SMOKING_STATUS" as const,
|
||||
value: "FORMER" as const,
|
||||
unreadable: false,
|
||||
validFrom: "2026-07-28T10:00:00.000Z",
|
||||
validUntil: null,
|
||||
provenance: "USER_REPORTED" as const,
|
||||
supersededByRevisionId: null,
|
||||
createdAt: "2026-07-28T10:00:00.000Z",
|
||||
};
|
||||
|
||||
function render() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
client.setQueryData(queryKeys.healthProfileFacts(), {
|
||||
current: {
|
||||
SMOKING_STATUS: fact,
|
||||
ALCOHOL_PATTERN: null,
|
||||
SHIFT_SCHEDULE: null,
|
||||
},
|
||||
history: [fact],
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthProfileFactsManager />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { client, html };
|
||||
}
|
||||
|
||||
// The one primary action: default variant, a button-type, its own onClick.
|
||||
function saveButton() {
|
||||
return renderedButtons.find(
|
||||
(props) =>
|
||||
props.type === "button" &&
|
||||
props.variant === undefined &&
|
||||
typeof props.onClick === "function",
|
||||
);
|
||||
}
|
||||
|
||||
function editDraft(index: number, value: string) {
|
||||
(selects[index].onValueChange as (next: string) => void)(value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
confirmButtons.length = 0;
|
||||
renderedButtons.length = 0;
|
||||
selects.length = 0;
|
||||
apiDelete.mockClear();
|
||||
apiPatch.mockReset();
|
||||
apiPost.mockReset();
|
||||
toastError.mockClear();
|
||||
toastWritten.mockClear();
|
||||
});
|
||||
|
||||
describe("health-profile fact save + removal controls", () => {
|
||||
it("renders exactly one Save for the whole lifestyle-context tile", () => {
|
||||
render();
|
||||
|
||||
const actionButtons = renderedButtons.filter(
|
||||
(props) =>
|
||||
props.type === "button" &&
|
||||
props.variant === undefined &&
|
||||
typeof props.onClick === "function",
|
||||
);
|
||||
const labels = actionButtons.map((props) => {
|
||||
const children = Array.isArray(props.children)
|
||||
? props.children
|
||||
: [props.children];
|
||||
return children.filter((child) => typeof child === "string").join("");
|
||||
});
|
||||
|
||||
expect(labels).toEqual(["Save"]);
|
||||
});
|
||||
|
||||
it("renders a confirmed removal action only for each recorded current fact", () => {
|
||||
const { html } = render();
|
||||
|
||||
expect(confirmButtons).toHaveLength(1);
|
||||
// The visible label is short — three entries side by side each carrying
|
||||
// their own field name read as one run of words — but the ACCESSIBLE name
|
||||
// still says which entry it removes, and so does the dialog it opens.
|
||||
expect(confirmButtons[0]).toMatchObject({
|
||||
slot: "health-profile-fact-remove-SMOKING_STATUS",
|
||||
label: "Remove",
|
||||
ariaLabel: "Remove Smoking status",
|
||||
confirmLabel: "Remove Smoking status",
|
||||
title: "Remove Smoking status?",
|
||||
body: "The current value will become not recorded. Its dated history will be retained.",
|
||||
});
|
||||
expect(html).toContain("Remove");
|
||||
expect(apiDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the current revision id as the concurrency token after confirmation", async () => {
|
||||
render();
|
||||
|
||||
(confirmButtons[0].onConfirm as () => void)();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(apiDelete).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("optimistically resolves the current fact as absent while retaining closed history", async () => {
|
||||
const { client } = render();
|
||||
|
||||
(confirmButtons[0].onConfirm as () => void)();
|
||||
await vi.waitFor(() => {
|
||||
const data = client.getQueryData<{
|
||||
current: Record<string, unknown>;
|
||||
history: Array<{ id: string; validUntil: string | null }>;
|
||||
}>(queryKeys.healthProfileFacts());
|
||||
expect(data?.current.SMOKING_STATUS).toBeNull();
|
||||
expect(data?.history).toEqual([
|
||||
expect.objectContaining({ id: "fact-1", validUntil: removedAt }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates advisor data after a successful save", async () => {
|
||||
apiPatch.mockResolvedValueOnce({ ...fact, value: "NEVER" });
|
||||
const { client } = render();
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
|
||||
// Revise the recorded smoking status, then hit the one Save.
|
||||
editDraft(0, "NEVER");
|
||||
(saveButton()!.onClick as () => void)();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", {
|
||||
value: "NEVER",
|
||||
}),
|
||||
);
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.insightsAdvisor(),
|
||||
});
|
||||
expect(toastWritten).toHaveBeenCalledWith(
|
||||
"success",
|
||||
"Lifestyle context saved",
|
||||
);
|
||||
});
|
||||
|
||||
it("invalidates the facts read when a save finds a stale 404 target", async () => {
|
||||
apiPatch.mockRejectedValueOnce(new MockApiError("Not found", 404));
|
||||
const { client } = render();
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
|
||||
editDraft(0, "NEVER");
|
||||
(saveButton()!.onClick as () => void)();
|
||||
|
||||
await vi.waitFor(() => expect(apiPatch).toHaveBeenCalledOnce());
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.healthProfileFacts(),
|
||||
});
|
||||
// A stale target is a failure, never a green toast.
|
||||
expect(toastWritten).not.toHaveBeenCalled();
|
||||
expect(toastError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalidates advisor data after a successful removal", async () => {
|
||||
const { client } = render();
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
|
||||
(confirmButtons[0].onConfirm as () => void)();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.insightsAdvisor(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("invalidates the facts read when removal finds a stale 404 target", async () => {
|
||||
apiDelete.mockRejectedValueOnce(new MockApiError("Not found", 404));
|
||||
const { client } = render();
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
|
||||
(confirmButtons[0].onConfirm as () => void)();
|
||||
|
||||
await vi.waitFor(() => expect(apiDelete).toHaveBeenCalledOnce());
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.healthProfileFacts(),
|
||||
});
|
||||
});
|
||||
|
||||
it("writes every dirty kind and keeps a conflicted kind while the other persists", async () => {
|
||||
// Two dirty kinds: SMOKING revises an existing fact (PATCH), ALCOHOL is a
|
||||
// first value (POST). Force the PATCH to 409 and prove the POST still lands,
|
||||
// the failure is surfaced per-kind, and the conflicted draft survives.
|
||||
apiPatch.mockRejectedValue(new MockApiError("Stale", 409));
|
||||
apiPost.mockResolvedValue({
|
||||
id: "fact-2",
|
||||
kind: "ALCOHOL_PATTERN",
|
||||
value: "WEEKLY",
|
||||
});
|
||||
render();
|
||||
|
||||
editDraft(0, "NEVER"); // SMOKING_STATUS, differs from FORMER
|
||||
editDraft(1, "WEEKLY"); // ALCOHOL_PATTERN, first value
|
||||
(saveButton()!.onClick as () => void)();
|
||||
|
||||
// Both writes reach the real endpoints in one Save.
|
||||
await vi.waitFor(() => expect(toastError).toHaveBeenCalled());
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", {
|
||||
value: "NEVER",
|
||||
});
|
||||
expect(apiPost).toHaveBeenCalledWith("/api/anamnesis/facts", {
|
||||
kind: "ALCOHOL_PATTERN",
|
||||
value: "WEEKLY",
|
||||
});
|
||||
|
||||
// The partial failure is not hidden behind a green toast: the error names
|
||||
// the kind that did not land, and no success toast fired.
|
||||
expect(toastError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Smoking status"),
|
||||
);
|
||||
expect(toastWritten).not.toHaveBeenCalled();
|
||||
|
||||
// The conflicted draft survives while the persisted one is consumed: a
|
||||
// second Save re-attempts only SMOKING and never re-POSTs ALCOHOL.
|
||||
apiPatch.mockClear();
|
||||
apiPost.mockClear();
|
||||
(saveButton()!.onClick as () => void)();
|
||||
|
||||
await vi.waitFor(() => expect(apiPatch).toHaveBeenCalledOnce());
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/anamnesis/facts/fact-1", {
|
||||
value: "NEVER",
|
||||
});
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,18 @@ const SECTION_LABEL_KEY: Record<HealthProfileAiSection, string> = {
|
||||
SHIFT_SCHEDULE: "records.aiInclusion.sectionShiftSchedule",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.43 (#614) — smoking, alcohol and shift work are switched beside their
|
||||
* answers in the life-circumstances card; listing them here as well would put
|
||||
* two switches for one setting on one page. They still travel in the PUT
|
||||
* below with their saved value, so this card never changes them.
|
||||
*/
|
||||
const LIFE_CARD_SECTIONS: ReadonlySet<HealthProfileAiSection> = new Set([
|
||||
"SMOKING_STATUS",
|
||||
"ALCOHOL_PATTERN",
|
||||
"SHIFT_SCHEDULE",
|
||||
]);
|
||||
|
||||
export function AiProfileInclusionManager() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -83,6 +95,9 @@ export function AiProfileInclusionManager() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsAdvisor(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.lifeCircumstances(),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isConflict(error)) {
|
||||
@@ -106,7 +121,9 @@ export function AiProfileInclusionManager() {
|
||||
return (
|
||||
<div className="space-y-4" data-slot="ai-profile-inclusion-manager">
|
||||
<div className="divide-border divide-y">
|
||||
{HEALTH_PROFILE_AI_SECTIONS.map((section) => {
|
||||
{HEALTH_PROFILE_AI_SECTIONS.filter(
|
||||
(section) => !LIFE_CARD_SECTIONS.has(section),
|
||||
).map((section) => {
|
||||
const checked = value.includes(section);
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { FreeTextTakeOver } from "@/components/records/free-text-take-over";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -142,6 +143,8 @@ export function AllergyFreeTextNote({
|
||||
) : (
|
||||
<SettingsCardActions>
|
||||
{leadingAction}
|
||||
{/* v1.43 (#614) — move the free text into a structured entry by hand. */}
|
||||
<FreeTextTakeOver field="allergies" />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.43 (#614) — the alcohol section of the life circumstances: AUDIT-C.
|
||||
*
|
||||
* The three items render exactly as the server delivers them
|
||||
* (`auditC.instrument`): validated wording in the account language, or the
|
||||
* WHO English original with a note when no validated translation exists.
|
||||
* This file never carries item text of its own. The result line, the cut-off
|
||||
* and its source are the server's too; nothing is scored here.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
type AuditC = LifeCircumstancesDto["auditC"];
|
||||
|
||||
export function AuditCPanel({
|
||||
data,
|
||||
onChanged,
|
||||
}: {
|
||||
data: AuditC;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [answers, setAnswers] = useState<Array<number | null>>([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
const { instrument, latest } = data;
|
||||
|
||||
const submit = useMutation({
|
||||
mutationKey: queryKeys.lifeCircumstances(),
|
||||
mutationFn: (items: number[]) =>
|
||||
apiPost("/api/anamnesis/audit-c", {
|
||||
items,
|
||||
// The language of the wording actually answered; the cut-off follows it.
|
||||
locale: instrument.locale,
|
||||
source: "WEB",
|
||||
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome("success", t("records.life.auditC.savedToast"));
|
||||
setOpen(false);
|
||||
setAnswers([null, null, null]);
|
||||
onChanged();
|
||||
},
|
||||
onError: (error) =>
|
||||
showErrorToast(t("records.life.auditC.saveError"), { cause: error }),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationKey: queryKeys.lifeCircumstances(),
|
||||
mutationFn: (id: string) => apiDelete(`/api/anamnesis/audit-c/${id}`),
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome("success", t("records.life.auditC.removedToast"));
|
||||
onChanged();
|
||||
},
|
||||
onError: (error) =>
|
||||
showErrorToast(t("records.life.auditC.removeError"), { cause: error }),
|
||||
});
|
||||
|
||||
const complete = answers.every((answer) => answer !== null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="audit-c-panel">
|
||||
<p className="text-sm">{t("records.life.auditC.intro")}</p>
|
||||
|
||||
{latest ? (
|
||||
<div className="space-y-1" data-slot="audit-c-latest">
|
||||
<p className="text-sm font-medium">
|
||||
{t("records.life.auditC.result", { score: latest.totalScore })}
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{" "}
|
||||
{fmt.date(new Date(latest.takenAt))}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{latest.cutoff === null
|
||||
? t("records.life.auditC.noCutoff")
|
||||
: t(
|
||||
latest.aboveCutoff
|
||||
? "records.life.auditC.above"
|
||||
: "records.life.auditC.below",
|
||||
{ cutoff: latest.cutoff.value },
|
||||
)}
|
||||
</p>
|
||||
{latest.cutoff ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<a
|
||||
href={latest.cutoff.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
{t("records.life.auditC.source", {
|
||||
source: latest.cutoff.sourceLabel,
|
||||
})}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.history.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("records.life.auditC.history")}
|
||||
</h4>
|
||||
<ul className="divide-border divide-y text-sm">
|
||||
{data.history.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between gap-2 py-1.5"
|
||||
>
|
||||
<span>
|
||||
{t("records.life.auditC.historyEntry", {
|
||||
date: fmt.date(new Date(entry.takenAt)),
|
||||
score: entry.totalScore,
|
||||
})}
|
||||
</span>
|
||||
<ConfirmButton
|
||||
label={t("records.life.auditC.remove")}
|
||||
ariaLabel={t("records.life.auditC.remove")}
|
||||
title={t("records.life.auditC.removeConfirmTitle")}
|
||||
body={t("records.life.auditC.removeConfirmBody")}
|
||||
confirmLabel={t("records.life.auditC.remove")}
|
||||
onConfirm={() => remove.mutate(entry.id)}
|
||||
pending={remove.isPending && remove.variables === entry.id}
|
||||
disabled={remove.isPending}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
slot={`audit-c-remove-${entry.id}`}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.offerDue ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("records.life.auditC.offerDue")}
|
||||
</p>
|
||||
) : null}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={data.offerDue ? "default" : "outline"}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setOpen(true)}
|
||||
data-slot="audit-c-start"
|
||||
>
|
||||
{latest
|
||||
? t("records.life.auditC.again")
|
||||
: t("records.life.auditC.start")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t("records.life.sections.alcohol.title")}
|
||||
description={t("records.life.auditC.intro")}
|
||||
footer={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={!complete || submit.isPending}
|
||||
onClick={() => submit.mutate(answers as number[])}
|
||||
data-slot="audit-c-submit"
|
||||
>
|
||||
{submit.isPending && (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{t("records.life.auditC.submit")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6" lang={instrument.locale}>
|
||||
{!instrument.validatedLocale ? (
|
||||
<p className="text-sm" lang={locale}>
|
||||
{t("records.life.auditC.notValidated")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-sm" lang={locale}>
|
||||
{instrument.standardDrink}
|
||||
</p>
|
||||
{instrument.items.map((item, index) => (
|
||||
<fieldset key={index} className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{index + 1}. {item.stem}
|
||||
</legend>
|
||||
<div className="space-y-1">
|
||||
{item.options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex min-h-11 items-center gap-2 text-sm sm:min-h-9"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`audit-c-${index}`}
|
||||
value={option.value}
|
||||
checked={answers[index] === option.value}
|
||||
onChange={() =>
|
||||
setAnswers((old) =>
|
||||
old.map((value, i) =>
|
||||
i === index ? option.value : value,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="accent-primary size-4"
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
<p className="text-muted-foreground text-xs" lang="en">
|
||||
{instrument.attribution}
|
||||
</p>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { FreeTextTakeOver } from "@/components/records/free-text-take-over";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -140,6 +141,8 @@ export function ConditionsManager() {
|
||||
t("settings.ai.aboutMe.focusPlaceholder"),
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
{/* v1.43 (#614) — move the free text into the condition journal by hand. */}
|
||||
<FreeTextTakeOver field="conditions" />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.43 (#614) — turn the free-text conditions or allergies line into a
|
||||
* structured entry, by hand.
|
||||
*
|
||||
* The free text and the structured records overlap: the chronic conditions
|
||||
* live in the condition journal (`IllnessEpisode`, `CHRONIC_ONGOING`), the
|
||||
* allergies in `Allergy`. Nothing here parses the free text or merges
|
||||
* anything on its own. The button opens a form beside the text; the person
|
||||
* types the entry, it is saved through the ordinary create route, and only
|
||||
* then is clearing the free text offered, as a separate decision.
|
||||
*
|
||||
* The button shows only while there is free text and the target is
|
||||
* available (`takeOver` from the life-circumstances read).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { AllergyForm } from "@/components/records/allergy-form";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { withBaseToken } from "@/lib/api/optimistic-token";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
interface AboutMeData {
|
||||
conditions: string | null;
|
||||
allergies: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export function FreeTextTakeOver({
|
||||
field,
|
||||
}: {
|
||||
field: "conditions" | "allergies";
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [label, setLabel] = useState("");
|
||||
const [onset, setOnset] = useState("");
|
||||
const [footerEl, setFooterEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
const life = useQuery({
|
||||
queryKey: queryKeys.lifeCircumstances(),
|
||||
queryFn: () =>
|
||||
apiGet<LifeCircumstancesDto>("/api/anamnesis/life-circumstances"),
|
||||
});
|
||||
const aboutMe = useQuery({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
queryFn: () => apiGet<AboutMeData>("/api/coach/about-me"),
|
||||
});
|
||||
|
||||
const afterCreate = () => {
|
||||
setSaved(true);
|
||||
toastWrittenOutcome("success", t("records.life.takeOver.savedToast"));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.lifeCircumstances(),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
field === "conditions" ? queryKeys.illness() : queryKeys.allergies(),
|
||||
});
|
||||
};
|
||||
|
||||
const createCondition = useMutation({
|
||||
mutationKey: queryKeys.lifeCircumstances(),
|
||||
mutationFn: () =>
|
||||
apiPost("/api/illness/episodes", {
|
||||
label: label.trim(),
|
||||
type: "CHRONIC",
|
||||
lifecycle: "CHRONIC_ONGOING",
|
||||
...(isCalendarDateKey(onset)
|
||||
? { onsetAt: dateOnlyAtNoonUtc(onset).toISOString() }
|
||||
: {}),
|
||||
}),
|
||||
onSuccess: afterCreate,
|
||||
onError: (error) =>
|
||||
showErrorToast(t("records.life.takeOver.saveError"), { cause: error }),
|
||||
});
|
||||
|
||||
const clear = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
mutationFn: () =>
|
||||
apiPut(
|
||||
"/api/coach/about-me",
|
||||
withBaseToken({ [field]: "" }, aboutMe.data?.updatedAt ?? undefined),
|
||||
),
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome("success", t("records.life.takeOver.clearedToast"));
|
||||
close();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.lifeCircumstances(),
|
||||
});
|
||||
},
|
||||
onError: (error) =>
|
||||
showErrorToast(t("records.life.takeOver.clearError"), { cause: error }),
|
||||
});
|
||||
|
||||
function close() {
|
||||
setOpen(false);
|
||||
setSaved(false);
|
||||
setLabel("");
|
||||
setOnset("");
|
||||
}
|
||||
|
||||
const entry = life.data?.takeOver.find((item) => item.field === field);
|
||||
const text = aboutMe.data?.[field] ?? null;
|
||||
if (!entry?.freeTextPresent || !entry.available || !text) return null;
|
||||
|
||||
const title =
|
||||
field === "conditions"
|
||||
? t("records.life.takeOver.conditionsTitle")
|
||||
: t("records.life.takeOver.allergiesTitle");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setOpen(true)}
|
||||
data-slot={`take-over-${field}`}
|
||||
>
|
||||
{field === "conditions"
|
||||
? t("records.life.takeOver.conditionsButton")
|
||||
: t("records.life.takeOver.allergiesButton")}
|
||||
</Button>
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(next) => (next ? setOpen(true) : close())}
|
||||
title={title}
|
||||
description={t("records.life.takeOver.description")}
|
||||
footer={
|
||||
saved ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={close}
|
||||
>
|
||||
{t("records.life.takeOver.keep")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={clear.isPending}
|
||||
onClick={() => clear.mutate()}
|
||||
>
|
||||
{t("records.life.takeOver.clear")}
|
||||
</Button>
|
||||
</>
|
||||
) : field === "conditions" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={label.trim().length === 0 || createCondition.isPending}
|
||||
onClick={() => createCondition.mutate()}
|
||||
>
|
||||
{createCondition.isPending && (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{t("records.life.takeOver.save")}
|
||||
</Button>
|
||||
) : (
|
||||
<div ref={setFooterEl} className="flex w-full justify-end gap-2" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("records.life.takeOver.freeTextLabel")}
|
||||
</p>
|
||||
<p className="bg-muted/40 rounded-md p-3 text-sm whitespace-pre-wrap">
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
{saved ? (
|
||||
<p className="text-sm">
|
||||
{t("records.life.takeOver.clearQuestion")}
|
||||
</p>
|
||||
) : field === "conditions" ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="take-over-condition-label">
|
||||
{t("records.life.takeOver.conditionLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="take-over-condition-label"
|
||||
maxLength={120}
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="take-over-condition-onset">
|
||||
{t("records.life.takeOver.onset")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="take-over-condition-onset"
|
||||
value={onset}
|
||||
onChange={setOnset}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<AllergyForm
|
||||
footerSlot={footerEl}
|
||||
onSuccess={afterCreate}
|
||||
onCancel={close}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
ApiError,
|
||||
apiDelete,
|
||||
apiGet,
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { isConflict } from "@/lib/api/optimistic-token";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
ALCOHOL_PATTERN_VALUES,
|
||||
HEALTH_PROFILE_FACT_KINDS,
|
||||
SHIFT_SCHEDULE_VALUES,
|
||||
SMOKING_STATUS_VALUES,
|
||||
type HealthProfileFactKind,
|
||||
type HealthProfileFactValue,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
|
||||
interface FactDto {
|
||||
id: string;
|
||||
kind: HealthProfileFactKind;
|
||||
value: HealthProfileFactValue | null;
|
||||
unreadable: boolean;
|
||||
validFrom: string;
|
||||
validUntil: string | null;
|
||||
provenance: "USER_REPORTED" | "USER_CORRECTION";
|
||||
supersededByRevisionId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface FactsData {
|
||||
current: Record<HealthProfileFactKind, FactDto | null>;
|
||||
history: FactDto[];
|
||||
}
|
||||
|
||||
interface RemovedFactDto {
|
||||
id: string;
|
||||
kind: HealthProfileFactKind;
|
||||
removedAt: string;
|
||||
}
|
||||
|
||||
const VALUES: Record<HealthProfileFactKind, readonly string[]> = {
|
||||
SMOKING_STATUS: SMOKING_STATUS_VALUES,
|
||||
ALCOHOL_PATTERN: ALCOHOL_PATTERN_VALUES,
|
||||
SHIFT_SCHEDULE: SHIFT_SCHEDULE_VALUES,
|
||||
};
|
||||
|
||||
function isStaleFactTarget(error: unknown): boolean {
|
||||
return (
|
||||
isConflict(error) || (error instanceof ApiError && error.status === 404)
|
||||
);
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<HealthProfileFactKind, string> = {
|
||||
SMOKING_STATUS: "records.profileFacts.smokingLabel",
|
||||
ALCOHOL_PATTERN: "records.profileFacts.alcoholLabel",
|
||||
SHIFT_SCHEDULE: "records.profileFacts.shiftLabel",
|
||||
};
|
||||
|
||||
export function HealthProfileFactsManager() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const [drafts, setDrafts] = useState<
|
||||
Partial<Record<HealthProfileFactKind, string>>
|
||||
>({});
|
||||
// Kinds whose last save attempt failed. A per-kind flag rather than a single
|
||||
// banner, so a partial failure names which entry the user still has to redo
|
||||
// instead of hiding it behind one green toast.
|
||||
const [failedKinds, setFailedKinds] = useState<
|
||||
Partial<Record<HealthProfileFactKind, boolean>>
|
||||
>({});
|
||||
// Live copy of the draft set. State drives the controlled Selects; this ref
|
||||
// is what the one footer Save reads at click time, so a multi-field save
|
||||
// always writes the latest selections and never a value a render lagged on.
|
||||
const draftsRef = useRef<Partial<Record<HealthProfileFactKind, string>>>({});
|
||||
|
||||
const editDraft = (kind: HealthProfileFactKind, value: string) => {
|
||||
draftsRef.current = { ...draftsRef.current, [kind]: value };
|
||||
setDrafts(draftsRef.current);
|
||||
// Editing a kind clears its stale failure marker; the next Save decides its
|
||||
// outcome afresh.
|
||||
setFailedKinds((old) => (old[kind] ? { ...old, [kind]: undefined } : old));
|
||||
};
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.healthProfileFacts(),
|
||||
queryFn: () => apiGet<FactsData>("/api/anamnesis/facts"),
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.healthProfileFacts(),
|
||||
// One Save for the whole tile: iterate only the dirty kinds, fire the right
|
||||
// bitemporal call per kind (PATCH a revision, POST a first value), and settle
|
||||
// them together so a single 409 cannot take the healthy writes down with it.
|
||||
mutationFn: async (
|
||||
inputs: Array<{
|
||||
kind: HealthProfileFactKind;
|
||||
value: string;
|
||||
current: FactDto | null;
|
||||
}>,
|
||||
) => {
|
||||
const settled = await Promise.allSettled(
|
||||
inputs.map((input) =>
|
||||
input.current
|
||||
? apiPatch<FactDto>(`/api/anamnesis/facts/${input.current.id}`, {
|
||||
value: input.value,
|
||||
})
|
||||
: apiPost<FactDto>("/api/anamnesis/facts", {
|
||||
kind: input.kind,
|
||||
value: input.value,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return inputs.map((input, index) => ({
|
||||
kind: input.kind,
|
||||
outcome: settled[index],
|
||||
}));
|
||||
},
|
||||
onSuccess: (results) => {
|
||||
const succeeded = results.filter((r) => r.outcome.status === "fulfilled");
|
||||
const failed = results.filter((r) => r.outcome.status === "rejected");
|
||||
|
||||
// Clear the drafts and the failure flags only for the kinds that landed.
|
||||
// A kind that 409'd keeps its draft on screen so the user can retry it.
|
||||
const remaining = { ...draftsRef.current };
|
||||
for (const { kind } of succeeded) delete remaining[kind];
|
||||
draftsRef.current = remaining;
|
||||
setDrafts(remaining);
|
||||
setFailedKinds((current) => {
|
||||
const next = { ...current };
|
||||
for (const { kind } of succeeded) next[kind] = undefined;
|
||||
for (const { kind } of failed) next[kind] = true;
|
||||
return next;
|
||||
});
|
||||
|
||||
if (succeeded.length > 0) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.healthProfileFacts(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsAdvisor(),
|
||||
});
|
||||
} else if (
|
||||
failed.some((r) =>
|
||||
isStaleFactTarget((r.outcome as PromiseRejectedResult).reason),
|
||||
)
|
||||
) {
|
||||
// Nothing persisted, but a stale target means our view is behind the
|
||||
// server; refetch so the next attempt revises the current revision.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.healthProfileFacts(),
|
||||
});
|
||||
}
|
||||
|
||||
if (failed.length === 0) {
|
||||
toastWrittenOutcome("success", t("records.profileFacts.savedToast"));
|
||||
} else {
|
||||
showErrorToast(
|
||||
t("records.profileFacts.savePartialError", {
|
||||
kinds: failed.map((r) => t(KIND_LABEL[r.kind])).join(", "),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationKey: queryKeys.healthProfileFacts(),
|
||||
mutationFn: (current: FactDto) =>
|
||||
apiDelete<RemovedFactDto>(`/api/anamnesis/facts/${current.id}`),
|
||||
onSuccess: (removed) => {
|
||||
setDrafts((current) => ({ ...current, [removed.kind]: undefined }));
|
||||
queryClient.setQueryData<FactsData>(
|
||||
queryKeys.healthProfileFacts(),
|
||||
(facts) =>
|
||||
facts
|
||||
? {
|
||||
current: {
|
||||
...facts.current,
|
||||
[removed.kind]: null,
|
||||
},
|
||||
history: facts.history.map((revision) =>
|
||||
revision.id === removed.id
|
||||
? { ...revision, validUntil: removed.removedAt }
|
||||
: revision,
|
||||
),
|
||||
}
|
||||
: facts,
|
||||
);
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
t("records.profileFacts.removedToast", {
|
||||
kind: t(KIND_LABEL[removed.kind]),
|
||||
}),
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.healthProfileFacts(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsAdvisor(),
|
||||
});
|
||||
},
|
||||
onError: (error, current) => {
|
||||
if (isStaleFactTarget(error)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.healthProfileFacts(),
|
||||
});
|
||||
}
|
||||
showErrorToast(
|
||||
t("records.profileFacts.removeError", {
|
||||
kind: t(KIND_LABEL[current.kind]),
|
||||
}),
|
||||
{ cause: error },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
title={t("records.profileFacts.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = query.isLoading || save.isPending || remove.isPending;
|
||||
const closedHistory = (query.data?.history ?? []).filter(
|
||||
(revision) => revision.validUntil !== null,
|
||||
);
|
||||
|
||||
// A kind is dirty when its draft differs from the stored value and is not the
|
||||
// empty placeholder. `isDirty` drives the render (Save enabled state); the
|
||||
// footer re-collects the same set from the ref at click time.
|
||||
const isDirty = (kind: HealthProfileFactKind, draft: string | undefined) => {
|
||||
const current = query.data?.current[kind] ?? null;
|
||||
return draft !== undefined && draft !== "" && draft !== current?.value;
|
||||
};
|
||||
const dirtyCount = HEALTH_PROFILE_FACT_KINDS.filter((kind) =>
|
||||
isDirty(kind, drafts[kind]),
|
||||
).length;
|
||||
const collectDirtyInputs = () =>
|
||||
HEALTH_PROFILE_FACT_KINDS.flatMap((kind) => {
|
||||
const draft = draftsRef.current[kind];
|
||||
if (!isDirty(kind, draft)) return [];
|
||||
return [
|
||||
{
|
||||
kind,
|
||||
value: draft as string,
|
||||
current: query.data?.current[kind] ?? null,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="health-profile-facts-manager">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{HEALTH_PROFILE_FACT_KINDS.map((kind) => {
|
||||
const current = query.data?.current[kind] ?? null;
|
||||
const value = drafts[kind] ?? current?.value ?? "";
|
||||
const failed = failedKinds[kind] === true;
|
||||
return (
|
||||
<div key={kind} className="space-y-2">
|
||||
<Label htmlFor={`health-profile-fact-${kind}`}>
|
||||
{t(KIND_LABEL[kind])}
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => editDraft(kind, next)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`health-profile-fact-${kind}`}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
current?.unreadable
|
||||
? t("records.profileFacts.unreadable")
|
||||
: t("records.profileFacts.notRecorded")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VALUES[kind].map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`records.profileFacts.values.${kind}.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Per-entry row now carries only the quiet destructive action:
|
||||
saving is one shared footer button (UI-STANDARDS §11), so the
|
||||
three fields no longer each stack their own grey Save slab.
|
||||
When a shared Save partially fails, the kind that didn't land
|
||||
says so here rather than behind the toast. */}
|
||||
{failed || current ? (
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
{failed ? (
|
||||
<span
|
||||
className="text-destructive text-xs"
|
||||
data-slot={`health-profile-fact-error-${kind}`}
|
||||
>
|
||||
{t("records.profileFacts.saveKindError")}
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
{current ? (
|
||||
<ConfirmButton
|
||||
// Short label inside the row; the full phrase stays the
|
||||
// accessible name, because "Remove" on its own is
|
||||
// ambiguous once three of them are on screen.
|
||||
label={t("records.profileFacts.removeShort")}
|
||||
ariaLabel={t("records.profileFacts.remove", {
|
||||
kind: t(KIND_LABEL[kind]),
|
||||
})}
|
||||
title={t("records.profileFacts.removeConfirmTitle", {
|
||||
kind: t(KIND_LABEL[kind]),
|
||||
})}
|
||||
body={t("records.profileFacts.removeConfirmDescription")}
|
||||
confirmLabel={t("records.profileFacts.remove", {
|
||||
kind: t(KIND_LABEL[kind]),
|
||||
})}
|
||||
onConfirm={() => remove.mutate(current)}
|
||||
pending={
|
||||
remove.isPending && remove.variables?.id === current.id
|
||||
}
|
||||
disabled={disabled}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
slot={`health-profile-fact-remove-${kind}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{closedHistory.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("records.profileFacts.historyTitle")}
|
||||
</h3>
|
||||
<ul className="divide-border divide-y text-sm">
|
||||
{closedHistory.map((revision) => (
|
||||
<li
|
||||
key={revision.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 py-2"
|
||||
>
|
||||
<span>
|
||||
{t(KIND_LABEL[revision.kind])}:{" "}
|
||||
{revision.unreadable
|
||||
? t("records.profileFacts.unreadable")
|
||||
: revision.value
|
||||
? t(
|
||||
`records.profileFacts.values.${revision.kind}.${revision.value}`,
|
||||
)
|
||||
: t("records.profileFacts.notRecorded")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmt.dateTime(revision.validFrom)}
|
||||
{revision.validUntil ? (
|
||||
<>
|
||||
{" "}
|
||||
{t("records.profileFacts.to")}{" "}
|
||||
{fmt.dateTime(revision.validUntil)}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* One Save for the whole tile. It writes only the dirty kinds and stays
|
||||
disabled until at least one field differs from its stored value. It
|
||||
is the last thing in the card, below the history (design standards
|
||||
§12). */}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={disabled || dirtyCount === 0}
|
||||
onClick={() => save.mutate(collectDirtyInputs())}
|
||||
>
|
||||
{save.isPending && (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{t("records.profileFacts.save")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.43 (#614) — screening hints from the life circumstances, in Settings →
|
||||
* Vorsorge. Low-dose CT for lung cancer and the abdominal-aorta ultrasound,
|
||||
* each with its status, the reasons in the account's language and the rule's
|
||||
* source and country. A hint, never a reminder: nothing is scheduled here,
|
||||
* and the card says so. Resolved on the server; rendered as delivered.
|
||||
*
|
||||
* The card stays away until the person has said something about smoking,
|
||||
* because every hint would otherwise read "not enough information".
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Stethoscope } from "lucide-react";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export function ScreeningHintsCard() {
|
||||
const { t } = useTranslations();
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.lifeCircumstances(),
|
||||
queryFn: () =>
|
||||
apiGet<LifeCircumstancesDto>("/api/anamnesis/life-circumstances"),
|
||||
});
|
||||
const data = query.data;
|
||||
const tobacco = data?.sections.find((section) => section.key === "tobacco");
|
||||
if (!data || !tobacco || tobacco.facts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="screening-hints-card">
|
||||
<SettingsCardHeader
|
||||
anchor="screening-hints"
|
||||
icon={Stethoscope}
|
||||
title={t("records.life.screening.cardTitle")}
|
||||
description={t("records.life.screening.cardDescription")}
|
||||
/>
|
||||
<ul className="divide-border divide-y">
|
||||
{data.derived.screeningHints.map((hint) => (
|
||||
<li
|
||||
key={hint.id}
|
||||
className="space-y-1 py-3"
|
||||
data-slot={`screening-hint-${hint.id}`}
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t(`records.life.screening.titles.${hint.id}`)}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t(`records.life.screening.status.${hint.status}`)}
|
||||
{hint.reasonKeys.length > 0
|
||||
? ` ${hint.reasonKeys
|
||||
.map((key) => t(`records.life.screening.reasons.${key}`))
|
||||
.join(" ")}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<a
|
||||
href={hint.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
{t("records.life.screening.source", {
|
||||
source: hint.sourceLabel,
|
||||
})}
|
||||
</a>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("records.life.screening.noReminder")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -35,8 +35,8 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
|
||||
vi.mock("@/components/records/conditions-manager", () => ({
|
||||
ConditionsManager: () => null,
|
||||
}));
|
||||
vi.mock("@/components/records/health-profile-facts-manager", () => ({
|
||||
HealthProfileFactsManager: () => null,
|
||||
vi.mock("@/components/records/life-circumstances-manager", () => ({
|
||||
LifeCircumstancesManager: () => null,
|
||||
}));
|
||||
vi.mock("@/components/records/allergy-manager", () => ({
|
||||
AllergyManager: ({
|
||||
|
||||
@@ -18,8 +18,8 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
|
||||
vi.mock("@/components/records/conditions-manager", () => ({
|
||||
ConditionsManager: () => <div data-slot="conditions" />,
|
||||
}));
|
||||
vi.mock("@/components/records/health-profile-facts-manager", () => ({
|
||||
HealthProfileFactsManager: () => null,
|
||||
vi.mock("@/components/records/life-circumstances-manager", () => ({
|
||||
LifeCircumstancesManager: () => null,
|
||||
}));
|
||||
vi.mock("@/components/records/allergy-manager", () => ({
|
||||
AllergyManager: () => null,
|
||||
|
||||
@@ -40,7 +40,7 @@ import { ConditionsManager } from "@/components/records/conditions-manager";
|
||||
import { EmergencyProfileManager } from "@/components/records/emergency-profile-manager";
|
||||
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
|
||||
import { AiProfileInclusionManager } from "@/components/records/ai-profile-inclusion-manager";
|
||||
import { HealthProfileFactsManager } from "@/components/records/health-profile-facts-manager";
|
||||
import { LifeCircumstancesManager } from "@/components/records/life-circumstances-manager";
|
||||
|
||||
export function AnamnesisSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -99,14 +99,17 @@ export function AnamnesisSection() {
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* v1.43 (#614) — the life circumstances took over the three profile
|
||||
facts (smoking status and shift work live in their sections now;
|
||||
alcohol is AUDIT-C). */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="profile-facts"
|
||||
anchor="life-circumstances"
|
||||
icon={ClipboardList}
|
||||
title={t("records.profileFacts.cardTitle")}
|
||||
description={t("records.profileFacts.cardDescription")}
|
||||
title={t("records.life.cardTitle")}
|
||||
description={t("records.life.cardDescription")}
|
||||
/>
|
||||
<HealthProfileFactsManager />
|
||||
<LifeCircumstancesManager />
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard>
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
*/
|
||||
|
||||
import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings";
|
||||
import { ScreeningHintsCard } from "@/components/records/screening-hints-card";
|
||||
|
||||
export function VorsorgeSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<VorsorgeSettings />
|
||||
{/* v1.43 (#614) — hints from the life circumstances, with their source. */}
|
||||
<ScreeningHintsCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
allergyFindMany: vi.fn(),
|
||||
familyFindMany: vi.fn(),
|
||||
factFindMany: vi.fn(),
|
||||
auditFindFirst: vi.fn(),
|
||||
decrypt: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -16,6 +17,7 @@ vi.mock("@/lib/db", () => ({
|
||||
allergy: { findMany: mocks.allergyFindMany },
|
||||
familyHistoryEntry: { findMany: mocks.familyFindMany },
|
||||
healthProfileFactRevision: { findMany: mocks.factFindMany },
|
||||
mentalHealthAssessment: { findFirst: mocks.auditFindFirst },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -44,6 +46,7 @@ beforeEach(() => {
|
||||
mocks.allergyFindMany.mockResolvedValue([]);
|
||||
mocks.familyFindMany.mockResolvedValue([]);
|
||||
mocks.factFindMany.mockResolvedValue([]);
|
||||
mocks.auditFindFirst.mockResolvedValue(null);
|
||||
mocks.decrypt.mockImplementation((bytes: Uint8Array) => {
|
||||
if (bytes[0] === 1) return secret;
|
||||
if (bytes[0] === 2) return "Asthma";
|
||||
@@ -166,4 +169,89 @@ describe("profile section access in AI prompt paths", () => {
|
||||
expect(mocks.factFindMany).toHaveBeenCalledOnce();
|
||||
expect(text).toContain("Penicillin");
|
||||
});
|
||||
|
||||
describe("life circumstances (v1.43)", () => {
|
||||
const habitsBytes = new Uint8Array([20]);
|
||||
const goalsBytes = new Uint8Array([21]);
|
||||
const validFrom = new Date("2026-03-01T00:00:00.000Z");
|
||||
|
||||
beforeEach(() => {
|
||||
// Answer with exactly the kinds the query asked for, the way the
|
||||
// database would, so a kind left out of the query is never returned.
|
||||
mocks.factFindMany.mockImplementation(
|
||||
({ where }: { where: { kind: { in: string[] } } }) =>
|
||||
Promise.resolve(
|
||||
[
|
||||
{ kind: "HABITS", valueEncrypted: habitsBytes, validFrom },
|
||||
{ kind: "HEALTH_GOALS", valueEncrypted: goalsBytes, validFrom },
|
||||
].filter((row) => where.kind.in.includes(row.kind)),
|
||||
),
|
||||
);
|
||||
mocks.decrypt.mockImplementation((bytes: Uint8Array) => {
|
||||
if (bytes[0] === 20) {
|
||||
return JSON.stringify({
|
||||
schema: 1,
|
||||
napRegularly: null,
|
||||
snoringReported: true,
|
||||
dietPattern: "vegan",
|
||||
dietSpecifics: [],
|
||||
lateMealsPerWeek: null,
|
||||
fruitVegPortionsPerDay: null,
|
||||
caffeineDrinksPerDay: 4,
|
||||
latestCaffeine: "after_18",
|
||||
});
|
||||
}
|
||||
if (bytes[0] === 21) {
|
||||
return JSON.stringify({ schema: 1, goals: ["sleep"] });
|
||||
}
|
||||
return "unknown";
|
||||
});
|
||||
});
|
||||
|
||||
it("never decrypts a section without its switch", async () => {
|
||||
mockIncluded(["GOALS"]);
|
||||
const text = await getSelfContextTextForUser("user-1", "en");
|
||||
|
||||
const decrypted = mocks.decrypt.mock.calls.map(([bytes]) => bytes);
|
||||
expect(decrypted).not.toContain(habitsBytes);
|
||||
expect(decrypted).toContain(goalsBytes);
|
||||
const query = mocks.factFindMany.mock.calls[0][0] as {
|
||||
where: { kind: { in: string[] } };
|
||||
};
|
||||
expect(query.where.kind.in).not.toContain("HABITS");
|
||||
expect(text).toContain("Health goals: sleep");
|
||||
expect(text).toContain("self-report, valid since 2026-03");
|
||||
expect(text).not.toContain("vegan");
|
||||
});
|
||||
|
||||
it("adds the section once it is switched on", async () => {
|
||||
mockIncluded(["HABITS"]);
|
||||
const text = await getSelfContextTextForUser("user-1", "en");
|
||||
expect(mocks.decrypt.mock.calls.map(([bytes]) => bytes)).toContain(
|
||||
habitsBytes,
|
||||
);
|
||||
expect(text).toContain("Diet pattern: vegan");
|
||||
expect(text).toContain("Latest caffeine: after 18");
|
||||
});
|
||||
|
||||
it("gives AUDIT-C as a sum with its screening verdict under the alcohol switch only", async () => {
|
||||
mocks.auditFindFirst.mockResolvedValue({
|
||||
totalScore: 5,
|
||||
takenAt: new Date("2026-10-01T09:00:00.000Z"),
|
||||
locale: "de",
|
||||
});
|
||||
mocks.userFindUnique.mockResolvedValue({ gender: "MALE", locale: "de" });
|
||||
|
||||
mockIncluded(["ALCOHOL_PATTERN"]);
|
||||
const text = await getSelfContextTextForUser("user-1", "de");
|
||||
expect(text).toContain(
|
||||
"AUDIT-C (Screening, keine Diagnose): 5 von 12 am 2026-10-01, Screening-Grenzwert 5 erreicht",
|
||||
);
|
||||
|
||||
mocks.auditFindFirst.mockClear();
|
||||
mockIncluded(["GOALS"]);
|
||||
await getSelfContextTextForUser("user-1", "de");
|
||||
expect(mocks.auditFindFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+368
-63
@@ -27,6 +27,7 @@
|
||||
* Server-only — reads `@/lib/db`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -40,7 +41,24 @@ import {
|
||||
type HealthProfileFactKind,
|
||||
type HealthProfileFactValue,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
import { decryptHealthProfileFactValue } from "@/lib/profile/health-facts";
|
||||
import {
|
||||
decryptHealthProfileFactValue,
|
||||
decryptLifeFactValue,
|
||||
} from "@/lib/profile/health-facts";
|
||||
import { LIFE_KIND_AI_SECTION } from "@/lib/profile/life-derived";
|
||||
import { auditCCutoff } from "@/lib/mental-health/audit-c";
|
||||
import {
|
||||
LIFE_FACT_KINDS,
|
||||
isLifeFactKind,
|
||||
type ActivityHabitValue,
|
||||
type FreeWeekdaysValue,
|
||||
type HabitsValue,
|
||||
type HealthGoalsValue,
|
||||
type LifeFactKind,
|
||||
type LifeFactValue,
|
||||
type TobaccoDetailValue,
|
||||
type WorkContextValue,
|
||||
} from "@/lib/validations/life-circumstances";
|
||||
|
||||
export interface SelfContext {
|
||||
aboutMe: string | null;
|
||||
@@ -397,6 +415,260 @@ export function composeStructuredHealthFactsText(
|
||||
return lines.length > 0 ? lines.join("\n") : null;
|
||||
}
|
||||
|
||||
// ── Life circumstances (v1.43, #614) ─────────────────────────────────────────
|
||||
|
||||
type LifeLang = "de" | "en";
|
||||
|
||||
const LIFE_LABELS: Record<LifeLang, Record<string, string>> = {
|
||||
de: {
|
||||
selfReport: "Selbstauskunft, gilt seit",
|
||||
products: "Tabakprodukte",
|
||||
cigarettes: "Zigaretten pro Tag",
|
||||
startYear: "Rauchbeginn",
|
||||
quit: "Rauchstopp",
|
||||
secondhand: "Passivrauch",
|
||||
activity: "Mäßige bis anstrengende Bewegung",
|
||||
activityValue: "{days} Tage pro Woche, je {minutes} Minuten",
|
||||
sitting: "Sitzen an Werktagen (Stunden)",
|
||||
situation: "Arbeitssituation",
|
||||
nature: "Art der Arbeit",
|
||||
night: "Nachtarbeit",
|
||||
exposures: "Belastungen bei der Arbeit",
|
||||
freeDays: "Freie Wochentage",
|
||||
nap: "Regelmäßiger Mittagsschlaf",
|
||||
snoring: "Schnarchen laut anderen",
|
||||
diet: "Ernährungsweise",
|
||||
dietSpecifics: "Besonderheiten",
|
||||
lateMeals: "Mahlzeiten nach 21 Uhr pro Woche",
|
||||
fruitVeg: "Portionen Obst und Gemüse pro Tag",
|
||||
caffeine: "Koffeinhaltige Getränke pro Tag",
|
||||
latestCaffeine: "Spätestes Koffein",
|
||||
goals: "Gesundheitsziele",
|
||||
auditC: "AUDIT-C (Screening, keine Diagnose)",
|
||||
auditCValue: "{score} von 12 am {date}",
|
||||
above: "Screening-Grenzwert {cutoff} erreicht",
|
||||
below: "unter dem Screening-Grenzwert {cutoff}",
|
||||
yes: "ja",
|
||||
no: "nein",
|
||||
},
|
||||
en: {
|
||||
selfReport: "self-report, valid since",
|
||||
products: "Tobacco products",
|
||||
cigarettes: "Cigarettes per day",
|
||||
startYear: "Started smoking",
|
||||
quit: "Stopped smoking",
|
||||
secondhand: "Secondhand smoke",
|
||||
activity: "Moderate to strenuous activity",
|
||||
activityValue: "{days} days a week, {minutes} minutes each",
|
||||
sitting: "Sitting on a workday (hours)",
|
||||
situation: "Work situation",
|
||||
nature: "Physical nature of work",
|
||||
night: "Night work",
|
||||
exposures: "Exposures at work",
|
||||
freeDays: "Free weekdays",
|
||||
nap: "Regular nap",
|
||||
snoring: "Snoring reported by others",
|
||||
diet: "Diet pattern",
|
||||
dietSpecifics: "Specifics",
|
||||
lateMeals: "Meals after 9 pm per week",
|
||||
fruitVeg: "Fruit and vegetable portions per day",
|
||||
caffeine: "Caffeinated drinks per day",
|
||||
latestCaffeine: "Latest caffeine",
|
||||
goals: "Health goals",
|
||||
auditC: "AUDIT-C (screening, not a diagnosis)",
|
||||
auditCValue: "{score} of 12 on {date}",
|
||||
above: "at or above the screening cut-off of {cutoff}",
|
||||
below: "below the screening cut-off of {cutoff}",
|
||||
yes: "yes",
|
||||
no: "no",
|
||||
},
|
||||
};
|
||||
|
||||
const WEEKDAY_NAMES: Record<LifeLang, readonly string[]> = {
|
||||
de: ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"],
|
||||
en: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
};
|
||||
|
||||
function fill(
|
||||
template: string,
|
||||
params: Record<string, string | number>,
|
||||
): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) =>
|
||||
String(params[key] ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
/** One current life-circumstance revision as the prompt block reads it. */
|
||||
export interface LifeFactForPrompt {
|
||||
kind: LifeFactKind;
|
||||
value: LifeFactValue;
|
||||
validFrom: Date;
|
||||
}
|
||||
|
||||
/** The newest AUDIT-C entry as the prompt block reads it: the sum and the cut-off verdict only. */
|
||||
export interface AuditCForPrompt {
|
||||
totalScore: number;
|
||||
takenAt: Date;
|
||||
/** The zone the entry was taken in, for its calendar day. */
|
||||
tz: string;
|
||||
cutoff: number | null;
|
||||
aboveCutoff: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the life-circumstance lines of the self-context block: each line
|
||||
* says it is a self-report and since when it holds, the enum values stay in
|
||||
* their wire form where a translation would add nothing (`fumes_solvents`
|
||||
* reads fine to a model), and AUDIT-C is a sum with a screening verdict,
|
||||
* never a label for the person. Null when there is nothing to say.
|
||||
*/
|
||||
export function composeLifeCircumstancesText(
|
||||
facts: readonly LifeFactForPrompt[],
|
||||
auditC: AuditCForPrompt | null,
|
||||
locale: string,
|
||||
): string | null {
|
||||
const lang: LifeLang = locale === "de" ? "de" : "en";
|
||||
const L = LIFE_LABELS[lang];
|
||||
const yesNo = (v: boolean) => (v ? L.yes : L.no);
|
||||
const since = (d: Date) => `(${L.selfReport} ${d.toISOString().slice(0, 7)})`;
|
||||
const words = (list: readonly string[]) =>
|
||||
list.map((w) => w.replace(/_/g, " ")).join(", ");
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const fact of facts) {
|
||||
const parts: string[] = [];
|
||||
switch (fact.kind) {
|
||||
case "TOBACCO_DETAIL": {
|
||||
const v = fact.value as TobaccoDetailValue;
|
||||
if (v.products.length > 0)
|
||||
parts.push(`${L.products}: ${words(v.products)}`);
|
||||
if (v.cigarettesPerDay !== null)
|
||||
parts.push(`${L.cigarettes}: ${v.cigarettesPerDay}`);
|
||||
if (v.startYear !== null) parts.push(`${L.startYear}: ${v.startYear}`);
|
||||
if (v.quitMonth !== null) parts.push(`${L.quit}: ${v.quitMonth}`);
|
||||
if (v.secondhandSmoke !== null)
|
||||
parts.push(`${L.secondhand}: ${yesNo(v.secondhandSmoke)}`);
|
||||
break;
|
||||
}
|
||||
case "ACTIVITY_HABIT": {
|
||||
const v = fact.value as ActivityHabitValue;
|
||||
if (
|
||||
v.moderateDaysPerWeek !== null &&
|
||||
v.moderateMinutesPerDay !== null
|
||||
) {
|
||||
parts.push(
|
||||
`${L.activity}: ${fill(L.activityValue, {
|
||||
days: v.moderateDaysPerWeek,
|
||||
minutes: v.moderateMinutesPerDay,
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
if (v.sittingHoursWorkday !== null)
|
||||
parts.push(`${L.sitting}: ${v.sittingHoursWorkday}`);
|
||||
break;
|
||||
}
|
||||
case "WORK_CONTEXT": {
|
||||
const v = fact.value as WorkContextValue;
|
||||
if (v.situation !== null)
|
||||
parts.push(`${L.situation}: ${words([v.situation])}`);
|
||||
if (v.physicalNature !== null)
|
||||
parts.push(`${L.nature}: ${words([v.physicalNature])}`);
|
||||
if (v.nightWork !== null)
|
||||
parts.push(`${L.night}: ${yesNo(v.nightWork)}`);
|
||||
if (v.exposures.length > 0)
|
||||
parts.push(`${L.exposures}: ${words(v.exposures)}`);
|
||||
break;
|
||||
}
|
||||
case "FREE_WEEKDAYS": {
|
||||
const v = fact.value as FreeWeekdaysValue;
|
||||
parts.push(
|
||||
`${L.freeDays}: ${[...v.days]
|
||||
.sort((a, b) => a - b)
|
||||
.map((d) => WEEKDAY_NAMES[lang][d - 1])
|
||||
.join(", ")}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "HABITS": {
|
||||
const v = fact.value as HabitsValue;
|
||||
if (v.napRegularly !== null)
|
||||
parts.push(`${L.nap}: ${yesNo(v.napRegularly)}`);
|
||||
if (v.snoringReported !== null)
|
||||
parts.push(`${L.snoring}: ${yesNo(v.snoringReported)}`);
|
||||
if (v.dietPattern !== null)
|
||||
parts.push(`${L.diet}: ${words([v.dietPattern])}`);
|
||||
if (v.dietSpecifics.length > 0)
|
||||
parts.push(`${L.dietSpecifics}: ${words(v.dietSpecifics)}`);
|
||||
if (v.lateMealsPerWeek !== null)
|
||||
parts.push(`${L.lateMeals}: ${v.lateMealsPerWeek}`);
|
||||
if (v.fruitVegPortionsPerDay !== null)
|
||||
parts.push(`${L.fruitVeg}: ${v.fruitVegPortionsPerDay}`);
|
||||
if (v.caffeineDrinksPerDay !== null)
|
||||
parts.push(`${L.caffeine}: ${v.caffeineDrinksPerDay}`);
|
||||
if (v.latestCaffeine !== null)
|
||||
parts.push(`${L.latestCaffeine}: ${words([v.latestCaffeine])}`);
|
||||
break;
|
||||
}
|
||||
case "HEALTH_GOALS": {
|
||||
const v = fact.value as HealthGoalsValue;
|
||||
if (v.goals.length > 0) parts.push(`${L.goals}: ${words(v.goals)}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (parts.length > 0)
|
||||
lines.push(`${parts.join("; ")} ${since(fact.validFrom)}`);
|
||||
}
|
||||
|
||||
if (auditC) {
|
||||
const verdict =
|
||||
auditC.cutoff === null || auditC.aboveCutoff === null
|
||||
? ""
|
||||
: `, ${fill(auditC.aboveCutoff ? L.above : L.below, { cutoff: auditC.cutoff })}`;
|
||||
lines.push(
|
||||
`${L.auditC}: ${fill(L.auditCValue, {
|
||||
score: auditC.totalScore,
|
||||
date: userDayKey(auditC.takenAt, auditC.tz),
|
||||
})}${verdict}`,
|
||||
);
|
||||
}
|
||||
return lines.length > 0 ? lines.join("\n") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest AUDIT-C entry for a prompt: only when the alcohol section is
|
||||
* released, and without decrypting anything (the sum is stored in the clear,
|
||||
* like the screeners'). The profile sex for the cut-off is read only when
|
||||
* there is an entry to judge.
|
||||
*/
|
||||
async function loadAuditCForPrompt(
|
||||
userId: string,
|
||||
included: ReadonlySet<string>,
|
||||
): Promise<AuditCForPrompt | null> {
|
||||
if (!included.has("ALCOHOL_PATTERN")) return null;
|
||||
const audit = await prisma.mentalHealthAssessment.findFirst({
|
||||
where: { userId, instrument: "AUDIT_C", deletedAt: null },
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
select: { totalScore: true, takenAt: true, locale: true, tz: true },
|
||||
});
|
||||
if (!audit) return null;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { gender: true, locale: true, timezone: true },
|
||||
});
|
||||
const cutoff = auditCCutoff(
|
||||
audit.locale,
|
||||
user?.gender ?? null,
|
||||
user?.locale ?? "en",
|
||||
);
|
||||
return {
|
||||
totalScore: audit.totalScore,
|
||||
takenAt: audit.takenAt,
|
||||
tz: audit.tz ?? user?.timezone ?? "UTC",
|
||||
cutoff: cutoff?.value ?? null,
|
||||
aboveCutoff: cutoff ? audit.totalScore >= cutoff.value : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the merged self-context text the prompt block quotes: profile
|
||||
* facts first (age/gender from the User row — the single source, never
|
||||
@@ -486,51 +758,64 @@ export async function getSelfContextTextForUser(
|
||||
(controlRow?.aiIncludedSections ??
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS) as HealthProfileAiSection[],
|
||||
);
|
||||
const includedFactKinds = (
|
||||
["SMOKING_STATUS", "ALCOHOL_PATTERN", "SHIFT_SCHEDULE"] as const
|
||||
).filter((kind) => included.has(kind));
|
||||
const includedStored: ReadonlySet<string> = new Set<string>(
|
||||
controlRow?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
);
|
||||
// One read for every fact kind a released section covers: the three
|
||||
// closed kinds and the life-circumstance kinds (v1.43). A kind whose
|
||||
// section is not released is never read, so it is never decrypted.
|
||||
const includedFactKinds = [
|
||||
...(["SMOKING_STATUS", "ALCOHOL_PATTERN", "SHIFT_SCHEDULE"] as const),
|
||||
...LIFE_FACT_KINDS,
|
||||
].filter((kind) =>
|
||||
includedStored.has(
|
||||
isLifeFactKind(kind) ? LIFE_KIND_AI_SECTION[kind] : kind,
|
||||
),
|
||||
);
|
||||
|
||||
const [ctx, user, allergyRows, familyRows, factRows] = await Promise.all([
|
||||
getSelfContextForUser(userId, prisma, included),
|
||||
included.has("ABOUT_ME")
|
||||
? prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dateOfBirth: true, gender: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
included.has("ALLERGIES")
|
||||
? prisma.allergy.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
substance: true,
|
||||
type: true,
|
||||
severity: true,
|
||||
status: true,
|
||||
reactionEncrypted: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
included.has("FAMILY_HISTORY")
|
||||
? prisma.familyHistoryEntry.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { relationship: true, condition: true, ageAtOnset: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
includedFactKinds.length > 0
|
||||
? prisma.healthProfileFactRevision.findMany({
|
||||
where: {
|
||||
userId,
|
||||
kind: { in: includedFactKinds },
|
||||
validUntil: null,
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
orderBy: { kind: "asc" },
|
||||
select: { kind: true, valueEncrypted: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const [ctx, user, allergyRows, familyRows, factRows, auditC] =
|
||||
await Promise.all([
|
||||
getSelfContextForUser(userId, prisma, included),
|
||||
included.has("ABOUT_ME")
|
||||
? prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dateOfBirth: true, gender: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
included.has("ALLERGIES")
|
||||
? prisma.allergy.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
substance: true,
|
||||
type: true,
|
||||
severity: true,
|
||||
status: true,
|
||||
reactionEncrypted: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
included.has("FAMILY_HISTORY")
|
||||
? prisma.familyHistoryEntry.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { relationship: true, condition: true, ageAtOnset: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
includedFactKinds.length > 0
|
||||
? prisma.healthProfileFactRevision.findMany({
|
||||
where: {
|
||||
userId,
|
||||
kind: { in: includedFactKinds },
|
||||
validUntil: null,
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
orderBy: { kind: "asc" },
|
||||
select: { kind: true, valueEncrypted: true, validFrom: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
loadAuditCForPrompt(userId, includedStored),
|
||||
]);
|
||||
|
||||
const selfText = composeSelfContextText(
|
||||
ctx,
|
||||
@@ -552,24 +837,36 @@ export async function getSelfContextTextForUser(
|
||||
locale,
|
||||
);
|
||||
const factsText = composeStructuredHealthFactsText(
|
||||
factRows.flatMap((row) => {
|
||||
const decrypted = decryptHealthProfileFactValue(
|
||||
row.kind as HealthProfileFactKind,
|
||||
row.valueEncrypted,
|
||||
);
|
||||
return decrypted.value
|
||||
? [
|
||||
{
|
||||
kind: row.kind as HealthProfileFactKind,
|
||||
value: decrypted.value,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
factRows
|
||||
.filter((row) => !isLifeFactKind(row.kind))
|
||||
.flatMap((row) => {
|
||||
const decrypted = decryptHealthProfileFactValue(
|
||||
row.kind as HealthProfileFactKind,
|
||||
row.valueEncrypted,
|
||||
);
|
||||
return decrypted.value
|
||||
? [
|
||||
{
|
||||
kind: row.kind as HealthProfileFactKind,
|
||||
value: decrypted.value,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
locale,
|
||||
);
|
||||
if (!selfText && !recordsText && !factsText) return null;
|
||||
return [selfText, recordsText, factsText].filter(Boolean).join("\n");
|
||||
const lifeFacts = factRows.flatMap((row) => {
|
||||
if (!isLifeFactKind(row.kind)) return [];
|
||||
const opened = decryptLifeFactValue(row.kind, row.valueEncrypted);
|
||||
return opened.value
|
||||
? [{ kind: row.kind, value: opened.value, validFrom: row.validFrom }]
|
||||
: [];
|
||||
});
|
||||
const lifeText = composeLifeCircumstancesText(lifeFacts, auditC, locale);
|
||||
if (!selfText && !recordsText && !factsText && !lifeText) return null;
|
||||
return [selfText, recordsText, factsText, lifeText]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`coach self-context text load failed: ${
|
||||
@@ -683,7 +980,11 @@ INTERPRETATIONSREGELN:
|
||||
- Das Alkoholkonsum-Muster nur als Kontext nutzen, ohne Menge oder Wirkung zu
|
||||
erfinden.
|
||||
- Feste oder wechselnde Schichtarbeit bei der Schlafzeit-Einordnung
|
||||
berücksichtigen; unregelmäßiges Timing nicht allein als Problem darstellen.`;
|
||||
berücksichtigen; unregelmäßiges Timing nicht allein als Problem darstellen.
|
||||
- Ein AUDIT-C-Wert ist ein Screening-Ergebnis: nie als Diagnose oder
|
||||
Alkoholproblem bezeichnen.
|
||||
- Selbstauskünfte zu Bewegung, Schlaf und Ernährung ergänzen die Messdaten und
|
||||
ersetzen sie nie; bei Widerspruch gelten die Messdaten.`;
|
||||
}
|
||||
return `
|
||||
|
||||
@@ -702,5 +1003,9 @@ INTERPRETATION RULES:
|
||||
smoking cessation.
|
||||
- Use an alcohol pattern as context only, without inventing amount or effect.
|
||||
- Account for fixed or rotating shifts when interpreting sleep timing; do not
|
||||
call irregular timing a problem on that fact alone.`;
|
||||
call irregular timing a problem on that fact alone.
|
||||
- An AUDIT-C score is a screening result: never call it a diagnosis or an
|
||||
alcohol problem.
|
||||
- Self-reported activity, sleep and diet habits add to the measurements and
|
||||
never replace them; on a conflict the measurements win.`;
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
* read plus the shared accumulators, so the emitted shape and ordering
|
||||
* are unchanged.
|
||||
*/
|
||||
import type { FreeWeekdaysSpan } from "@/lib/profile/life-derived";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
@@ -158,6 +159,8 @@ interface SleepRhythmBlockContext {
|
||||
sourcePriorityJson: unknown;
|
||||
userTz: string;
|
||||
ageYears: number | null;
|
||||
/** v1.43 — the record's dated free-weekday answers for the day type. */
|
||||
freeWeekdaySpans: readonly FreeWeekdaysSpan[];
|
||||
snapshot: Record<string, unknown>;
|
||||
metrics: Set<CoachProvenanceMetric>;
|
||||
registerBlock: (key: string, source: CoachScopeSource) => void;
|
||||
@@ -192,6 +195,7 @@ export function buildSleepRhythmBlock(
|
||||
sourcePriorityJson,
|
||||
userTz,
|
||||
ageYears,
|
||||
freeWeekdaySpans,
|
||||
snapshot,
|
||||
metrics,
|
||||
registerBlock,
|
||||
@@ -199,6 +203,8 @@ export function buildSleepRhythmBlock(
|
||||
const rhythm: SleepRhythmDto = computeSleepRhythmFromNights(
|
||||
reconstructNights(sleepRhythmRows, userTz, sourcePriorityJson),
|
||||
sleepNeedMinutes(ageYears),
|
||||
"COMPUTED",
|
||||
freeWeekdaySpans,
|
||||
);
|
||||
const chronotypeReady = rhythm.chronotype.state === "ready";
|
||||
snapshot.sleepRhythm = {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* values with weekday labels and aggregates the older window into
|
||||
* weekly buckets so the prompt budget stays tight.
|
||||
*/
|
||||
import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import type { CoachDataCluster } from "@/lib/validations/coach-prefs";
|
||||
@@ -806,6 +807,7 @@ async function buildCoachSnapshotImpl(
|
||||
sourcePriorityJson: prefsRow?.sourcePriorityJson ?? null,
|
||||
userTz,
|
||||
ageYears: derivedProfile.ageYears,
|
||||
freeWeekdaySpans: await loadFreeWeekdaySpans(userId),
|
||||
snapshot,
|
||||
metrics,
|
||||
registerBlock,
|
||||
|
||||
@@ -39,6 +39,9 @@ import {
|
||||
} from "@/lib/ai/coach/history-reach";
|
||||
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { decryptLifeFactValue } from "@/lib/profile/health-facts";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import type { WorkContextValue } from "@/lib/validations/life-circumstances";
|
||||
|
||||
/** Days listed one by one; the summary covers the whole window. */
|
||||
export const ENVIRONMENT_DAILY_LIMIT = 31;
|
||||
@@ -103,6 +106,17 @@ export interface EnvironmentToolData {
|
||||
pollenPeak?: number | null;
|
||||
};
|
||||
airQuality: EnvironmentAirQualityState;
|
||||
/**
|
||||
* v1.43 (#614) — present only when the person released the work section
|
||||
* to the AI and said they work outdoors (or partly) or with heat, dust or
|
||||
* fumes: hot and poor-air days then weigh more for them. A self-report,
|
||||
* never a measured exposure.
|
||||
*/
|
||||
workContext?: {
|
||||
outdoorWork: boolean;
|
||||
exposures: string[];
|
||||
note: string;
|
||||
};
|
||||
provenance: {
|
||||
weather: string;
|
||||
airQuality: string;
|
||||
@@ -151,6 +165,63 @@ function dayKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const RELEVANT_EXPOSURES = ["heat", "dust", "fumes_solvents"] as const;
|
||||
|
||||
/**
|
||||
* The person's work context as far as it bears on weather and air: only with
|
||||
* the work section released to the AI (the same switch the self-context block
|
||||
* honours), and only when it says something the environment can weigh.
|
||||
*/
|
||||
async function loadWorkContextForEnvironment(
|
||||
userId: string,
|
||||
): Promise<EnvironmentToolData["workContext"] | null> {
|
||||
try {
|
||||
return await readWorkContext(userId);
|
||||
} catch (error) {
|
||||
// Context, not data: a failed read leaves the note out, never the days.
|
||||
getEvent()?.addWarning(
|
||||
`environment work context read failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readWorkContext(
|
||||
userId: string,
|
||||
): Promise<EnvironmentToolData["workContext"] | null> {
|
||||
const profile = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { aiIncludedSections: true },
|
||||
});
|
||||
if (!profile?.aiIncludedSections.includes("WORK")) return null;
|
||||
const row = await prisma.healthProfileFactRevision.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
kind: "WORK_CONTEXT",
|
||||
validUntil: null,
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
select: { valueEncrypted: true },
|
||||
});
|
||||
if (!row) return null;
|
||||
const work = decryptLifeFactValue("WORK_CONTEXT", row.valueEncrypted)
|
||||
.value as WorkContextValue | null;
|
||||
if (!work) return null;
|
||||
const outdoorWork =
|
||||
work.situation === "outdoor" || work.situation === "mixed";
|
||||
const exposures = work.exposures.filter((exposure) =>
|
||||
(RELEVANT_EXPOSURES as readonly string[]).includes(exposure),
|
||||
);
|
||||
if (!outdoorWork && exposures.length === 0) return null;
|
||||
return {
|
||||
outdoorWork,
|
||||
exposures,
|
||||
note: "Self-reported work context: hot nights, heat and very poor air days weigh more for this person than for someone working indoors. Context, never a measured exposure.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the stored environment days for the tool. Never throws for "nothing
|
||||
* there"; a database error propagates to the executor, which reports
|
||||
@@ -314,6 +385,7 @@ export async function readEnvironmentForTool(args: {
|
||||
|
||||
const firstDate = rows[0].date;
|
||||
const lastDate = rows[rows.length - 1].date;
|
||||
const workContext = await loadWorkContextForEnvironment(userId);
|
||||
return {
|
||||
present: true,
|
||||
data: {
|
||||
@@ -331,6 +403,7 @@ export async function readEnvironmentForTool(args: {
|
||||
...(airActive ? { veryPoorAirDays, highPollenDays, pollenPeak } : {}),
|
||||
},
|
||||
airQuality: airState,
|
||||
...(workContext ? { workContext } : {}),
|
||||
provenance: PROVENANCE,
|
||||
attributions: environmentAttributionLines({
|
||||
airQuality: airActive,
|
||||
|
||||
@@ -281,8 +281,8 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
|
||||
},
|
||||
{
|
||||
section: "anamnesis",
|
||||
anchor: "profile-facts",
|
||||
titleKey: "records.profileFacts.cardTitle",
|
||||
anchor: "life-circumstances",
|
||||
titleKey: "records.life.cardTitle",
|
||||
},
|
||||
{
|
||||
section: "anamnesis",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { lifeReportRows } from "@/lib/profile/life-report-rows";
|
||||
import type { jsPDF } from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import type { EncounterKind, Laterality } from "@/generated/prisma/client";
|
||||
@@ -603,6 +604,12 @@ export function buildClinicalRecordsNotesSection(
|
||||
t("doctorReport.anamnesisShiftSchedule"),
|
||||
factValue("SHIFT_SCHEDULE", data.anamnesis.shiftSchedule),
|
||||
],
|
||||
...lifeReportRows(
|
||||
data.anamnesis.life,
|
||||
t,
|
||||
(value) => num(value, 1),
|
||||
fmtDate,
|
||||
),
|
||||
];
|
||||
|
||||
autoTable(doc, {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* `doctor-report-data.ts`.
|
||||
*/
|
||||
|
||||
import type { LifeClinicalSummary } from "@/lib/profile/life-derived";
|
||||
import type { GlucoseUnit } from "@/lib/glucose";
|
||||
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
@@ -463,6 +464,13 @@ export interface DoctorReportData {
|
||||
unreadableFacts: Array<
|
||||
"SMOKING_STATUS" | "ALCOHOL_PATTERN" | "SHIFT_SCHEDULE"
|
||||
>;
|
||||
/**
|
||||
* v1.43 (#614) — the clinical part of the life circumstances: pack-years,
|
||||
* the newest AUDIT-C (a screening result with its cut-off and source),
|
||||
* activity and work. Optional so a report built before v1.43 still
|
||||
* renders.
|
||||
*/
|
||||
life?: LifeClinicalSummary;
|
||||
} | null;
|
||||
/**
|
||||
* Emergency ("Notfalldaten") facts for the page-one section. Present only
|
||||
|
||||
@@ -54,6 +54,10 @@ const { calls, rows, prismaMock } = vi.hoisted(() => {
|
||||
calls.push(name);
|
||||
return Promise.resolve(rows[`${name}Unique`]?.[0] ?? null);
|
||||
},
|
||||
findFirst: () => {
|
||||
calls.push(name);
|
||||
return Promise.resolve(rows[`${name}First`]?.[0] ?? null);
|
||||
},
|
||||
});
|
||||
return {
|
||||
calls,
|
||||
@@ -463,6 +467,8 @@ describe("zero read for unchosen leaves", () => {
|
||||
["ANAMNESIS", "userHealthProfile"],
|
||||
["EMERGENCY", "userHealthProfile"],
|
||||
["ANAMNESIS", "healthProfileFactRevision"],
|
||||
// v1.43 — the newest AUDIT-C entry rides the anamnesis leaf.
|
||||
["ANAMNESIS", "mentalHealthAssessment"],
|
||||
];
|
||||
|
||||
// ANAMNESIS and EMERGENCY both read `userHealthProfile` (and VISITS and
|
||||
|
||||
@@ -23,13 +23,15 @@ import { decryptAllergyReaction } from "@/lib/doctor-report-helpers";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-types";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { decryptHealthProfileFactValue } from "@/lib/profile/health-facts";
|
||||
import { loadLifeClinicalSummary } from "@/lib/profile/life-circumstances";
|
||||
import {
|
||||
emergencyProfileHasContent,
|
||||
readEmergencyProfile,
|
||||
} from "@/lib/profile/emergency-profile";
|
||||
import type {
|
||||
HealthProfileFactKind,
|
||||
HealthProfileFactValue,
|
||||
import {
|
||||
HEALTH_PROFILE_FACT_KINDS,
|
||||
type HealthProfileFactKind,
|
||||
type HealthProfileFactValue,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
|
||||
/**
|
||||
@@ -435,7 +437,7 @@ export async function loadEmergency(
|
||||
export async function loadAnamnesis(
|
||||
userId: string,
|
||||
): Promise<NonNullable<DoctorReportData["anamnesis"]>> {
|
||||
const [profile, factRows] = await Promise.all([
|
||||
const [profile, factRows, life] = await Promise.all([
|
||||
prisma.userHealthProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { conditionsEncrypted: true },
|
||||
@@ -443,11 +445,15 @@ export async function loadAnamnesis(
|
||||
prisma.healthProfileFactRevision.findMany({
|
||||
where: {
|
||||
userId,
|
||||
// The three closed kinds; the life-circumstance kinds arrive through
|
||||
// `loadLifeClinicalSummary` with their own codec.
|
||||
kind: { in: [...HEALTH_PROFILE_FACT_KINDS] },
|
||||
validUntil: null,
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
select: { kind: true, valueEncrypted: true },
|
||||
}),
|
||||
loadLifeClinicalSummary(userId),
|
||||
]);
|
||||
|
||||
let conditions: string | null = null;
|
||||
@@ -492,5 +498,6 @@ export async function loadAnamnesis(
|
||||
DoctorReportData["anamnesis"]
|
||||
>["shiftSchedule"],
|
||||
unreadableFacts,
|
||||
life,
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -121,7 +121,7 @@ export function anamnesisObservationsFromReportData(
|
||||
"SHIFT_SCHEDULE",
|
||||
];
|
||||
|
||||
return kinds.map((kind, index) => {
|
||||
const facts = kinds.map((kind, index): FhirObservation => {
|
||||
const definition = FACT_DEFINITIONS[kind];
|
||||
const value = values[kind];
|
||||
const unreadable = anamnesis.unreadableFacts.includes(kind);
|
||||
@@ -157,6 +157,7 @@ export function anamnesisObservationsFromReportData(
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
return [...facts, ...lifeObservationsFromReportData(data)];
|
||||
}
|
||||
|
||||
/** Raw narrative text; the document builder performs XHTML escaping once. */
|
||||
@@ -182,5 +183,137 @@ export function anamnesisNarrativeFromReportData(
|
||||
`Smoking status: ${factText("SMOKING_STATUS", anamnesis.smokingStatus)}.`,
|
||||
`Alcohol pattern: ${factText("ALCOHOL_PATTERN", anamnesis.alcoholPattern)}.`,
|
||||
`Shift schedule: ${factText("SHIFT_SCHEDULE", anamnesis.shiftSchedule)}.`,
|
||||
...(anamnesis.life?.packYears != null
|
||||
? [`Pack-years: ${anamnesis.life.packYears}.`]
|
||||
: []),
|
||||
...(anamnesis.life?.auditC
|
||||
? [
|
||||
`AUDIT-C (screening): ${anamnesis.life.auditC.totalScore} of 12 on ${anamnesis.life.auditC.takenAt.slice(0, 10)}.`,
|
||||
]
|
||||
: []),
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.43 (#614) — the clinical part of the life circumstances: AUDIT-C as a
|
||||
* screening score (LOINC 75626-2), pack-years (SNOMED CT 401201003), and the
|
||||
* self-reported activity and work context as text observations. Only present
|
||||
* values emit; diet and goals never do (coach-only by design).
|
||||
*/
|
||||
function lifeObservationsFromReportData(
|
||||
data: DoctorReportData,
|
||||
): FhirObservation[] {
|
||||
const life = data.anamnesis?.life;
|
||||
if (!life) return [];
|
||||
const out: FhirObservation[] = [];
|
||||
const base = (id: string, category: string) => ({
|
||||
resourceType: "Observation" as const,
|
||||
id,
|
||||
status: "final" as const,
|
||||
category: [categoryConcept(category)],
|
||||
subject: patientRef,
|
||||
});
|
||||
|
||||
if (life.auditC) {
|
||||
const a = life.auditC;
|
||||
const verdict =
|
||||
a.aboveCutoff === null || a.cutoffValue === null
|
||||
? "No sex-specific cut-off applied (no binary sex in the profile)."
|
||||
: `${a.aboveCutoff ? "At or above" : "Below"} the screening cut-off of ${a.cutoffValue} (${a.cutoffSourceLabel ?? "source not named"}).`;
|
||||
out.push({
|
||||
...base("obs-life-audit-c", "survey"),
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: "75626-2",
|
||||
display: "Total score [AUDIT-C]",
|
||||
},
|
||||
],
|
||||
text: "AUDIT-C total score",
|
||||
},
|
||||
effectiveDateTime: a.takenAt,
|
||||
valueInteger: a.totalScore,
|
||||
note: [
|
||||
{
|
||||
text: `Screening result, not a diagnosis. ${verdict}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (life.packYears !== null) {
|
||||
out.push({
|
||||
...base("obs-life-pack-years", "social-history"),
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: SNOMED_SYSTEM,
|
||||
code: "401201003",
|
||||
display: "Cigarette pack-years",
|
||||
},
|
||||
],
|
||||
text: "Cigarette pack-years",
|
||||
},
|
||||
valueQuantity: { value: life.packYears, unit: "pack-years" },
|
||||
note: [
|
||||
{
|
||||
text: "Computed from self-reported cigarettes per day and years smoked.",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
life.activityMinutesPerWeek !== null ||
|
||||
life.sittingHoursWorkday !== null
|
||||
) {
|
||||
const parts: string[] = [];
|
||||
if (life.activityMinutesPerWeek !== null) {
|
||||
parts.push(
|
||||
`${life.activityMinutesPerWeek} minutes of moderate to strenuous activity per week (Exercise Vital Sign)`,
|
||||
);
|
||||
}
|
||||
if (life.sittingHoursWorkday !== null) {
|
||||
parts.push(`${life.sittingHoursWorkday} hours sitting on a workday`);
|
||||
}
|
||||
out.push({
|
||||
...base("obs-life-activity", "social-history"),
|
||||
code: { text: "Self-reported physical activity" },
|
||||
valueString: `${parts.join("; ")}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const work = life.work;
|
||||
if (work) {
|
||||
const parts = [
|
||||
work.situation ? `situation: ${work.situation.replace(/_/g, " ")}` : null,
|
||||
work.physicalNature
|
||||
? `physical nature: ${work.physicalNature.replace(/_/g, " ")}`
|
||||
: null,
|
||||
work.nightWork === null
|
||||
? null
|
||||
: `night work: ${work.nightWork ? "yes" : "no"}`,
|
||||
work.exposures.length > 0
|
||||
? `exposures: ${work.exposures.map((e) => e.replace(/_/g, " ")).join(", ")}`
|
||||
: null,
|
||||
].filter((part): part is string => part !== null);
|
||||
if (parts.length > 0) {
|
||||
out.push({
|
||||
...base("obs-life-work", "social-history"),
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: "11341-5",
|
||||
display: "History of Occupation",
|
||||
},
|
||||
],
|
||||
text: "Self-reported work context",
|
||||
},
|
||||
valueString: `${parts.join("; ")}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* The answer of a route whose contract is published but whose behaviour has
|
||||
* not landed on this server yet.
|
||||
*
|
||||
* A route answers this only after it has authenticated the caller, resolved
|
||||
* the record, applied its module gate and validated the request exactly as
|
||||
* the published contract says, so a client already sees the real refusals
|
||||
* (401, 403, 404, 422, 429). What is missing is the effect, and the body says
|
||||
* so plainly instead of pretending: `501` with `errorCode: "not_implemented"`.
|
||||
* Nothing calls these routes in normal use: every client path to them sits
|
||||
* behind a server feature flag that stays `false` until the route is filled.
|
||||
*
|
||||
* `contract-stub-inventory.test.ts` freezes the set of route files that still
|
||||
* answer this; it has to be empty before a release is tagged.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const NOT_IMPLEMENTED_ERROR_CODE = "not_implemented";
|
||||
|
||||
/**
|
||||
* 501 for an operation whose contract exists and whose effect does not yet.
|
||||
* `operation` is a stable `<surface>.<noun>.<verb>` name for the wide event.
|
||||
*/
|
||||
export function notImplemented(operation: string): Response {
|
||||
annotate({
|
||||
action: { name: "contract.not_implemented" },
|
||||
meta: { operation },
|
||||
});
|
||||
return apiError("Not implemented on this server yet", 501, {
|
||||
errorCode: NOT_IMPLEMENTED_ERROR_CODE,
|
||||
operation,
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
// Mock the DB so the builder is hermetic — we control the rows.
|
||||
const findMany = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findMany: (...a: unknown[]) => findMany(...a) } },
|
||||
prisma: {
|
||||
measurement: { findMany: (...a: unknown[]) => findMany(...a) },
|
||||
// v1.43 — the free-weekday answers; none here, so the weekend default.
|
||||
healthProfileFactRevision: { findMany: vi.fn(async () => []) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(async () => null),
|
||||
@@ -146,6 +150,21 @@ describe("computeSleepRhythmFromNights window cap", () => {
|
||||
// rhythm route (42-day read) both depend on to render identical values.
|
||||
expect(oneYear.chronotype).toEqual(sixWeeks.chronotype);
|
||||
});
|
||||
|
||||
it("counts the answered free weekdays as free nights instead of the weekend (v1.43)", () => {
|
||||
const nights = trailingNights(42);
|
||||
const weekend = computeSleepRhythmFromNights(nights, 420);
|
||||
const tueWed = computeSleepRhythmFromNights(nights, 420, "COMPUTED", [
|
||||
{
|
||||
validFrom: new Date("2026-01-01T00:00:00.000Z"),
|
||||
validUntil: null,
|
||||
days: [2, 3, 4],
|
||||
},
|
||||
]);
|
||||
expect(weekend.chronotype.freeNightsCounted).toBe(12);
|
||||
// Three free weekdays over six weeks: 18 free nights, not the weekend 12.
|
||||
expect(tueWed.chronotype.freeNightsCounted).toBe(18);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAverageSleep", () => {
|
||||
|
||||
@@ -47,6 +47,11 @@ import {
|
||||
resolveSleepDebtSource,
|
||||
} from "@/lib/validations/source-priority";
|
||||
import { loadBaselineProfile } from "./baseline";
|
||||
import {
|
||||
dayTypeForNight,
|
||||
type FreeWeekdaysSpan,
|
||||
} from "@/lib/profile/life-derived";
|
||||
import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances";
|
||||
import { reconstructNights, sleepNeedMinutes } from "./sleep-score";
|
||||
import {
|
||||
computeSleepDebt,
|
||||
@@ -255,6 +260,10 @@ export function computeSleepRhythmFromNights(
|
||||
// COMPUTED (the only producer today) so the dashboard + Coach snapshot
|
||||
// callers, which always surface our own estimate, need not thread it.
|
||||
sleepDebtSource: MeasurementSource = "COMPUTED",
|
||||
// v1.43 (#614) — the record's dated "free weekdays" answers. Each night is
|
||||
// read against the answer in force on its wake day; a night no answer
|
||||
// covers keeps the weekend default.
|
||||
freeWeekdaySpans: readonly FreeWeekdaysSpan[] = [],
|
||||
): SleepRhythmDto {
|
||||
// Sort ascending by wake-day key so "trailing N" is well-defined regardless
|
||||
// of the caller's input order, then keep only scorable nights.
|
||||
@@ -279,7 +288,7 @@ export function computeSleepRhythmFromNights(
|
||||
night: n.night,
|
||||
midpointMinutes: n.midpoint as number,
|
||||
asleepMinutes: n.asleepMinutes,
|
||||
dayType: defaultDayType(n.night),
|
||||
dayType: dayTypeForNight(n.night, freeWeekdaySpans),
|
||||
}));
|
||||
const chronotype = computeChronotype(chronoNights);
|
||||
|
||||
@@ -310,9 +319,10 @@ export async function buildSleepRhythm(
|
||||
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
|
||||
const now = opts.now ?? new Date();
|
||||
const tz = opts.tz ?? (await resolveUserTimezone(userId));
|
||||
const [priorityJson, profile] = await Promise.all([
|
||||
const [priorityJson, profile, freeWeekdaySpans] = await Promise.all([
|
||||
loadUserSourcePriority(userId),
|
||||
loadBaselineProfile(prisma, userId),
|
||||
loadFreeWeekdaySpans(userId),
|
||||
]);
|
||||
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
|
||||
|
||||
@@ -347,5 +357,10 @@ export async function buildSleepRhythm(
|
||||
const sleepDebtSource = resolveSleepDebtSource(
|
||||
parseSourcePriority(priorityJson),
|
||||
);
|
||||
return computeSleepRhythmFromNights(nights, needMinutes, sleepDebtSource);
|
||||
return computeSleepRhythmFromNights(
|
||||
nights,
|
||||
needMinutes,
|
||||
sleepDebtSource,
|
||||
freeWeekdaySpans,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
auditCCutoff,
|
||||
auditCInstrument,
|
||||
auditCTotal,
|
||||
isValidatedAuditCLocale,
|
||||
normaliseAuditCLocale,
|
||||
} from "../audit-c";
|
||||
import { AUDIT_C_ITEMS, AUDIT_C_STANDARD_DRINK } from "../instruments";
|
||||
|
||||
describe("AUDIT-C cut-offs", () => {
|
||||
it("German entry, woman, sum 4: at the S3 cut-off of 4", () => {
|
||||
const cutoff = auditCCutoff("de", "FEMALE", "de");
|
||||
expect(cutoff).toMatchObject({
|
||||
value: 4,
|
||||
sex: "female",
|
||||
source: "S3_ALCOHOL_DE",
|
||||
});
|
||||
expect(cutoff?.sourceLabel).toContain("Deutschland");
|
||||
expect(auditCTotal([2, 1, 1]) >= cutoff!.value).toBe(true);
|
||||
});
|
||||
|
||||
it("German men 5, Korean 5 and 4, everyone else Bradley 4 and 3", () => {
|
||||
expect(auditCCutoff("de", "MALE", "de")?.value).toBe(5);
|
||||
expect(auditCCutoff("ko", "MALE", "ko")).toMatchObject({
|
||||
value: 5,
|
||||
source: "KNHANES_KR",
|
||||
});
|
||||
expect(auditCCutoff("ko", "FEMALE", "ko")?.value).toBe(4);
|
||||
expect(auditCCutoff("en", "MALE", "en")).toMatchObject({
|
||||
value: 4,
|
||||
source: "BRADLEY_2007",
|
||||
});
|
||||
expect(auditCCutoff("fr", "FEMALE", "fr")?.value).toBe(3);
|
||||
});
|
||||
|
||||
it("has no cut-off without a binary sex in the profile", () => {
|
||||
expect(auditCCutoff("de", null, "de")).toBeNull();
|
||||
expect(auditCCutoff("de", "OTHER", "de")).toBeNull();
|
||||
});
|
||||
|
||||
it("names the country in the reader's language", () => {
|
||||
expect(auditCCutoff("en", "MALE", "es")?.sourceLabel).toContain(
|
||||
"Estados Unidos",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AUDIT-C instrument", () => {
|
||||
it("serves the validated wording in the account language", () => {
|
||||
const de = auditCInstrument("de");
|
||||
expect(de.locale).toBe("de");
|
||||
expect(de.validatedLocale).toBe(true);
|
||||
expect(de.items).toHaveLength(3);
|
||||
expect(de.items[0].options.map((o) => o.value)).toEqual([0, 1, 2, 3, 4]);
|
||||
expect(de.attribution).toBe(
|
||||
"As a WHO-approved instrument, the AUDIT is in the public domain.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the WHO English items, never a translation of its own", () => {
|
||||
for (const locale of ["fr", "es", "it"]) {
|
||||
const instrument = auditCInstrument(locale);
|
||||
expect(instrument.locale).toBe("en");
|
||||
expect(instrument.validatedLocale).toBe(false);
|
||||
expect(instrument.items[0].stem).toBe(AUDIT_C_ITEMS.en.items[0].stem);
|
||||
// The help text is still in the account language.
|
||||
expect(instrument.standardDrink).toBe(AUDIT_C_STANDARD_DRINK[locale]);
|
||||
}
|
||||
});
|
||||
|
||||
it("knows which languages are validated", () => {
|
||||
expect(["de", "en", "ko", "pl"].every(isValidatedAuditCLocale)).toBe(true);
|
||||
expect(["es", "fr", "it"].some(isValidatedAuditCLocale)).toBe(false);
|
||||
expect(normaliseAuditCLocale("pt-BR")).toBe("en");
|
||||
expect(normaliseAuditCLocale("de-AT")).toBe("de");
|
||||
});
|
||||
|
||||
it("carries a standard-drink sentence for every app language", () => {
|
||||
for (const locale of ["de", "en", "es", "fr", "it", "pl", "ko"]) {
|
||||
expect(AUDIT_C_STANDARD_DRINK[locale]?.length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The validated wording must not change: re-wording invalidates the score,
|
||||
* and the WHO licence forbids material changes. The hashes pin the exact
|
||||
* characters per language; a change here needs a new primary source, and
|
||||
* the source note in `instruments.ts` changes with it.
|
||||
*/
|
||||
it("keeps the validated wording byte for byte", () => {
|
||||
const hashes = Object.fromEntries(
|
||||
Object.entries(AUDIT_C_ITEMS).map(([locale, wording]) => [
|
||||
locale,
|
||||
createHash("sha256")
|
||||
.update(JSON.stringify(wording.items))
|
||||
.digest("hex")
|
||||
.slice(0, 16),
|
||||
]),
|
||||
);
|
||||
expect(hashes).toEqual(PINNED);
|
||||
});
|
||||
});
|
||||
|
||||
const PINNED: Record<string, string> = {
|
||||
de: "4c072c57581dfa60",
|
||||
en: "7d91058682d56051",
|
||||
ko: "5617030281f6f963",
|
||||
pl: "1a06ca242af64e54",
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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잔에 해당한다.",
|
||||
};
|
||||
@@ -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 },
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
@@ -471,7 +472,6 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"modules_conflict",
|
||||
"mood_tag_layout_conflict",
|
||||
"not_configured",
|
||||
"not_implemented",
|
||||
"notification_prefs_conflict",
|
||||
"oidc_only",
|
||||
"private_origin_not_approved",
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -557,19 +557,6 @@ export function recordRefusal(...alsoRefusesFor: string[]): RefusalResponse {
|
||||
export const MODULE_DISABLED_DESCRIPTION =
|
||||
'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.';
|
||||
|
||||
/**
|
||||
* The 501 a published operation answers while its behaviour has not landed on
|
||||
* this server (`src/lib/http/not-implemented.ts`). Spread into an operation's
|
||||
* responses with the stub, and removed with it.
|
||||
*/
|
||||
export const notImplementedResponse = {
|
||||
"501": {
|
||||
description:
|
||||
"Not implemented on this server yet: the contract is published, the effect is not. `meta.errorCode` = `not_implemented`, `meta.operation` names the operation. Authentication, module gates and request validation already answer as documented. A client reaches this only by ignoring the server feature flag that guards the operation.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
|
||||
export const moduleDisabledResponse = {
|
||||
"403": {
|
||||
description: MODULE_DISABLED_DESCRIPTION,
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserInsights: mocks.invalidateInsights,
|
||||
invalidateUserDashboardSnapshot: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ getEvent: vi.fn(() => null) }));
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
activityMinutesPerWeek,
|
||||
computePackYears,
|
||||
dayTypeForNight,
|
||||
isReviewDue,
|
||||
screeningHints,
|
||||
type ScreeningInputs,
|
||||
} from "../life-derived";
|
||||
import type { TobaccoDetailValue } from "@/lib/validations/life-circumstances";
|
||||
|
||||
const NOW = new Date("2026-10-16T12:00:00.000Z");
|
||||
|
||||
function tobacco(over: Partial<TobaccoDetailValue> = {}): TobaccoDetailValue {
|
||||
return {
|
||||
schema: 1,
|
||||
products: ["cigarettes"],
|
||||
cigarettesPerDay: 20,
|
||||
startYear: 1990,
|
||||
quitMonth: null,
|
||||
secondhandSmoke: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pack-years", () => {
|
||||
it("20 a day from 1990 to 2020 is 30.0", () => {
|
||||
expect(
|
||||
computePackYears("FORMER", tobacco({ quitMonth: "2020-06" }), NOW),
|
||||
).toEqual({ value: 30, status: "computed" });
|
||||
});
|
||||
|
||||
it("runs to the current year for a current smoker, to one decimal", () => {
|
||||
expect(
|
||||
computePackYears("CURRENT", tobacco({ cigarettesPerDay: 15 }), NOW),
|
||||
).toEqual({ value: 27, status: "computed" });
|
||||
expect(
|
||||
computePackYears(
|
||||
"CURRENT",
|
||||
tobacco({ cigarettesPerDay: 7, startYear: 2010 }),
|
||||
NOW,
|
||||
),
|
||||
).toEqual({ value: 5.6, status: "computed" });
|
||||
});
|
||||
|
||||
it("is missing without a start year, never estimated", () => {
|
||||
expect(
|
||||
computePackYears("CURRENT", tobacco({ startYear: null }), NOW),
|
||||
).toEqual({ value: null, status: "missing_inputs" });
|
||||
expect(computePackYears("CURRENT", null, NOW).status).toBe(
|
||||
"missing_inputs",
|
||||
);
|
||||
});
|
||||
|
||||
it("needs the quit date of a former smoker", () => {
|
||||
expect(computePackYears("FORMER", tobacco(), NOW).status).toBe(
|
||||
"missing_inputs",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply to someone who never smoked or smokes no cigarettes", () => {
|
||||
expect(computePackYears("NEVER", tobacco(), NOW).status).toBe(
|
||||
"not_applicable",
|
||||
);
|
||||
expect(
|
||||
computePackYears(
|
||||
"CURRENT",
|
||||
tobacco({ products: ["e_cigarette"], cigarettesPerDay: null }),
|
||||
NOW,
|
||||
).status,
|
||||
).toBe("not_applicable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity minutes", () => {
|
||||
it("multiplies the two Exercise Vital Sign answers", () => {
|
||||
expect(activityMinutesPerWeek(5, 30)).toEqual({
|
||||
value: 150,
|
||||
meetsWho150: true,
|
||||
});
|
||||
expect(activityMinutesPerWeek(2, 30)).toEqual({
|
||||
value: 60,
|
||||
meetsWho150: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays null with either answer missing", () => {
|
||||
expect(activityMinutesPerWeek(null, 30)).toEqual({
|
||||
value: null,
|
||||
meetsWho150: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("free weekdays per night", () => {
|
||||
// 2026-10-13 is a Tuesday, 2026-10-17 a Saturday.
|
||||
const spans = [
|
||||
{
|
||||
validFrom: new Date("2026-10-01T00:00:00.000Z"),
|
||||
validUntil: null,
|
||||
days: [2, 3],
|
||||
},
|
||||
];
|
||||
|
||||
it("reads a night against the answer in force: Tuesday and Wednesday free", () => {
|
||||
expect(dayTypeForNight("2026-10-13", spans)).toBe("free");
|
||||
expect(dayTypeForNight("2026-10-14", spans)).toBe("free");
|
||||
expect(dayTypeForNight("2026-10-17", spans)).toBe("work");
|
||||
});
|
||||
|
||||
it("keeps the weekend default for nights before the answer", () => {
|
||||
expect(dayTypeForNight("2026-09-26", spans)).toBe("free");
|
||||
expect(dayTypeForNight("2026-09-29", spans)).toBe("work");
|
||||
});
|
||||
|
||||
it("is the weekend default with no answer at all", () => {
|
||||
expect(dayTypeForNight("2026-10-17", [])).toBe("free");
|
||||
expect(dayTypeForNight("2026-10-13", [])).toBe("work");
|
||||
});
|
||||
});
|
||||
|
||||
describe("review due", () => {
|
||||
it("after a year, and never without an answer", () => {
|
||||
expect(isReviewDue(new Date("2025-10-15T00:00:00.000Z"), NOW)).toBe(true);
|
||||
expect(isReviewDue(new Date("2026-01-01T00:00:00.000Z"), NOW)).toBe(false);
|
||||
expect(isReviewDue(null, NOW)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("screening hints", () => {
|
||||
function input(over: Partial<ScreeningInputs> = {}): ScreeningInputs {
|
||||
const detail = over.detail ?? tobacco({ startYear: 1985 });
|
||||
return {
|
||||
ageYears: 60,
|
||||
sex: "MALE",
|
||||
smokingStatus: "CURRENT",
|
||||
detail,
|
||||
packYears: computePackYears(over.smokingStatus ?? "CURRENT", detail, NOW),
|
||||
locale: "de",
|
||||
now: NOW,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
it("suggests the German low-dose CT with the G-BA source and country", () => {
|
||||
const [lung] = screeningHints(input());
|
||||
expect(lung.id).toBe("lung_ldct");
|
||||
expect(lung.status).toBe("suggested");
|
||||
expect(lung.region).toBe("DE");
|
||||
expect(lung.sourceLabel).toContain("G-BA");
|
||||
expect(lung.sourceLabel).toContain("Deutschland");
|
||||
expect(lung.sourceUrl).toMatch(/^https:\/\/www\.g-ba\.de\//);
|
||||
});
|
||||
|
||||
it("names the failing criteria", () => {
|
||||
const [lung] = screeningHints(
|
||||
input({
|
||||
ageYears: 45,
|
||||
detail: tobacco({ startYear: 2015, cigarettesPerDay: 10 }),
|
||||
}),
|
||||
);
|
||||
expect(lung.status).toBe("not_eligible");
|
||||
expect(lung.reasonKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"age_out_of_range",
|
||||
"pack_years_below_15",
|
||||
"smoked_years_below_25",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("says unknown, not no, when the birth date is missing", () => {
|
||||
const [lung] = screeningHints(input({ ageYears: null }));
|
||||
expect(lung.status).toBe("unknown");
|
||||
expect(lung.reasonKeys).toContain("missing_birth_date");
|
||||
});
|
||||
|
||||
it("uses the US rules outside German, with the country in the account language", () => {
|
||||
const [lung, aaa] = screeningHints(input({ locale: "fr" }));
|
||||
expect(lung.region).toBe("US");
|
||||
expect(lung.sourceLabel).toContain("USPSTF");
|
||||
expect(lung.sourceLabel).toContain("États-Unis");
|
||||
expect(aaa.status).toBe("not_eligible");
|
||||
expect(aaa.reasonKeys).toContain("age_out_of_range");
|
||||
});
|
||||
|
||||
it("offers the aorta ultrasound to every man from 65 in Germany, not to women", () => {
|
||||
expect(screeningHints(input({ ageYears: 66 }))[1].status).toBe("suggested");
|
||||
const women = screeningHints(input({ ageYears: 66, sex: "FEMALE" }))[1];
|
||||
expect(women.status).toBe("not_eligible");
|
||||
expect(women.reasonKeys).toContain("sex_not_male");
|
||||
expect(screeningHints(input({ ageYears: 66, sex: null }))[1].status).toBe(
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
|
||||
it("asks the US rule for a smoking history", () => {
|
||||
const never = screeningHints(
|
||||
input({ ageYears: 70, locale: "en", smokingStatus: "NEVER" }),
|
||||
);
|
||||
expect(never[0].reasonKeys).toEqual(["never_smoked"]);
|
||||
expect(never[1].reasonKeys).toContain("never_smoked");
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
+216
-57
@@ -3,7 +3,10 @@ import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
invalidateUserDashboardSnapshot,
|
||||
invalidateUserInsights,
|
||||
} from "@/lib/cache/invalidate";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
HEALTH_PROFILE_FACT_KINDS,
|
||||
@@ -11,6 +14,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 +35,7 @@ export interface HealthProfileFactRevisionDto {
|
||||
|
||||
interface FactRevisionRow {
|
||||
id: string;
|
||||
kind: HealthProfileFactKind;
|
||||
kind: StoredFactKind;
|
||||
valueEncrypted: Uint8Array;
|
||||
validFrom: Date;
|
||||
validUntil: Date | null;
|
||||
@@ -42,7 +51,7 @@ export interface HealthProfileFactsPayload {
|
||||
|
||||
export interface RemovedHealthProfileFactDto {
|
||||
id: string;
|
||||
kind: HealthProfileFactKind;
|
||||
kind: StoredFactKind;
|
||||
removedAt: string;
|
||||
}
|
||||
|
||||
@@ -64,6 +73,8 @@ export async function invalidateHealthProfileFactConsumers(
|
||||
},
|
||||
});
|
||||
invalidateUserInsights(userId);
|
||||
// The dashboard's chronotype reads the free-weekday answers (v1.43).
|
||||
invalidateUserDashboardSnapshot(userId);
|
||||
}
|
||||
|
||||
export function decryptHealthProfileFactValue(
|
||||
@@ -89,10 +100,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 +118,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 +152,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 +319,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 +349,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 +362,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 +441,7 @@ export async function removeHealthProfileFact(
|
||||
|
||||
return {
|
||||
id: current.id,
|
||||
kind: current.kind as HealthProfileFactKind,
|
||||
kind: current.kind as StoredFactKind,
|
||||
removedAt: effectiveAt.toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
/**
|
||||
* 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 {
|
||||
decryptHealthProfileFactValue,
|
||||
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 LifeClinicalSummary,
|
||||
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 LifeFactKind,
|
||||
type LifeSectionKey,
|
||||
type TobaccoDetailValue,
|
||||
type WorkContextValue,
|
||||
} 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. Fail-soft: the answers refine a chronotype the
|
||||
* weekend default already produces, so a failed read falls back to that
|
||||
* default with a warning instead of taking the sleep surfaces down with it.
|
||||
*/
|
||||
export async function loadFreeWeekdaySpans(
|
||||
userId: string,
|
||||
): Promise<FreeWeekdaysSpan[]> {
|
||||
try {
|
||||
const rows = await prisma.healthProfileFactRevision.findMany({
|
||||
where: { userId, kind: "FREE_WEEKDAYS" },
|
||||
orderBy: { validFrom: "asc" },
|
||||
select: { validFrom: true, validUntil: true, valueEncrypted: true },
|
||||
});
|
||||
return rows.flatMap((row) => {
|
||||
const opened = decryptLifeFactValue("FREE_WEEKDAYS", row.valueEncrypted);
|
||||
const value = opened.value as FreeWeekdaysValue | null;
|
||||
return value
|
||||
? [
|
||||
{
|
||||
validFrom: row.validFrom,
|
||||
validUntil: row.validUntil,
|
||||
days: value.days,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
} catch (error) {
|
||||
getEvent()?.addWarning(
|
||||
`free weekdays read failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Assemble the whole read model for one record. */
|
||||
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 confirmedAt: Record<string, string> = {};
|
||||
for (const [key, at] of confirmations(row?.lifeConfirmedJson)) {
|
||||
if ((LIFE_SECTION_KEYS as readonly string[]).includes(key)) {
|
||||
confirmedAt[key] = at.toISOString();
|
||||
}
|
||||
}
|
||||
confirmedAt[section] = stamp;
|
||||
await tx.userHealthProfile.upsert({
|
||||
where: { userId },
|
||||
create: { userId, lifeConfirmedJson: confirmedAt },
|
||||
update: { lifeConfirmedJson: confirmedAt },
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clinical summary (doctor report, FHIR) ───────────────────────────────────
|
||||
|
||||
export async function loadLifeClinicalSummary(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<LifeClinicalSummary> {
|
||||
const [user, rows, audit] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { gender: true, locale: true },
|
||||
}),
|
||||
prisma.healthProfileFactRevision.findMany({
|
||||
where: {
|
||||
userId,
|
||||
kind: {
|
||||
in: [
|
||||
"SMOKING_STATUS",
|
||||
"TOBACCO_DETAIL",
|
||||
"ACTIVITY_HABIT",
|
||||
"WORK_CONTEXT",
|
||||
],
|
||||
},
|
||||
validUntil: null,
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
select: { kind: true, valueEncrypted: true },
|
||||
}),
|
||||
prisma.mentalHealthAssessment.findFirst({
|
||||
where: { userId, instrument: "AUDIT_C", deletedAt: null },
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
select: { id: true, takenAt: true, totalScore: true, locale: true },
|
||||
}),
|
||||
]);
|
||||
const value = <T>(kind: string): T | null => {
|
||||
const row = rows.find((candidate) => candidate.kind === kind);
|
||||
if (!row) return null;
|
||||
const opened =
|
||||
kind === "SMOKING_STATUS"
|
||||
? decryptHealthProfileFactValue("SMOKING_STATUS", row.valueEncrypted)
|
||||
: decryptLifeFactValue(kind as LifeFactKind, row.valueEncrypted);
|
||||
return (opened.value as T | null) ?? null;
|
||||
};
|
||||
const packYears = computePackYears(
|
||||
value<string>("SMOKING_STATUS"),
|
||||
value<TobaccoDetailValue>("TOBACCO_DETAIL"),
|
||||
now,
|
||||
);
|
||||
const activity = value<ActivityHabitValue>("ACTIVITY_HABIT");
|
||||
const work = value<WorkContextValue>("WORK_CONTEXT");
|
||||
const cutoff = audit
|
||||
? auditCCutoff(audit.locale, user?.gender ?? null, user?.locale ?? "en")
|
||||
: null;
|
||||
return {
|
||||
packYears: packYears.value,
|
||||
auditC: audit
|
||||
? {
|
||||
id: audit.id,
|
||||
takenAt: audit.takenAt.toISOString(),
|
||||
totalScore: audit.totalScore,
|
||||
aboveCutoff: cutoff ? audit.totalScore >= cutoff.value : null,
|
||||
cutoffValue: cutoff?.value ?? null,
|
||||
cutoffSourceLabel: cutoff?.sourceLabel ?? null,
|
||||
}
|
||||
: null,
|
||||
activityMinutesPerWeek: activityMinutesPerWeek(
|
||||
activity?.moderateDaysPerWeek ?? null,
|
||||
activity?.moderateMinutesPerDay ?? null,
|
||||
).value,
|
||||
sittingHoursWorkday: activity?.sittingHoursWorkday ?? null,
|
||||
work: work
|
||||
? {
|
||||
situation: work.situation,
|
||||
physicalNature: work.physicalNature,
|
||||
nightWork: work.nightWork,
|
||||
exposures: work.exposures,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* 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,
|
||||
WorkContextValue,
|
||||
} 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;
|
||||
|
||||
/** 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)];
|
||||
}
|
||||
|
||||
// ── Clinical summary ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What the doctor report and the FHIR export carry from the life
|
||||
* circumstances: pack-years, the newest AUDIT-C as a screening result,
|
||||
* activity and work. Diet and goals are deliberately absent (coach-only by
|
||||
* design): they are context for a conversation, not clinical findings.
|
||||
*/
|
||||
export interface LifeClinicalSummary {
|
||||
packYears: number | null;
|
||||
auditC: {
|
||||
id: string;
|
||||
takenAt: string;
|
||||
totalScore: number;
|
||||
aboveCutoff: boolean | null;
|
||||
cutoffValue: number | null;
|
||||
cutoffSourceLabel: string | null;
|
||||
} | null;
|
||||
activityMinutesPerWeek: number | null;
|
||||
sittingHoursWorkday: number | null;
|
||||
work: {
|
||||
situation: WorkContextValue["situation"];
|
||||
physicalNature: WorkContextValue["physicalNature"];
|
||||
nightWork: boolean | null;
|
||||
exposures: WorkContextValue["exposures"];
|
||||
} | null;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* v1.43 (#614) — the life-circumstance rows of the anamnesis section, one
|
||||
* builder for the PDF and the clinician share view so both print the same
|
||||
* lines. Self-reported values, labelled as such; AUDIT-C as a screening
|
||||
* result with its cut-off and source, never as a finding. Diet and goals are
|
||||
* not here on purpose (coach-only by design).
|
||||
*
|
||||
* Client-safe: pure, takes the translator and formatters it renders with.
|
||||
*/
|
||||
import type { LifeClinicalSummary } from "@/lib/profile/life-derived";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
export function lifeReportRows(
|
||||
life: LifeClinicalSummary | undefined,
|
||||
t: Translate,
|
||||
fmtNum: (value: number) => string,
|
||||
fmtDate: (iso: string) => string,
|
||||
): Array<[string, string]> {
|
||||
if (!life) return [];
|
||||
const absent = t("doctorReport.anamnesisNotRecorded");
|
||||
const rows: Array<[string, string]> = [];
|
||||
|
||||
rows.push([
|
||||
t("doctorReport.lifePackYears"),
|
||||
life.packYears === null ? absent : fmtNum(life.packYears),
|
||||
]);
|
||||
|
||||
if (life.auditC) {
|
||||
const a = life.auditC;
|
||||
const score = t("doctorReport.lifeAuditCScore", {
|
||||
score: a.totalScore,
|
||||
date: fmtDate(a.takenAt),
|
||||
});
|
||||
const verdict =
|
||||
a.aboveCutoff === null || a.cutoffValue === null
|
||||
? t("doctorReport.lifeAuditCNoCutoff")
|
||||
: t(
|
||||
a.aboveCutoff
|
||||
? "doctorReport.lifeAuditCAbove"
|
||||
: "doctorReport.lifeAuditCBelow",
|
||||
{ cutoff: a.cutoffValue, source: a.cutoffSourceLabel ?? "" },
|
||||
);
|
||||
rows.push([t("doctorReport.lifeAuditC"), `${score} ${verdict}`]);
|
||||
} else {
|
||||
rows.push([t("doctorReport.lifeAuditC"), absent]);
|
||||
}
|
||||
|
||||
rows.push([
|
||||
t("doctorReport.lifeActivity"),
|
||||
life.activityMinutesPerWeek === null
|
||||
? absent
|
||||
: t("doctorReport.lifeActivityValue", {
|
||||
minutes: fmtNum(life.activityMinutesPerWeek),
|
||||
}),
|
||||
]);
|
||||
rows.push([
|
||||
t("doctorReport.lifeSitting"),
|
||||
life.sittingHoursWorkday === null
|
||||
? absent
|
||||
: t("doctorReport.lifeSittingValue", {
|
||||
hours: fmtNum(life.sittingHoursWorkday),
|
||||
}),
|
||||
]);
|
||||
|
||||
const work = life.work;
|
||||
const workParts: string[] = [];
|
||||
if (work?.situation) {
|
||||
workParts.push(t(`records.life.values.situation.${work.situation}`));
|
||||
}
|
||||
if (work?.physicalNature) {
|
||||
workParts.push(
|
||||
t(`records.life.values.physicalNature.${work.physicalNature}`),
|
||||
);
|
||||
}
|
||||
if (work?.nightWork === true) workParts.push(t("doctorReport.lifeNightWork"));
|
||||
if (work && work.exposures.length > 0) {
|
||||
workParts.push(
|
||||
t("doctorReport.lifeExposures", {
|
||||
list: work.exposures
|
||||
.map((exposure) => t(`records.life.values.exposures.${exposure}`))
|
||||
.join(", "),
|
||||
}),
|
||||
);
|
||||
}
|
||||
rows.push([
|
||||
t("doctorReport.lifeWork"),
|
||||
workParts.length > 0 ? workParts.join(", ") : absent,
|
||||
]);
|
||||
|
||||
return rows;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export const coachKeys = {
|
||||
coachAboutMeQuestions: () => ["coach-about-me", "questions"] as const,
|
||||
/** Encrypted, effective-dated structured anamnesis facts. */
|
||||
healthProfileFacts: () => ["health-profile-facts"] as const,
|
||||
/** v1.43 (#614) — the life-circumstance sections, derived values and AUDIT-C. */
|
||||
lifeCircumstances: () => ["life-circumstances"] as const,
|
||||
/** v1.16.1 — unseen proactive-nudge state for the floating Coach bubble. */
|
||||
coachNudgeStatus: () => ["coach-nudge-status"] as const,
|
||||
/** v1.18.6 (CCH-03) — mark-Coach-seen mutation (clears the FAB unread dot). */
|
||||
|
||||
@@ -70,6 +70,10 @@ export function recordActivityVerbLine(
|
||||
return t("recordSharing.activityVerb.allergyCreate", { name });
|
||||
case "family-history.create":
|
||||
return t("recordSharing.activityVerb.familyHistoryCreate", { name });
|
||||
case "anamnesis.audit_c.create":
|
||||
return t("recordSharing.activityVerb.auditCCreate", { name });
|
||||
case "anamnesis.audit_c.delete":
|
||||
return t("recordSharing.activityVerb.auditCDelete", { name });
|
||||
case "illness.episode.create":
|
||||
return t("recordSharing.activityVerb.illnessEpisodeCreate", { name });
|
||||
case "symptoms.event.create":
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
auditCRequestSchema,
|
||||
factWriteSchema,
|
||||
isStoredFactPlaintext,
|
||||
lifeAiSectionsPatchSchema,
|
||||
} from "../life-circumstances";
|
||||
|
||||
const tobacco = {
|
||||
schema: 1,
|
||||
products: ["cigarettes"],
|
||||
cigarettesPerDay: 20,
|
||||
startYear: 1990,
|
||||
quitMonth: "2020-06",
|
||||
secondhandSmoke: null,
|
||||
};
|
||||
|
||||
describe("fact writes (v1.43)", () => {
|
||||
it("still takes the three closed kinds with their string value", () => {
|
||||
expect(
|
||||
factWriteSchema.safeParse({ kind: "SMOKING_STATUS", value: "FORMER" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
factWriteSchema.safeParse({ kind: "SMOKING_STATUS", value: "ROTATING" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("takes a life kind with its object value and an optional day", () => {
|
||||
const parsed = factWriteSchema.safeParse({
|
||||
kind: "TOBACCO_DETAIL",
|
||||
value: tobacco,
|
||||
validFrom: "2026-03-01",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses cigarettes per day without cigarettes, and unknown keys", () => {
|
||||
expect(
|
||||
factWriteSchema.safeParse({
|
||||
kind: "TOBACCO_DETAIL",
|
||||
value: { ...tobacco, products: ["e_cigarette"] },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
factWriteSchema.safeParse({
|
||||
kind: "TOBACCO_DETAIL",
|
||||
value: { ...tobacco, packYears: 30 },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("caps the goals at three and the sitting hours at half-hour steps", () => {
|
||||
expect(
|
||||
factWriteSchema.safeParse({
|
||||
kind: "HEALTH_GOALS",
|
||||
value: {
|
||||
schema: 1,
|
||||
goals: ["sleep", "weight", "fitness", "blood_sugar"],
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
factWriteSchema.safeParse({
|
||||
kind: "ACTIVITY_HABIT",
|
||||
value: {
|
||||
schema: 1,
|
||||
moderateDaysPerWeek: 3,
|
||||
moderateMinutesPerDay: 30,
|
||||
sittingHoursWorkday: 7.25,
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("checks a backup's plaintext the way a write checks the value", () => {
|
||||
expect(isStoredFactPlaintext("SHIFT_SCHEDULE", "ROTATING")).toBe(true);
|
||||
expect(
|
||||
isStoredFactPlaintext("TOBACCO_DETAIL", JSON.stringify(tobacco)),
|
||||
).toBe(true);
|
||||
expect(isStoredFactPlaintext("TOBACCO_DETAIL", "{")).toBe(false);
|
||||
expect(isStoredFactPlaintext("HABITS", JSON.stringify(tobacco))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI switches and AUDIT-C requests", () => {
|
||||
it("needs at least one section", () => {
|
||||
expect(lifeAiSectionsPatchSchema.safeParse({ sections: {} }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
lifeAiSectionsPatchSchema.safeParse({ sections: { HABITS: true } })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("takes three items from 0 to 4", () => {
|
||||
expect(
|
||||
auditCRequestSchema.safeParse({ items: [0, 4, 2], source: "WEB" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
auditCRequestSchema.safeParse({ items: [0, 5, 2], source: "WEB" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
auditCRequestSchema.safeParse({ items: [0, 1], source: "WEB" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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, stored at noon UTC like every date-only value. 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user