mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(anamnesis): serve the life circumstances, record AUDIT-C, and keep both off the closed legacy lists
This commit is contained in:
1 parent
1e7fc08f57
commit
f2be772eaf
28 files changed
+2131
-220
No files matched your search
@@ -28,15 +28,6 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
"admin.pulse_day_mean.get",
|
||||
"admin.pulse_day_mean.post",
|
||||
],
|
||||
"anamnesis/audit-c/[id]/route.ts": ["anamnesis.audit_c.delete"],
|
||||
"anamnesis/audit-c/route.ts": ["anamnesis.audit_c.create"],
|
||||
"anamnesis/life-circumstances/ai-sections/route.ts": [
|
||||
"anamnesis.life_circumstances.ai_sections.patch",
|
||||
],
|
||||
"anamnesis/life-circumstances/confirm/route.ts": [
|
||||
"anamnesis.life_circumstances.confirm",
|
||||
],
|
||||
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
|
||||
"dawarich/connect/route.ts": ["dawarich.connect"],
|
||||
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
|
||||
"dawarich/places/route.ts": ["dawarich.places.get"],
|
||||
@@ -53,7 +44,7 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
};
|
||||
|
||||
/** The size of the register when it was written; see the header. */
|
||||
const REGISTER_SIZE = 15;
|
||||
const REGISTER_SIZE = 10;
|
||||
|
||||
function routeFiles(): string[] {
|
||||
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
/**
|
||||
* `DELETE /api/anamnesis/audit-c/{id}` — remove an AUDIT-C entry (#614).
|
||||
* Soft delete; answers `{ id, removedAt }`, 404 `anamnesis.auditC.notFound`.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C
|
||||
* lands the stub resolves the caller's own record only.
|
||||
* Soft delete (`deletedAt`); answers `{ id, removedAt }`, 404
|
||||
* `anamnesis.auditC.notFound` for an id that is not a live AUDIT-C entry of
|
||||
* the record. Authority `requireRecordAuth("manage", "profile")`, like the
|
||||
* create.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { removeAuditC } from "@/lib/profile/life-circumstances";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
await requireAuth();
|
||||
await params;
|
||||
return notImplemented("anamnesis.audit_c.delete");
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
const { id } = await params;
|
||||
|
||||
const removed = await removeAuditC(user.id, id);
|
||||
if (!removed) {
|
||||
return apiError("AUDIT-C entry not found", 404, {
|
||||
errorCode: "anamnesis.auditC.notFound",
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("anamnesis.audit_c.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { assessmentId: removed.id, instrument: "AUDIT_C" },
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "anamnesis.audit_c.delete",
|
||||
entity_type: "mental_health_assessment",
|
||||
entity_id: removed.id,
|
||||
},
|
||||
});
|
||||
return apiSuccess(removed);
|
||||
},
|
||||
);
|
||||
@@ -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 { dayStartUtc } from "@/lib/profile/life-derived";
|
||||
import {
|
||||
healthProfileFactCorrectionSchema,
|
||||
validateCorrectedFactValue,
|
||||
type HealthProfileFactKind,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
import {
|
||||
factCorrectionSchema,
|
||||
isLifeFactKind,
|
||||
parseLifeFactValue,
|
||||
} from "@/lib/validations/life-circumstances";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
function invalidValue(): Response {
|
||||
return apiError("Value is not valid for this fact kind", 422, {
|
||||
errorCode: "anamnesis.fact.invalidValue",
|
||||
});
|
||||
}
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -32,7 +44,7 @@ export const PATCH = apiHandler(
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
const parsed = healthProfileFactCorrectionSchema.safeParse(rawBody);
|
||||
const parsed = factCorrectionSchema.safeParse(rawBody);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const current = await prisma.healthProfileFactRevision.findFirst({
|
||||
@@ -46,17 +58,38 @@ export const PATCH = apiHandler(
|
||||
});
|
||||
if (!current) return apiError("Current profile fact not found", 404);
|
||||
|
||||
const value = validateCorrectedFactValue(
|
||||
current.kind as HealthProfileFactKind,
|
||||
parsed.data.value,
|
||||
);
|
||||
if (!value) {
|
||||
return apiError("Value is not valid for this fact kind", 422, {
|
||||
errorCode: "anamnesis.fact.invalidValue",
|
||||
});
|
||||
// The value's form follows the stored kind: a string from the closed set
|
||||
// for the three original kinds, an object with `schema: 1` for the life
|
||||
// circumstances (v1.43, #614).
|
||||
const now = new Date();
|
||||
const validFrom = parsed.data.validFrom
|
||||
? dayStartUtc(parsed.data.validFrom)
|
||||
: undefined;
|
||||
let corrected: { id: string; kind: string } | null;
|
||||
if (isLifeFactKind(current.kind)) {
|
||||
const value =
|
||||
typeof parsed.data.value === "string"
|
||||
? null
|
||||
: parseLifeFactValue(current.kind, parsed.data.value);
|
||||
if (!value) return invalidValue();
|
||||
corrected = await correctLifeFact(user.id, id, value, now, validFrom);
|
||||
} else {
|
||||
const value =
|
||||
typeof parsed.data.value === "string"
|
||||
? validateCorrectedFactValue(
|
||||
current.kind as HealthProfileFactKind,
|
||||
parsed.data.value,
|
||||
)
|
||||
: null;
|
||||
if (!value) return invalidValue();
|
||||
corrected = await correctHealthProfileFact(
|
||||
user.id,
|
||||
id,
|
||||
value,
|
||||
now,
|
||||
validFrom,
|
||||
);
|
||||
}
|
||||
|
||||
const corrected = await correctHealthProfileFact(user.id, id, value);
|
||||
if (!corrected) {
|
||||
return apiError("Profile fact changed since it was loaded", 409, {
|
||||
errorCode: "anamnesis.fact.conflict",
|
||||
|
||||
@@ -14,10 +14,18 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import {
|
||||
createHealthProfileFact,
|
||||
createLifeFact,
|
||||
readHealthProfileFacts,
|
||||
} from "@/lib/profile/health-facts";
|
||||
import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts";
|
||||
import { dayStartUtc } from "@/lib/profile/life-derived";
|
||||
import { factWriteSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
/**
|
||||
* `GET` lists the three closed kinds only (`SMOKING_STATUS`, `ALCOHOL_PATTERN`,
|
||||
* `SHIFT_SCHEDULE`): older app builds decode this list closed. `POST` also
|
||||
* takes the six life-circumstance kinds (v1.43, #614), which are read back
|
||||
* through `GET /api/anamnesis/life-circumstances`.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
const payload = await readHealthProfileFacts(user.id);
|
||||
@@ -38,15 +46,33 @@ async function postFact(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
const parsed = healthProfileFactWriteSchema.safeParse(rawBody);
|
||||
const parsed = factWriteSchema.safeParse(rawBody);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const now = new Date();
|
||||
const validFrom = parsed.data.validFrom
|
||||
? dayStartUtc(parsed.data.validFrom)
|
||||
: undefined;
|
||||
try {
|
||||
const fact = await createHealthProfileFact(
|
||||
user.id,
|
||||
parsed.data.kind,
|
||||
parsed.data.value,
|
||||
);
|
||||
const input = parsed.data;
|
||||
const fact =
|
||||
input.kind === "SMOKING_STATUS" ||
|
||||
input.kind === "ALCOHOL_PATTERN" ||
|
||||
input.kind === "SHIFT_SCHEDULE"
|
||||
? await createHealthProfileFact(
|
||||
user.id,
|
||||
input.kind,
|
||||
input.value,
|
||||
now,
|
||||
validFrom,
|
||||
)
|
||||
: await createLifeFact(
|
||||
user.id,
|
||||
input.kind,
|
||||
input.value,
|
||||
now,
|
||||
validFrom,
|
||||
);
|
||||
await auditLog("anamnesis.fact.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -6,17 +6,26 @@
|
||||
* key, over `SMOKING_STATUS`, `ALCOHOL_PATTERN`, `SHIFT_SCHEDULE`, `ACTIVITY`,
|
||||
* `WORK`, `HABITS`, `GOALS`. Sections not named keep their state. A stale
|
||||
* `baseUpdatedAt` is 409 `about_me_conflict`, the token `about-me` uses.
|
||||
* Own account only.
|
||||
* Own account only: which of a person's answers a model may read is that
|
||||
* person's decision.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { updateLifeAiSections } from "@/lib/profile/life-circumstances";
|
||||
import { lifeAiSectionsPatchSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
await requireAuth();
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
@@ -29,5 +38,30 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("anamnesis.life_circumstances.ai_sections.patch");
|
||||
const outcome = await updateLifeAiSections(
|
||||
user.id,
|
||||
parsed.data.sections,
|
||||
parsed.data.baseUpdatedAt ? new Date(parsed.data.baseUpdatedAt) : undefined,
|
||||
);
|
||||
if (outcome.conflict) {
|
||||
annotate({ action: { name: "anamnesis.life_sections.conflict" } });
|
||||
return apiError("Self-context changed since it was loaded", 409, {
|
||||
errorCode: "about_me_conflict",
|
||||
});
|
||||
}
|
||||
|
||||
// Section names and switch states only; nothing about the answers.
|
||||
await auditLog("anamnesis.life_sections.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { sections: parsed.data.sections },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "anamnesis.life_sections.update" },
|
||||
meta: { changed: Object.keys(parsed.data.sections).length },
|
||||
});
|
||||
return apiSuccess({
|
||||
aiSections: outcome.aiSections,
|
||||
updatedAt: outcome.updatedAt,
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,20 @@
|
||||
/**
|
||||
* `POST /api/anamnesis/life-circumstances/confirm` — "still true" for one
|
||||
* section (#614). `{ section }`; stores the confirmation instant only, never a
|
||||
* new revision, and answers `{ section, lastConfirmedAt }`. Own account only.
|
||||
* new revision, and answers `{ section, lastConfirmedAt }`. Own account only,
|
||||
* like the answers' AI switches: it vouches for the person's own account of
|
||||
* their life, which a delegate cannot do for them.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { confirmLifeSection } from "@/lib/profile/life-circumstances";
|
||||
import { lifeConfirmSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await requireAuth();
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
@@ -24,5 +27,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("anamnesis.life_circumstances.confirm");
|
||||
const lastConfirmedAt = await confirmLifeSection(
|
||||
user.id,
|
||||
parsed.data.section,
|
||||
);
|
||||
annotate({
|
||||
action: { name: "anamnesis.life_section.confirm" },
|
||||
meta: { section: parsed.data.section },
|
||||
});
|
||||
return apiSuccess({ section: parsed.data.section, lastConfirmedAt });
|
||||
});
|
||||
@@ -9,11 +9,24 @@
|
||||
* read it under the `profile` grant, like the facts.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildLifeCircumstances } from "@/lib/profile/life-circumstances";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireRecordAuth("read", "profile");
|
||||
return notImplemented("anamnesis.life_circumstances.get");
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
const payload = await buildLifeCircumstances(user.id);
|
||||
annotate({
|
||||
action: { name: "anamnesis.life_circumstances.get" },
|
||||
meta: {
|
||||
fact_count: payload.sections.reduce(
|
||||
(sum, section) => sum + section.facts.length,
|
||||
0,
|
||||
),
|
||||
audit_c_count: payload.auditC.history.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
@@ -64,7 +64,9 @@ import {
|
||||
} from "@/lib/validations/about-me";
|
||||
import {
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
type HealthProfileAiSection,
|
||||
HEALTH_PROFILE_AI_SECTIONS,
|
||||
aboutMeAiSections,
|
||||
type StoredHealthProfileAiSection,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
|
||||
const PUT_RATE_LIMIT = 30;
|
||||
@@ -101,9 +103,13 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
getAiCapability("aboutMeQuestions"),
|
||||
]);
|
||||
const aiIncludedSections =
|
||||
(row?.aiIncludedSections as HealthProfileAiSection[] | undefined) ??
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS;
|
||||
// The eight sections this wire has always carried. The life-circumstance
|
||||
// sections (v1.43) live in the same column but are switched and read
|
||||
// through `/api/anamnesis/life-circumstances`; older app builds decode this
|
||||
// list closed.
|
||||
const aiIncludedSections = aboutMeAiSections(
|
||||
row?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
);
|
||||
// A stored set may be model-written. While the capability is unavailable
|
||||
// an outstanding set is swapped for the deterministic hints; an answered
|
||||
// (empty) set stays empty.
|
||||
@@ -201,7 +207,7 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
conditionsEncrypted?: Uint8Array<ArrayBuffer> | null;
|
||||
allergiesEncrypted?: Uint8Array<ArrayBuffer> | null;
|
||||
coachFocusEncrypted?: Uint8Array<ArrayBuffer> | null;
|
||||
aiIncludedSections?: HealthProfileAiSection[];
|
||||
aiIncludedSections?: StoredHealthProfileAiSection[];
|
||||
} = {};
|
||||
const aboutMePayload = encryptOptional(parsed.data.aboutMe);
|
||||
if (aboutMePayload !== undefined) {
|
||||
@@ -219,9 +225,6 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
if (coachFocusPayload !== undefined) {
|
||||
update.coachFocusEncrypted = coachFocusPayload;
|
||||
}
|
||||
if (parsed.data.aiIncludedSections !== undefined) {
|
||||
update.aiIncludedSections = parsed.data.aiIncludedSections;
|
||||
}
|
||||
const fieldLengths: Record<string, number> = {
|
||||
...(text !== undefined ? { aboutMe: text.length } : {}),
|
||||
...(parsed.data.conditions !== undefined
|
||||
@@ -240,6 +243,23 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
// commit while an hour-fresh advisor payload still references excluded
|
||||
// profile content.
|
||||
const persistence = await prisma.$transaction(async (tx) => {
|
||||
// A PUT here replaces the eight sections it lists and leaves the
|
||||
// life-circumstance switches (v1.43) exactly as stored.
|
||||
if (parsed.data.aiIncludedSections !== undefined) {
|
||||
const stored = await tx.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { aiIncludedSections: true },
|
||||
});
|
||||
update.aiIncludedSections = [
|
||||
...parsed.data.aiIncludedSections,
|
||||
...(stored?.aiIncludedSections ?? []).filter(
|
||||
(section) =>
|
||||
!(HEALTH_PROFILE_AI_SECTIONS as readonly string[]).includes(
|
||||
section,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
let updatedAtIso: string;
|
||||
let savedProfileUpdatedAt: Date | null = null;
|
||||
|
||||
@@ -334,10 +354,9 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
select: { aiIncludedSections: true },
|
||||
}),
|
||||
]);
|
||||
const aiIncludedSections = (controlRow?.aiIncludedSections as
|
||||
HealthProfileAiSection[] | undefined) ?? [
|
||||
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
];
|
||||
const aiIncludedSections = aboutMeAiSections(
|
||||
controlRow?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
);
|
||||
const aiContext = filterSelfContextForAi(ctx, aiIncludedSections);
|
||||
const aiContextIsEmpty =
|
||||
aiContext.aboutMe === null &&
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
@@ -122,7 +123,12 @@ export const GET = apiHandler(
|
||||
// Fetch-then-guard against the resolved user (the by-id sibling pattern);
|
||||
// a soft-deleted row 404s like it does on the list.
|
||||
const row = await prisma.mentalHealthAssessment.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
// Screeners only; an AUDIT-C entry (v1.43) is a 404 here.
|
||||
where: {
|
||||
id,
|
||||
deletedAt: null,
|
||||
instrument: { in: [...SCREENER_INSTRUMENTS] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Country names in the app languages, for the source lines of the life
|
||||
* circumstances (screening hints, AUDIT-C cut-offs): a rule always says whose
|
||||
* rule it is, in the language of the account reading it.
|
||||
*
|
||||
* Client-safe, no imports.
|
||||
*/
|
||||
export type SourceCountry = "DE" | "KR" | "US";
|
||||
|
||||
const NAMES: Readonly<Record<string, Readonly<Record<SourceCountry, string>>>> =
|
||||
{
|
||||
de: { DE: "Deutschland", KR: "Südkorea", US: "USA" },
|
||||
en: { DE: "Germany", KR: "South Korea", US: "United States" },
|
||||
es: { DE: "Alemania", KR: "Corea del Sur", US: "Estados Unidos" },
|
||||
fr: { DE: "Allemagne", KR: "Corée du Sud", US: "États-Unis" },
|
||||
it: { DE: "Germania", KR: "Corea del Sud", US: "Stati Uniti" },
|
||||
pl: { DE: "Niemcy", KR: "Korea Południowa", US: "Stany Zjednoczone" },
|
||||
ko: { DE: "독일", KR: "대한민국", US: "미국" },
|
||||
};
|
||||
|
||||
export function countryLabel(
|
||||
country: SourceCountry,
|
||||
locale: string | null | undefined,
|
||||
): string {
|
||||
return (NAMES[locale ?? "en"] ?? NAMES.en)[country];
|
||||
}
|
||||
+210
-56
@@ -11,6 +11,12 @@ import {
|
||||
type HealthProfileFactKind,
|
||||
type HealthProfileFactValue,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
import {
|
||||
parseLifeFactValue,
|
||||
type LifeFactKind,
|
||||
type LifeFactValue,
|
||||
type StoredFactKind,
|
||||
} from "@/lib/validations/life-circumstances";
|
||||
|
||||
export interface HealthProfileFactRevisionDto {
|
||||
id: string;
|
||||
@@ -26,7 +32,7 @@ export interface HealthProfileFactRevisionDto {
|
||||
|
||||
interface FactRevisionRow {
|
||||
id: string;
|
||||
kind: HealthProfileFactKind;
|
||||
kind: StoredFactKind;
|
||||
valueEncrypted: Uint8Array;
|
||||
validFrom: Date;
|
||||
validUntil: Date | null;
|
||||
@@ -42,7 +48,7 @@ export interface HealthProfileFactsPayload {
|
||||
|
||||
export interface RemovedHealthProfileFactDto {
|
||||
id: string;
|
||||
kind: HealthProfileFactKind;
|
||||
kind: StoredFactKind;
|
||||
removedAt: string;
|
||||
}
|
||||
|
||||
@@ -89,10 +95,11 @@ export function decryptHealthProfileFactValue(
|
||||
export function toHealthProfileFactDto(
|
||||
row: FactRevisionRow,
|
||||
): HealthProfileFactRevisionDto {
|
||||
const decrypted = decryptHealthProfileFactValue(row.kind, row.valueEncrypted);
|
||||
const kind = row.kind as HealthProfileFactKind;
|
||||
const decrypted = decryptHealthProfileFactValue(kind, row.valueEncrypted);
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
kind,
|
||||
value: decrypted.value,
|
||||
unreadable: decrypted.unreadable,
|
||||
validFrom: row.validFrom.toISOString(),
|
||||
@@ -106,8 +113,11 @@ export function toHealthProfileFactDto(
|
||||
export async function readHealthProfileFacts(
|
||||
userId: string,
|
||||
): Promise<HealthProfileFactsPayload> {
|
||||
// The three closed kinds only. The table also holds the life-circumstance
|
||||
// kinds since v1.43, and older app builds decode this list against exactly
|
||||
// three values; those kinds are read through the life-circumstances route.
|
||||
const rows = (await prisma.healthProfileFactRevision.findMany({
|
||||
where: { userId },
|
||||
where: { userId, kind: { in: [...HEALTH_PROFILE_FACT_KINDS] } },
|
||||
orderBy: [{ kind: "asc" }, { validFrom: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
@@ -137,43 +147,165 @@ export async function readHealthProfileFacts(
|
||||
return { current, history };
|
||||
}
|
||||
|
||||
const FACT_ROW_SELECT = {
|
||||
id: true,
|
||||
kind: true,
|
||||
valueEncrypted: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
provenance: true,
|
||||
supersededByRevisionId: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
/** The plaintext a fact's ciphertext seals: the closed value, or the life value as JSON. */
|
||||
function sealFactValue(value: string | LifeFactValue): Uint8Array<ArrayBuffer> {
|
||||
return encryptToBytes(
|
||||
typeof value === "string" ? value : JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a life-circumstance value and check it against its kind's schema.
|
||||
* Fail-closed like the closed kinds: ciphertext this host cannot open, or a
|
||||
* value that no longer fits, reads as `unreadable` and never as an answer.
|
||||
*/
|
||||
export function decryptLifeFactValue(
|
||||
kind: LifeFactKind,
|
||||
payload: Uint8Array,
|
||||
): { value: LifeFactValue | null; unreadable: boolean } {
|
||||
try {
|
||||
const value = parseLifeFactValue(
|
||||
kind,
|
||||
JSON.parse(decryptFromBytes(payload)),
|
||||
);
|
||||
if (value) return { value, unreadable: false };
|
||||
getEvent()?.addWarning(`health profile fact has an invalid ${kind} value`);
|
||||
} catch (error) {
|
||||
getEvent()?.addWarning(
|
||||
`health profile fact decrypt failed (${kind}): ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return { value: null, unreadable: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant a new answer starts. A requested day later than now is not an
|
||||
* answer yet and starts now; one earlier than the end of the kind's previous
|
||||
* answer would overlap that interval and starts at its end instead, so the
|
||||
* history stays a sequence of half-open intervals.
|
||||
*/
|
||||
async function resolveStart(
|
||||
tx: Pick<Prisma.TransactionClient, "healthProfileFactRevision">,
|
||||
userId: string,
|
||||
kind: StoredFactKind,
|
||||
requested: Date | undefined,
|
||||
now: Date,
|
||||
): Promise<Date> {
|
||||
if (!requested || requested.getTime() >= now.getTime()) return now;
|
||||
const previous = await tx.healthProfileFactRevision.findFirst({
|
||||
where: { userId, kind, validUntil: { not: null } },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
const floor = previous?.validUntil?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||
return new Date(Math.max(requested.getTime(), floor));
|
||||
}
|
||||
|
||||
async function createFactRevision(
|
||||
userId: string,
|
||||
kind: StoredFactKind,
|
||||
value: string | LifeFactValue,
|
||||
now: Date,
|
||||
validFrom?: Date,
|
||||
): Promise<FactRevisionRow> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const start = await resolveStart(tx, userId, kind, validFrom, now);
|
||||
const row = (await tx.healthProfileFactRevision.create({
|
||||
data: {
|
||||
userId,
|
||||
kind,
|
||||
valueEncrypted: sealFactValue(value),
|
||||
validFrom: start,
|
||||
provenance: "USER_REPORTED",
|
||||
},
|
||||
select: FACT_ROW_SELECT,
|
||||
})) as FactRevisionRow;
|
||||
await invalidateHealthProfileFactConsumers(userId, tx);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createHealthProfileFact(
|
||||
userId: string,
|
||||
kind: HealthProfileFactKind,
|
||||
value: HealthProfileFactValue,
|
||||
now: Date = new Date(),
|
||||
validFrom?: Date,
|
||||
): Promise<HealthProfileFactRevisionDto> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const row = (await tx.healthProfileFactRevision.create({
|
||||
data: {
|
||||
userId,
|
||||
kind,
|
||||
valueEncrypted: encryptToBytes(value),
|
||||
validFrom: now,
|
||||
provenance: "USER_REPORTED",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
valueEncrypted: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
provenance: true,
|
||||
supersededByRevisionId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})) as FactRevisionRow;
|
||||
await invalidateHealthProfileFactConsumers(userId, tx);
|
||||
return toHealthProfileFactDto(row);
|
||||
});
|
||||
return toHealthProfileFactDto(
|
||||
await createFactRevision(userId, kind, value, now, validFrom),
|
||||
);
|
||||
}
|
||||
|
||||
export async function correctHealthProfileFact(
|
||||
/** A life-circumstance revision as the life-circumstances read and the fact writes answer it. */
|
||||
export interface LifeFactRevisionDto {
|
||||
id: string;
|
||||
kind: "SMOKING_STATUS" | "SHIFT_SCHEDULE" | LifeFactKind;
|
||||
value: string | LifeFactValue | null;
|
||||
unreadable: boolean;
|
||||
validFrom: string;
|
||||
validUntil: null;
|
||||
provenance: "USER_REPORTED" | "USER_CORRECTION";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function toLifeFactRevisionDto(row: {
|
||||
id: string;
|
||||
kind: string;
|
||||
valueEncrypted: Uint8Array;
|
||||
validFrom: Date;
|
||||
provenance: "USER_REPORTED" | "USER_CORRECTION";
|
||||
createdAt: Date;
|
||||
}): LifeFactRevisionDto {
|
||||
const kind = row.kind as LifeFactRevisionDto["kind"];
|
||||
const decrypted =
|
||||
kind === "SMOKING_STATUS" || kind === "SHIFT_SCHEDULE"
|
||||
? decryptHealthProfileFactValue(kind, row.valueEncrypted)
|
||||
: decryptLifeFactValue(kind, row.valueEncrypted);
|
||||
return {
|
||||
id: row.id,
|
||||
kind,
|
||||
value: decrypted.value,
|
||||
unreadable: decrypted.unreadable,
|
||||
validFrom: row.validFrom.toISOString(),
|
||||
validUntil: null,
|
||||
provenance: row.provenance,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createLifeFact(
|
||||
userId: string,
|
||||
kind: LifeFactKind,
|
||||
value: LifeFactValue,
|
||||
now: Date = new Date(),
|
||||
validFrom?: Date,
|
||||
): Promise<LifeFactRevisionDto> {
|
||||
return toLifeFactRevisionDto(
|
||||
await createFactRevision(userId, kind, value, now, validFrom),
|
||||
);
|
||||
}
|
||||
|
||||
async function correctFactRevision(
|
||||
userId: string,
|
||||
revisionId: string,
|
||||
value: HealthProfileFactValue,
|
||||
now: Date = new Date(),
|
||||
): Promise<HealthProfileFactRevisionDto | null> {
|
||||
value: string | LifeFactValue,
|
||||
now: Date,
|
||||
validFrom?: Date,
|
||||
): Promise<FactRevisionRow | null> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const current = (await tx.healthProfileFactRevision.findFirst({
|
||||
where: {
|
||||
@@ -182,21 +314,18 @@ export async function correctHealthProfileFact(
|
||||
validUntil: null,
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
valueEncrypted: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
provenance: true,
|
||||
supersededByRevisionId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
select: FACT_ROW_SELECT,
|
||||
})) as FactRevisionRow | null;
|
||||
if (!current) return null;
|
||||
|
||||
// The successor starts at the requested day when there is one, never
|
||||
// later than now and never at or before the revision it replaces.
|
||||
const requested = Math.min(
|
||||
validFrom?.getTime() ?? now.getTime(),
|
||||
now.getTime(),
|
||||
);
|
||||
const effectiveAt = new Date(
|
||||
Math.max(now.getTime(), current.validFrom.getTime() + 1),
|
||||
Math.max(requested, current.validFrom.getTime() + 1),
|
||||
);
|
||||
const successorId = randomUUID();
|
||||
const closed = await tx.healthProfileFactRevision.updateMany({
|
||||
@@ -215,20 +344,11 @@ export async function correctHealthProfileFact(
|
||||
id: successorId,
|
||||
userId,
|
||||
kind: current.kind,
|
||||
valueEncrypted: encryptToBytes(value),
|
||||
valueEncrypted: sealFactValue(value),
|
||||
validFrom: effectiveAt,
|
||||
provenance: "USER_CORRECTION",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
valueEncrypted: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
provenance: true,
|
||||
supersededByRevisionId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
select: FACT_ROW_SELECT,
|
||||
})) as FactRevisionRow;
|
||||
|
||||
await tx.healthProfileFactRevision.update({
|
||||
@@ -237,10 +357,44 @@ export async function correctHealthProfileFact(
|
||||
});
|
||||
await invalidateHealthProfileFactConsumers(userId, tx);
|
||||
|
||||
return toHealthProfileFactDto(successor);
|
||||
return successor;
|
||||
});
|
||||
}
|
||||
|
||||
export async function correctHealthProfileFact(
|
||||
userId: string,
|
||||
revisionId: string,
|
||||
value: HealthProfileFactValue,
|
||||
now: Date = new Date(),
|
||||
validFrom?: Date,
|
||||
): Promise<HealthProfileFactRevisionDto | null> {
|
||||
const row = await correctFactRevision(
|
||||
userId,
|
||||
revisionId,
|
||||
value,
|
||||
now,
|
||||
validFrom,
|
||||
);
|
||||
return row ? toHealthProfileFactDto(row) : null;
|
||||
}
|
||||
|
||||
export async function correctLifeFact(
|
||||
userId: string,
|
||||
revisionId: string,
|
||||
value: LifeFactValue,
|
||||
now: Date = new Date(),
|
||||
validFrom?: Date,
|
||||
): Promise<LifeFactRevisionDto | null> {
|
||||
const row = await correctFactRevision(
|
||||
userId,
|
||||
revisionId,
|
||||
value,
|
||||
now,
|
||||
validFrom,
|
||||
);
|
||||
return row ? toLifeFactRevisionDto(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current effective interval without deleting or superseding its
|
||||
* revision. The closed row remains in dated history, while every current-value
|
||||
@@ -282,7 +436,7 @@ export async function removeHealthProfileFact(
|
||||
|
||||
return {
|
||||
id: current.id,
|
||||
kind: current.kind as HealthProfileFactKind,
|
||||
kind: current.kind as StoredFactKind,
|
||||
removedAt: effectiveAt.toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
/**
|
||||
* v1.43 (#614) — the life circumstances of a record: the read model behind
|
||||
* `GET /api/anamnesis/life-circumstances`, the AI switches, the "still true"
|
||||
* confirmations and the AUDIT-C entries.
|
||||
*
|
||||
* The answers are fact revisions (`HealthProfileFactRevision`, sealed values,
|
||||
* dated, corrected by a successor) and AUDIT-C entries
|
||||
* (`MentalHealthAssessment` with `instrument = AUDIT_C`, sealed item answers,
|
||||
* soft delete). Everything a client shows is resolved here: pack-years,
|
||||
* minutes per week, free weekdays, screening hints, the AUDIT-C cut-off, and
|
||||
* the instrument wording in the account language.
|
||||
*
|
||||
* Server-only.
|
||||
*/
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { deriveAgeYears } from "@/lib/ai/coach/about-me";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
auditCCutoff,
|
||||
auditCInstrument,
|
||||
auditCTotal,
|
||||
isValidatedAuditCLocale,
|
||||
normaliseAuditCLocale,
|
||||
type AuditCCutoff,
|
||||
type AuditCInstrument,
|
||||
} from "@/lib/mental-health/audit-c";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
decryptLifeFactValue,
|
||||
invalidateHealthProfileFactConsumers,
|
||||
toLifeFactRevisionDto,
|
||||
type LifeFactRevisionDto,
|
||||
} from "@/lib/profile/health-facts";
|
||||
import {
|
||||
LIFE_SECTIONS,
|
||||
activityMinutesPerWeek,
|
||||
computePackYears,
|
||||
DEFAULT_FREE_WEEKDAYS,
|
||||
isReviewDue,
|
||||
screeningHints,
|
||||
type FreeWeekdaysSpan,
|
||||
type PackYears,
|
||||
type ScreeningHint,
|
||||
} from "@/lib/profile/life-derived";
|
||||
import {
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
type StoredHealthProfileAiSection,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
import {
|
||||
LIFE_AI_SECTIONS,
|
||||
LIFE_SECTION_KEYS,
|
||||
type ActivityHabitValue,
|
||||
type FreeWeekdaysValue,
|
||||
type LifeAiSection,
|
||||
type LifeSectionKey,
|
||||
type TobaccoDetailValue,
|
||||
} from "@/lib/validations/life-circumstances";
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
/** History entries the read route lists, newest first. */
|
||||
const AUDIT_C_HISTORY_LIMIT = 20;
|
||||
/** AUDIT-C is offered again once the newest entry is this old. */
|
||||
const AUDIT_C_OFFER_AFTER_DAYS = 365;
|
||||
/** A step count this recent means activity is measured, not only reported. */
|
||||
const MEASURED_STEPS_DAYS = 30;
|
||||
|
||||
/**
|
||||
* The sealed answers of one AUDIT-C entry. `schema` versions the blob the same
|
||||
* way the screener responses are versioned.
|
||||
*/
|
||||
interface AuditCResponses {
|
||||
schema: 1;
|
||||
items: [number, number, number];
|
||||
}
|
||||
|
||||
export interface AuditCResult {
|
||||
id: string;
|
||||
takenAt: string;
|
||||
items: [number, number, number];
|
||||
totalScore: number;
|
||||
locale: string;
|
||||
cutoff: AuditCCutoff | null;
|
||||
aboveCutoff: boolean | null;
|
||||
validatedLocale: boolean;
|
||||
}
|
||||
|
||||
export interface LifeCircumstancesDto {
|
||||
sections: Array<{
|
||||
key: LifeSectionKey;
|
||||
aiSection: Exclude<LifeAiSection, "SHIFT_SCHEDULE">;
|
||||
aiIncluded: boolean;
|
||||
lastConfirmedAt: string | null;
|
||||
reviewDue: boolean;
|
||||
facts: LifeFactRevisionDto[];
|
||||
}>;
|
||||
extraAiSections: { SHIFT_SCHEDULE: boolean };
|
||||
derived: {
|
||||
packYears: PackYears;
|
||||
activityMinutesPerWeek: {
|
||||
value: number | null;
|
||||
meetsWho150: boolean | null;
|
||||
measuredStepsAvailable: boolean;
|
||||
};
|
||||
freeWeekdays: { days: number[]; source: "fact" | "default_weekend" };
|
||||
screeningHints: ScreeningHint[];
|
||||
};
|
||||
auditC: {
|
||||
latest: AuditCResult | null;
|
||||
history: Array<{ id: string; takenAt: string; totalScore: number }>;
|
||||
offerDue: boolean;
|
||||
instrument: AuditCInstrument;
|
||||
};
|
||||
takeOver: Array<{
|
||||
field: "conditions" | "allergies";
|
||||
freeTextPresent: boolean;
|
||||
structuredCount: number;
|
||||
available: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** The kinds the read route shows: the six new ones and the two closed ones living in a section. */
|
||||
const SECTION_KINDS = [
|
||||
...new Set(LIFE_SECTIONS.flatMap((section) => section.kinds)),
|
||||
];
|
||||
|
||||
function storedSections(
|
||||
stored: readonly string[] | null | undefined,
|
||||
): Set<StoredHealthProfileAiSection> {
|
||||
return new Set(
|
||||
(stored ??
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS) as StoredHealthProfileAiSection[],
|
||||
);
|
||||
}
|
||||
|
||||
/** The stored confirmations, read leniently: a stamp that does not parse counts as none. */
|
||||
function confirmations(
|
||||
value: Prisma.JsonValue | null | undefined,
|
||||
): Map<string, Date> {
|
||||
const out = new Map<string, Date>();
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return out;
|
||||
for (const [key, stamp] of Object.entries(value)) {
|
||||
if (typeof stamp !== "string") continue;
|
||||
const at = new Date(stamp);
|
||||
if (!Number.isNaN(at.getTime())) out.set(key, at);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function latest(...dates: Array<Date | null | undefined>): Date | null {
|
||||
let best: Date | null = null;
|
||||
for (const date of dates) {
|
||||
if (date && (!best || date > best)) best = date;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Open the sealed AUDIT-C answers; null when this host cannot read them. */
|
||||
function openAuditCResponses(payload: Uint8Array): AuditCResponses | null {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
decryptFromBytes(payload),
|
||||
) as Partial<AuditCResponses>;
|
||||
const items = parsed.items;
|
||||
if (
|
||||
Array.isArray(items) &&
|
||||
items.length === 3 &&
|
||||
items.every((item) => Number.isInteger(item) && item >= 0 && item <= 4)
|
||||
) {
|
||||
return { schema: 1, items: items as [number, number, number] };
|
||||
}
|
||||
getEvent()?.addWarning("audit-c responses shape drifted");
|
||||
} catch (error) {
|
||||
getEvent()?.addWarning(
|
||||
`audit-c responses decrypt failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One stored entry scored for a profile sex; the source line follows `labelLocale`. */
|
||||
export function toAuditCResult(
|
||||
row: { id: string; takenAt: Date; totalScore: number; locale: string },
|
||||
items: [number, number, number],
|
||||
sex: string | null,
|
||||
labelLocale: string,
|
||||
): AuditCResult {
|
||||
const cutoff = auditCCutoff(row.locale, sex, labelLocale);
|
||||
return {
|
||||
id: row.id,
|
||||
takenAt: row.takenAt.toISOString(),
|
||||
items,
|
||||
totalScore: row.totalScore,
|
||||
locale: row.locale,
|
||||
cutoff,
|
||||
aboveCutoff: cutoff ? row.totalScore >= cutoff.value : null,
|
||||
validatedLocale: isValidatedAuditCLocale(row.locale),
|
||||
};
|
||||
}
|
||||
|
||||
/** The dated free-weekday answers of a record, oldest first, for the night-by-night day type. */
|
||||
export async function loadFreeWeekdaySpans(
|
||||
userId: string,
|
||||
): Promise<FreeWeekdaysSpan[]> {
|
||||
const rows = await prisma.healthProfileFactRevision.findMany({
|
||||
where: { userId, kind: "FREE_WEEKDAYS" },
|
||||
orderBy: { validFrom: "asc" },
|
||||
select: { validFrom: true, validUntil: true, valueEncrypted: true },
|
||||
});
|
||||
return rows.flatMap((row) => {
|
||||
const opened = decryptLifeFactValue("FREE_WEEKDAYS", row.valueEncrypted);
|
||||
const value = opened.value as FreeWeekdaysValue | null;
|
||||
return value
|
||||
? [
|
||||
{
|
||||
validFrom: row.validFrom,
|
||||
validUntil: row.validUntil,
|
||||
days: value.days,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Assemble the whole read model for one record. */
|
||||
export async function buildLifeCircumstances(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<LifeCircumstancesDto> {
|
||||
const [
|
||||
user,
|
||||
profile,
|
||||
factRows,
|
||||
auditRows,
|
||||
steps,
|
||||
illnessCount,
|
||||
allergyCount,
|
||||
illnessOn,
|
||||
] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dateOfBirth: true, gender: true, locale: true },
|
||||
}),
|
||||
prisma.userHealthProfile.findUnique({
|
||||
where: { userId },
|
||||
select: {
|
||||
aiIncludedSections: true,
|
||||
lifeConfirmedJson: true,
|
||||
conditionsEncrypted: true,
|
||||
allergiesEncrypted: true,
|
||||
},
|
||||
}),
|
||||
prisma.healthProfileFactRevision.findMany({
|
||||
where: {
|
||||
userId,
|
||||
kind: { in: SECTION_KINDS },
|
||||
validUntil: null,
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
valueEncrypted: true,
|
||||
validFrom: true,
|
||||
provenance: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.mentalHealthAssessment.findMany({
|
||||
where: { userId, instrument: "AUDIT_C", deletedAt: null },
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
take: AUDIT_C_HISTORY_LIMIT,
|
||||
select: {
|
||||
id: true,
|
||||
takenAt: true,
|
||||
totalScore: true,
|
||||
locale: true,
|
||||
responsesEncrypted: true,
|
||||
},
|
||||
}),
|
||||
prisma.measurement.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type: "ACTIVITY_STEPS",
|
||||
deletedAt: null,
|
||||
measuredAt: {
|
||||
gte: new Date(now.getTime() - MEASURED_STEPS_DAYS * MS_PER_DAY),
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.illnessEpisode.count({
|
||||
where: { userId, lifecycle: "CHRONIC_ONGOING", deletedAt: null },
|
||||
}),
|
||||
prisma.allergy.count({
|
||||
where: { userId, status: "ACTIVE", deletedAt: null },
|
||||
}),
|
||||
isModuleEnabled(userId, "illness"),
|
||||
]);
|
||||
|
||||
const locale = user?.locale ?? "en";
|
||||
const sex = user?.gender ?? null;
|
||||
const included = storedSections(profile?.aiIncludedSections);
|
||||
const confirmed = confirmations(profile?.lifeConfirmedJson);
|
||||
const facts = factRows.map(toLifeFactRevisionDto);
|
||||
const factOf = <T>(kind: string): T | null =>
|
||||
(facts.find((fact) => fact.kind === kind)?.value as T | undefined) ?? null;
|
||||
|
||||
// AUDIT-C: the newest entry whose answers this host can open is the one
|
||||
// shown in full; the history lists totals, which need no decryption.
|
||||
let latestAuditC: AuditCResult | null = null;
|
||||
for (const row of auditRows) {
|
||||
const opened = openAuditCResponses(row.responsesEncrypted);
|
||||
if (opened) {
|
||||
latestAuditC = toAuditCResult(row, opened.items, sex, locale);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const newestAuditC = auditRows[0]?.takenAt ?? null;
|
||||
|
||||
const sections = LIFE_SECTIONS.map((section) => {
|
||||
const sectionFacts = facts.filter((fact) =>
|
||||
(section.kinds as readonly string[]).includes(fact.kind),
|
||||
);
|
||||
const lastAnswer = latest(
|
||||
...sectionFacts.map((fact) => new Date(fact.createdAt)),
|
||||
section.key === "alcohol" ? newestAuditC : null,
|
||||
);
|
||||
const lastConfirmed = latest(lastAnswer, confirmed.get(section.key));
|
||||
return {
|
||||
key: section.key,
|
||||
aiSection: section.aiSection,
|
||||
aiIncluded: included.has(section.aiSection),
|
||||
lastConfirmedAt: lastConfirmed?.toISOString() ?? null,
|
||||
reviewDue: isReviewDue(lastConfirmed, now),
|
||||
facts: sectionFacts,
|
||||
};
|
||||
});
|
||||
|
||||
const smokingStatus = factOf<string>("SMOKING_STATUS");
|
||||
const tobacco = factOf<TobaccoDetailValue>("TOBACCO_DETAIL");
|
||||
const activity = factOf<ActivityHabitValue>("ACTIVITY_HABIT");
|
||||
const freeDays = factOf<FreeWeekdaysValue>("FREE_WEEKDAYS");
|
||||
const packYears = computePackYears(smokingStatus, tobacco, now);
|
||||
const minutes = activityMinutesPerWeek(
|
||||
activity?.moderateDaysPerWeek ?? null,
|
||||
activity?.moderateMinutesPerDay ?? null,
|
||||
);
|
||||
|
||||
return {
|
||||
sections,
|
||||
extraAiSections: { SHIFT_SCHEDULE: included.has("SHIFT_SCHEDULE") },
|
||||
derived: {
|
||||
packYears,
|
||||
activityMinutesPerWeek: {
|
||||
...minutes,
|
||||
measuredStepsAvailable: steps !== null,
|
||||
},
|
||||
freeWeekdays: freeDays
|
||||
? { days: [...freeDays.days].sort((a, b) => a - b), source: "fact" }
|
||||
: { days: [...DEFAULT_FREE_WEEKDAYS], source: "default_weekend" },
|
||||
screeningHints: screeningHints({
|
||||
ageYears: deriveAgeYears(user?.dateOfBirth ?? null, now),
|
||||
sex,
|
||||
smokingStatus,
|
||||
detail: tobacco,
|
||||
packYears,
|
||||
locale,
|
||||
now,
|
||||
}),
|
||||
},
|
||||
auditC: {
|
||||
latest: latestAuditC,
|
||||
history: auditRows.map((row) => ({
|
||||
id: row.id,
|
||||
takenAt: row.takenAt.toISOString(),
|
||||
totalScore: row.totalScore,
|
||||
})),
|
||||
offerDue:
|
||||
newestAuditC === null ||
|
||||
now.getTime() - newestAuditC.getTime() >=
|
||||
AUDIT_C_OFFER_AFTER_DAYS * MS_PER_DAY,
|
||||
instrument: auditCInstrument(locale),
|
||||
},
|
||||
takeOver: [
|
||||
{
|
||||
field: "conditions",
|
||||
freeTextPresent: profile?.conditionsEncrypted != null,
|
||||
structuredCount: illnessCount,
|
||||
available: illnessOn,
|
||||
},
|
||||
{
|
||||
field: "allergies",
|
||||
freeTextPresent: profile?.allergiesEncrypted != null,
|
||||
structuredCount: allergyCount,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ── AI switches ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type LifeAiSectionsOutcome =
|
||||
| { conflict: true }
|
||||
| {
|
||||
conflict: false;
|
||||
aiSections: Record<LifeAiSection, boolean>;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function lifeSwitches(
|
||||
stored: Set<StoredHealthProfileAiSection>,
|
||||
): Record<LifeAiSection, boolean> {
|
||||
return Object.fromEntries(
|
||||
LIFE_AI_SECTIONS.map((section) => [section, stored.has(section)]),
|
||||
) as Record<LifeAiSection, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch life-circumstance sections in or out of the AI inclusion list. Only
|
||||
* the named sections change; the other stored values, the five about-me
|
||||
* sections among them, stay as they are. `base` is the about-me token
|
||||
* (`UserHealthProfile.updatedAt`): a stale one is a conflict, as on about-me.
|
||||
* Any change clears the cached briefing in the same transaction, because it
|
||||
* may quote a section that just left.
|
||||
*/
|
||||
export async function updateLifeAiSections(
|
||||
userId: string,
|
||||
changes: Partial<Record<LifeAiSection, boolean>>,
|
||||
base?: Date,
|
||||
): Promise<LifeAiSectionsOutcome> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const row = await tx.userHealthProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { aiIncludedSections: true, updatedAt: true },
|
||||
});
|
||||
if (row && base && row.updatedAt.getTime() !== base.getTime()) {
|
||||
return { conflict: true as const };
|
||||
}
|
||||
const next = storedSections(row?.aiIncludedSections);
|
||||
for (const [section, on] of Object.entries(changes)) {
|
||||
if (on) next.add(section as StoredHealthProfileAiSection);
|
||||
else next.delete(section as StoredHealthProfileAiSection);
|
||||
}
|
||||
const aiIncludedSections = [...next];
|
||||
const saved = row
|
||||
? await tx.userHealthProfile.update({
|
||||
where: { userId },
|
||||
data: { aiIncludedSections },
|
||||
select: { updatedAt: true },
|
||||
})
|
||||
: await tx.userHealthProfile.create({
|
||||
data: { userId, aiIncludedSections },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
await invalidateHealthProfileFactConsumers(userId, tx);
|
||||
return {
|
||||
conflict: false as const,
|
||||
aiSections: lifeSwitches(next),
|
||||
updatedAt: saved.updatedAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Store "still true" for one section: the instant only, never a revision. */
|
||||
export async function confirmLifeSection(
|
||||
userId: string,
|
||||
section: LifeSectionKey,
|
||||
now: Date = new Date(),
|
||||
): Promise<string> {
|
||||
const stamp = now.toISOString();
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const row = await tx.userHealthProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { lifeConfirmedJson: true },
|
||||
});
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, at] of confirmations(row?.lifeConfirmedJson)) {
|
||||
if ((LIFE_SECTION_KEYS as readonly string[]).includes(key)) {
|
||||
next[key] = at.toISOString();
|
||||
}
|
||||
}
|
||||
next[section] = stamp;
|
||||
await tx.userHealthProfile.upsert({
|
||||
where: { userId },
|
||||
create: { userId, lifeConfirmedJson: next },
|
||||
update: { lifeConfirmedJson: next },
|
||||
});
|
||||
});
|
||||
return stamp;
|
||||
}
|
||||
|
||||
// ── AUDIT-C entries ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The client's `externalId` is stored with this prefix. The screeners share
|
||||
* the table and a per-user unique index on `external_id`, so a sync client
|
||||
* that happened to reuse an id across the two surfaces would otherwise get
|
||||
* the wrong record back as its "existing" entry.
|
||||
*/
|
||||
const AUDIT_C_EXTERNAL_ID_PREFIX = "audit-c:";
|
||||
|
||||
export interface AuditCCreateInput {
|
||||
items: [number, number, number];
|
||||
takenAt?: Date;
|
||||
tz?: string;
|
||||
locale: string;
|
||||
source: "WEB" | "IOS";
|
||||
externalId?: string;
|
||||
}
|
||||
|
||||
const AUDIT_C_SELECT = {
|
||||
id: true,
|
||||
takenAt: true,
|
||||
totalScore: true,
|
||||
locale: true,
|
||||
responsesEncrypted: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Store and score one entry. A repeat `externalId` returns the stored entry
|
||||
* (`replayed: true`) and writes nothing.
|
||||
*/
|
||||
export async function createAuditC(
|
||||
userId: string,
|
||||
input: AuditCCreateInput,
|
||||
now: Date = new Date(),
|
||||
): Promise<{ result: AuditCResult; replayed: boolean }> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { gender: true, locale: true },
|
||||
});
|
||||
const sex = user?.gender ?? null;
|
||||
const labelLocale = user?.locale ?? "en";
|
||||
const externalId = input.externalId
|
||||
? `${AUDIT_C_EXTERNAL_ID_PREFIX}${input.externalId}`
|
||||
: null;
|
||||
|
||||
const replay = async () => {
|
||||
if (!externalId) return null;
|
||||
const existing = await prisma.mentalHealthAssessment.findFirst({
|
||||
where: { userId, instrument: "AUDIT_C", externalId },
|
||||
select: AUDIT_C_SELECT,
|
||||
});
|
||||
if (!existing) return null;
|
||||
const opened = openAuditCResponses(existing.responsesEncrypted);
|
||||
return {
|
||||
result: toAuditCResult(
|
||||
existing,
|
||||
opened?.items ?? input.items,
|
||||
sex,
|
||||
labelLocale,
|
||||
),
|
||||
replayed: true,
|
||||
};
|
||||
};
|
||||
|
||||
const prior = await replay();
|
||||
if (prior) return prior;
|
||||
|
||||
const locale = normaliseAuditCLocale(input.locale);
|
||||
const responses: AuditCResponses = { schema: 1, items: input.items };
|
||||
try {
|
||||
const row = await prisma.mentalHealthAssessment.create({
|
||||
data: {
|
||||
userId,
|
||||
instrument: "AUDIT_C",
|
||||
locale,
|
||||
responsesEncrypted: encryptToBytes(JSON.stringify(responses)),
|
||||
totalScore: auditCTotal(input.items),
|
||||
// Not banded: the cut-off depends on the profile's sex at read time,
|
||||
// so nothing derived from it is frozen into the row.
|
||||
severityBand: "unbanded",
|
||||
takenAt: input.takenAt ?? now,
|
||||
tz: input.tz ?? null,
|
||||
source: input.source,
|
||||
externalId,
|
||||
},
|
||||
select: AUDIT_C_SELECT,
|
||||
});
|
||||
await invalidateHealthProfileFactConsumers(userId, prisma);
|
||||
return {
|
||||
result: toAuditCResult(row, input.items, sex, labelLocale),
|
||||
replayed: false,
|
||||
};
|
||||
} catch (error) {
|
||||
// A concurrent replay of the same externalId lost the unique race.
|
||||
if (
|
||||
externalId &&
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2002"
|
||||
) {
|
||||
const winner = await replay();
|
||||
if (winner) return winner;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Soft-delete one entry of the record; null when there is no live one with that id. */
|
||||
export async function removeAuditC(
|
||||
userId: string,
|
||||
id: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<{ id: string; removedAt: string } | null> {
|
||||
const removed = await prisma.mentalHealthAssessment.updateMany({
|
||||
where: { id, userId, instrument: "AUDIT_C", deletedAt: null },
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
if (removed.count !== 1) return null;
|
||||
await invalidateHealthProfileFactConsumers(userId, prisma);
|
||||
return { id, removedAt: now.toISOString() };
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* v1.43 (#614) — the pure half of the life circumstances: which kinds belong
|
||||
* to which section, and every number the read route derives from the answers
|
||||
* (pack-years, activity minutes, free weekdays, the screening hints). The
|
||||
* server resolves these once; the web and the app render them as delivered.
|
||||
*
|
||||
* Nothing here is ever inferred from anything but the person's own answers
|
||||
* and the two profile columns it needs (birth date and sex): no smoking status
|
||||
* from mood tags, no activity from steps. A missing input is an honest
|
||||
* "missing", never a zero.
|
||||
*
|
||||
* Client-safe: no database, no crypto.
|
||||
*/
|
||||
import { countryLabel } from "@/lib/profile/country-label";
|
||||
import type {
|
||||
LifeAiSection,
|
||||
LifeFactKind,
|
||||
LifeSectionKey,
|
||||
TobaccoDetailValue,
|
||||
} from "@/lib/validations/life-circumstances";
|
||||
|
||||
/** The kinds a section shows, in display order. Alcohol is AUDIT-C, not a fact. */
|
||||
export const LIFE_SECTIONS: ReadonlyArray<{
|
||||
key: LifeSectionKey;
|
||||
aiSection: Exclude<LifeAiSection, "SHIFT_SCHEDULE">;
|
||||
kinds: ReadonlyArray<"SMOKING_STATUS" | "SHIFT_SCHEDULE" | LifeFactKind>;
|
||||
}> = [
|
||||
{
|
||||
key: "tobacco",
|
||||
aiSection: "SMOKING_STATUS",
|
||||
kinds: ["SMOKING_STATUS", "TOBACCO_DETAIL"],
|
||||
},
|
||||
{ key: "alcohol", aiSection: "ALCOHOL_PATTERN", kinds: [] },
|
||||
{ key: "activity", aiSection: "ACTIVITY", kinds: ["ACTIVITY_HABIT"] },
|
||||
{
|
||||
key: "work",
|
||||
aiSection: "WORK",
|
||||
kinds: ["SHIFT_SCHEDULE", "WORK_CONTEXT", "FREE_WEEKDAYS"],
|
||||
},
|
||||
{ key: "habits", aiSection: "HABITS", kinds: ["HABITS"] },
|
||||
{ key: "goals", aiSection: "GOALS", kinds: ["HEALTH_GOALS"] },
|
||||
];
|
||||
|
||||
/**
|
||||
* The AI section each life kind is released under. A kind reaches a prompt
|
||||
* only when this section is in the profile's inclusion list; otherwise it is
|
||||
* never even decrypted for one.
|
||||
*/
|
||||
export const LIFE_KIND_AI_SECTION: Readonly<
|
||||
Record<LifeFactKind, LifeAiSection>
|
||||
> = {
|
||||
TOBACCO_DETAIL: "SMOKING_STATUS",
|
||||
ACTIVITY_HABIT: "ACTIVITY",
|
||||
WORK_CONTEXT: "WORK",
|
||||
FREE_WEEKDAYS: "WORK",
|
||||
HABITS: "HABITS",
|
||||
HEALTH_GOALS: "GOALS",
|
||||
};
|
||||
|
||||
/** A section asks "still true?" once its newest answer or confirmation is this old. */
|
||||
export const REVIEW_AFTER_DAYS = 365;
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/** UTC midnight of a `YYYY-MM-DD` calendar day. */
|
||||
export function dayStartUtc(day: string): Date {
|
||||
return new Date(`${day}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/** Whether an instant is at least `REVIEW_AFTER_DAYS` before `now`. */
|
||||
export function isReviewDue(at: Date | null, now: Date): boolean {
|
||||
return (
|
||||
at !== null &&
|
||||
now.getTime() - at.getTime() >= REVIEW_AFTER_DAYS * MS_PER_DAY
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pack-years ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PackYears {
|
||||
value: number | null;
|
||||
status: "computed" | "not_applicable" | "missing_inputs";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cigarettes per day / 20 × years smoked, to one decimal. Years run from the
|
||||
* start year to the quit year for a former smoker, to the current year
|
||||
* otherwise. Only with both inputs; never estimated.
|
||||
*/
|
||||
export function computePackYears(
|
||||
smokingStatus: string | null,
|
||||
detail: TobaccoDetailValue | null,
|
||||
now: Date,
|
||||
): PackYears {
|
||||
if (smokingStatus === "NEVER")
|
||||
return { value: null, status: "not_applicable" };
|
||||
if (
|
||||
detail &&
|
||||
detail.products.length > 0 &&
|
||||
!detail.products.includes("cigarettes")
|
||||
) {
|
||||
return { value: null, status: "not_applicable" };
|
||||
}
|
||||
if (
|
||||
!detail ||
|
||||
detail.cigarettesPerDay === null ||
|
||||
detail.startYear === null
|
||||
) {
|
||||
return { value: null, status: "missing_inputs" };
|
||||
}
|
||||
const quitYear = detail.quitMonth
|
||||
? Number(detail.quitMonth.slice(0, 4))
|
||||
: null;
|
||||
if (smokingStatus === "FORMER" && quitYear === null) {
|
||||
return { value: null, status: "missing_inputs" };
|
||||
}
|
||||
const endYear =
|
||||
smokingStatus === "CURRENT" || quitYear === null
|
||||
? now.getUTCFullYear()
|
||||
: quitYear;
|
||||
const years = endYear - detail.startYear;
|
||||
if (years < 0) return { value: null, status: "missing_inputs" };
|
||||
return {
|
||||
value: Math.round((detail.cigarettesPerDay / 20) * years * 10) / 10,
|
||||
status: "computed",
|
||||
};
|
||||
}
|
||||
|
||||
/** Whole years smoked, from the same inputs as pack-years; null when unknown. */
|
||||
export function yearsSmoked(
|
||||
smokingStatus: string | null,
|
||||
detail: TobaccoDetailValue | null,
|
||||
now: Date,
|
||||
): number | null {
|
||||
if (!detail || detail.startYear === null) return null;
|
||||
const quitYear = detail.quitMonth
|
||||
? Number(detail.quitMonth.slice(0, 4))
|
||||
: null;
|
||||
const endYear =
|
||||
smokingStatus === "CURRENT" || quitYear === null
|
||||
? now.getUTCFullYear()
|
||||
: quitYear;
|
||||
const years = endYear - detail.startYear;
|
||||
return years >= 0 ? years : null;
|
||||
}
|
||||
|
||||
// ── Activity ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The Exercise Vital Sign product: days per week × minutes per day. */
|
||||
export function activityMinutesPerWeek(
|
||||
daysPerWeek: number | null,
|
||||
minutesPerDay: number | null,
|
||||
): { value: number | null; meetsWho150: boolean | null } {
|
||||
if (daysPerWeek === null || minutesPerDay === null) {
|
||||
return { value: null, meetsWho150: null };
|
||||
}
|
||||
const value = daysPerWeek * minutesPerDay;
|
||||
return { value, meetsWho150: value >= 150 };
|
||||
}
|
||||
|
||||
// ── Free weekdays ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Saturday and Sunday, the calendar default when nobody said otherwise. */
|
||||
export const DEFAULT_FREE_WEEKDAYS: readonly number[] = [6, 7];
|
||||
|
||||
/** One dated answer to "which weekdays are free": `[validFrom, validUntil)`. */
|
||||
export interface FreeWeekdaysSpan {
|
||||
validFrom: Date;
|
||||
validUntil: Date | null;
|
||||
days: readonly number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Day type of a night from its wake day (`YYYY-MM-DD`), read against the
|
||||
* answer that was in force on that day. A night outside every answered span
|
||||
* falls back to the weekend default, so a free-days answer given last month
|
||||
* does not rewrite how last year's nights were read.
|
||||
*/
|
||||
export function dayTypeForNight(
|
||||
night: string,
|
||||
spans: readonly FreeWeekdaysSpan[],
|
||||
): "work" | "free" {
|
||||
const [y, m, d] = night.split("-").map(Number);
|
||||
if (!y || !m || !d) return "work";
|
||||
const at = Date.UTC(y, m - 1, d, 12);
|
||||
const jsDay = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0 = Sun
|
||||
const isoDay = jsDay === 0 ? 7 : jsDay;
|
||||
const span = spans.find(
|
||||
(s) =>
|
||||
s.validFrom.getTime() <= at &&
|
||||
(s.validUntil === null || at < s.validUntil.getTime()),
|
||||
);
|
||||
const free = span ? span.days : DEFAULT_FREE_WEEKDAYS;
|
||||
return free.includes(isoDay) ? "free" : "work";
|
||||
}
|
||||
|
||||
// ── Screening hints ──────────────────────────────────────────────────────────
|
||||
|
||||
export type ScreeningRegion = "DE" | "US";
|
||||
|
||||
export interface ScreeningHint {
|
||||
id: "lung_ldct" | "aaa_ultrasound";
|
||||
status: "suggested" | "not_eligible" | "unknown";
|
||||
reasonKeys: string[];
|
||||
region: ScreeningRegion;
|
||||
sourceLabel: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
/** The region of the hints follows the account language: German gives the German rules. */
|
||||
export function screeningRegion(
|
||||
locale: string | null | undefined,
|
||||
): ScreeningRegion {
|
||||
return locale === "de" ? "DE" : "US";
|
||||
}
|
||||
|
||||
const SOURCES = {
|
||||
lung_ldct: {
|
||||
DE: {
|
||||
label:
|
||||
"G-BA, Beschluss vom 18.06.2025 zur Lungenkrebs-Früherkennung mittels Niedrigdosis-CT",
|
||||
url: "https://www.g-ba.de/presse/pressemitteilungen-meldungen/1263/",
|
||||
},
|
||||
US: {
|
||||
label: "USPSTF, Lung Cancer: Screening (2021)",
|
||||
url: "https://www.uspreventiveservicestaskforce.org/uspstf/recommendation/lung-cancer-screening",
|
||||
},
|
||||
},
|
||||
aaa_ultrasound: {
|
||||
DE: {
|
||||
label:
|
||||
"G-BA, Ultraschallscreening auf Bauchaortenaneurysmen (Beschluss vom 20.10.2016)",
|
||||
url: "https://www.g-ba.de/institution/presse/pressemitteilungen/645/",
|
||||
},
|
||||
US: {
|
||||
label: "USPSTF, Abdominal Aortic Aneurysm: Screening (2019)",
|
||||
url: "https://www.uspreventiveservicestaskforce.org/uspstf/recommendation/abdominal-aortic-aneurysm-screening",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface ScreeningInputs {
|
||||
ageYears: number | null;
|
||||
/** Profile sex as stored: MALE, FEMALE, OTHER or null. */
|
||||
sex: string | null;
|
||||
smokingStatus: string | null;
|
||||
detail: TobaccoDetailValue | null;
|
||||
packYears: PackYears;
|
||||
locale: string | null | undefined;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
function hint(
|
||||
id: ScreeningHint["id"],
|
||||
region: ScreeningRegion,
|
||||
locale: string | null | undefined,
|
||||
status: ScreeningHint["status"],
|
||||
reasonKeys: string[],
|
||||
): ScreeningHint {
|
||||
const source = SOURCES[id][region];
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
reasonKeys,
|
||||
region,
|
||||
sourceLabel: `${source.label} (${countryLabel(region, locale)})`,
|
||||
sourceUrl: source.url,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whole years since the quit month, or null for a current smoker or without one. */
|
||||
function yearsSinceQuit(
|
||||
detail: TobaccoDetailValue | null,
|
||||
now: Date,
|
||||
): number | null {
|
||||
if (!detail?.quitMonth) return null;
|
||||
const [y, m] = detail.quitMonth.split("-").map(Number);
|
||||
const months = (now.getUTCFullYear() - y) * 12 + (now.getUTCMonth() + 1 - m);
|
||||
return Math.max(0, Math.floor(months / 12));
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-dose CT for lung cancer.
|
||||
* DE (G-BA, 18.06.2025): 50 to 75 years, at least 25 years smoked, at least
|
||||
* 15 pack-years, smoking now or stopped at most 10 years ago.
|
||||
* US (USPSTF 2021): 50 to 80 years, at least 20 pack-years, smoking now or
|
||||
* stopped within the past 15 years.
|
||||
*/
|
||||
function lungHint(
|
||||
input: ScreeningInputs,
|
||||
region: ScreeningRegion,
|
||||
): ScreeningHint {
|
||||
const rule =
|
||||
region === "DE"
|
||||
? { minAge: 50, maxAge: 75, minPackYears: 15, minYears: 25, maxQuit: 10 }
|
||||
: {
|
||||
minAge: 50,
|
||||
maxAge: 80,
|
||||
minPackYears: 20,
|
||||
minYears: null,
|
||||
maxQuit: 15,
|
||||
};
|
||||
if (input.smokingStatus === "NEVER") {
|
||||
return hint("lung_ldct", region, input.locale, "not_eligible", [
|
||||
"never_smoked",
|
||||
]);
|
||||
}
|
||||
const failed: string[] = [];
|
||||
const missing: string[] = [];
|
||||
if (input.ageYears === null) missing.push("missing_birth_date");
|
||||
else if (input.ageYears < rule.minAge || input.ageYears > rule.maxAge) {
|
||||
failed.push("age_out_of_range");
|
||||
}
|
||||
if (input.smokingStatus === null) missing.push("missing_smoking_status");
|
||||
if (input.packYears.status === "not_applicable") failed.push("no_cigarettes");
|
||||
else if (input.packYears.value === null)
|
||||
missing.push("missing_smoking_history");
|
||||
else if (input.packYears.value < rule.minPackYears) {
|
||||
failed.push(
|
||||
region === "DE" ? "pack_years_below_15" : "pack_years_below_20",
|
||||
);
|
||||
}
|
||||
if (rule.minYears !== null) {
|
||||
const years = yearsSmoked(input.smokingStatus, input.detail, input.now);
|
||||
if (years === null) {
|
||||
if (!missing.includes("missing_smoking_history"))
|
||||
missing.push("missing_smoking_history");
|
||||
} else if (years < rule.minYears) failed.push("smoked_years_below_25");
|
||||
}
|
||||
if (input.smokingStatus === "FORMER") {
|
||||
const since = yearsSinceQuit(input.detail, input.now);
|
||||
if (since === null) missing.push("missing_quit_date");
|
||||
else if (since > rule.maxQuit) {
|
||||
failed.push(
|
||||
region === "DE" ? "quit_over_10_years" : "quit_over_15_years",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failed.length > 0)
|
||||
return hint("lung_ldct", region, input.locale, "not_eligible", failed);
|
||||
if (missing.length > 0)
|
||||
return hint("lung_ldct", region, input.locale, "unknown", missing);
|
||||
return hint("lung_ldct", region, input.locale, "suggested", []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ultrasound for an abdominal aortic aneurysm.
|
||||
* DE (G-BA): men from 65, once.
|
||||
* US (USPSTF 2019): men 65 to 75 who have ever smoked.
|
||||
*/
|
||||
function aaaHint(
|
||||
input: ScreeningInputs,
|
||||
region: ScreeningRegion,
|
||||
): ScreeningHint {
|
||||
const failed: string[] = [];
|
||||
const missing: string[] = [];
|
||||
if (input.sex === null) missing.push("missing_sex");
|
||||
else if (input.sex !== "MALE") failed.push("sex_not_male");
|
||||
if (input.ageYears === null) missing.push("missing_birth_date");
|
||||
else if (input.ageYears < 65 || (region === "US" && input.ageYears > 75)) {
|
||||
failed.push("age_out_of_range");
|
||||
}
|
||||
if (region === "US") {
|
||||
if (input.smokingStatus === "NEVER") failed.push("never_smoked");
|
||||
else if (input.smokingStatus === null)
|
||||
missing.push("missing_smoking_status");
|
||||
}
|
||||
if (failed.length > 0)
|
||||
return hint("aaa_ultrasound", region, input.locale, "not_eligible", failed);
|
||||
if (missing.length > 0)
|
||||
return hint("aaa_ultrasound", region, input.locale, "unknown", missing);
|
||||
return hint("aaa_ultrasound", region, input.locale, "suggested", []);
|
||||
}
|
||||
|
||||
/** Both hints, always in this order. A hint, never a reminder: nothing is scheduled. */
|
||||
export function screeningHints(input: ScreeningInputs): ScreeningHint[] {
|
||||
const region = screeningRegion(input.locale);
|
||||
return [lungHint(input, region), aaaHint(input, region)];
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
HEALTH_PROFILE_AI_SECTIONS,
|
||||
removedHealthProfileFactSchema,
|
||||
} from "../health-profile-facts";
|
||||
import { removedFactSchema as removedHealthProfileFactSchema } from "../life-circumstances";
|
||||
|
||||
describe("health profile AI inclusion defaults", () => {
|
||||
it("keeps the legacy opt-in list independent from accepted section values", () => {
|
||||
|
||||
@@ -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 (UTC midnight). Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead.",
|
||||
);
|
||||
|
||||
/**
|
||||
* `POST /api/anamnesis/facts`. The three closed kinds keep their string value
|
||||
* (older app builds send exactly that); the six life-circumstance kinds send
|
||||
* their object value with `schema: 1`.
|
||||
*/
|
||||
export const factWriteSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("SMOKING_STATUS"),
|
||||
value: z.enum(SMOKING_STATUS_VALUES),
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("ALCOHOL_PATTERN"),
|
||||
value: z.enum(ALCOHOL_PATTERN_VALUES),
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("SHIFT_SCHEDULE"),
|
||||
value: z.enum(SHIFT_SCHEDULE_VALUES),
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("TOBACCO_DETAIL"),
|
||||
value: tobaccoDetailValueSchema,
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("ACTIVITY_HABIT"),
|
||||
value: activityHabitValueSchema,
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("WORK_CONTEXT"),
|
||||
value: workContextValueSchema,
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("FREE_WEEKDAYS"),
|
||||
value: freeWeekdaysValueSchema,
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("HABITS"),
|
||||
value: habitsValueSchema,
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("HEALTH_GOALS"),
|
||||
value: healthGoalsValueSchema,
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
]);
|
||||
export type FactWriteInput = z.infer<typeof factWriteSchema>;
|
||||
|
||||
/**
|
||||
* `PATCH /api/anamnesis/facts/{id}`: the closed kinds take a string, the life
|
||||
* kinds an object; which one the row needs is checked against the stored
|
||||
* kind after the row is found.
|
||||
*/
|
||||
export const factCorrectionSchema = z.object({
|
||||
value: z.union([z.string().trim().min(1), z.record(z.string(), z.unknown())]),
|
||||
validFrom: validFromDay,
|
||||
});
|
||||
|
||||
/** Parse a life-circumstance value against its kind's schema; null when it does not fit. */
|
||||
export function parseLifeFactValue(
|
||||
kind: LifeFactKind,
|
||||
value: unknown,
|
||||
): LifeFactValue | null {
|
||||
const parsed = LIFE_FACT_VALUE_SCHEMAS[kind].safeParse(value);
|
||||
return parsed.success ? (parsed.data as LifeFactValue) : null;
|
||||
}
|
||||
|
||||
export type TobaccoDetailValue = z.infer<typeof tobaccoDetailValueSchema>;
|
||||
export type ActivityHabitValue = z.infer<typeof activityHabitValueSchema>;
|
||||
export type WorkContextValue = z.infer<typeof workContextValueSchema>;
|
||||
export type FreeWeekdaysValue = z.infer<typeof freeWeekdaysValueSchema>;
|
||||
export type HabitsValue = z.infer<typeof habitsValueSchema>;
|
||||
export type HealthGoalsValue = z.infer<typeof healthGoalsValueSchema>;
|
||||
export type LifeFactValue =
|
||||
| TobaccoDetailValue
|
||||
| ActivityHabitValue
|
||||
| WorkContextValue
|
||||
| FreeWeekdaysValue
|
||||
| HabitsValue
|
||||
| HealthGoalsValue;
|
||||
|
||||
/** `DELETE /api/anamnesis/facts/{id}`: the revision whose interval was closed. */
|
||||
export const removedFactSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(STORED_FACT_KINDS),
|
||||
removedAt: z.iso.datetime({ offset: true }),
|
||||
});
|
||||
|
||||
/**
|
||||
* A life-circumstance revision as `/api/anamnesis/life-circumstances` and the
|
||||
* fact writes answer it. `SMOKING_STATUS` and `SHIFT_SCHEDULE` ride along in
|
||||
* their sections with their string value.
|
||||
*/
|
||||
export const lifeFactRevisionSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(["SMOKING_STATUS", "SHIFT_SCHEDULE", ...LIFE_FACT_KINDS]),
|
||||
value: z
|
||||
.unknown()
|
||||
.describe(
|
||||
"`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`.",
|
||||
),
|
||||
unreadable: z
|
||||
.boolean()
|
||||
.describe("The sealed value could not be opened on this host."),
|
||||
validFrom: z.iso.datetime({ offset: true }),
|
||||
validUntil: z.null(),
|
||||
provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether `raw` is a valid plaintext for a stored fact of `kind`: one of the
|
||||
* closed values for the three original kinds, the JSON of a value that fits
|
||||
* its schema for the life kinds. The portable backup carries exactly this
|
||||
* plaintext, so its writer and its restore share this one check.
|
||||
*/
|
||||
export function isStoredFactPlaintext(
|
||||
kind: StoredFactKind,
|
||||
raw: string,
|
||||
): boolean {
|
||||
if (!isLifeFactKind(kind)) return isHealthProfileFactValue(kind, raw);
|
||||
try {
|
||||
return parseLifeFactValue(kind, JSON.parse(raw)) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user