fix(anamnesis): one newest AUDIT-C entry, scored against the wording offered

The stored language is the wording the read route offers the account;
a request naming another wording, or a takenAt more than five minutes
ahead, is refused. Latest, offerDue, the Coach block and the doctor
report all read the newest entry whose answers open.

The old one-word alcohol pattern gives way to an AUDIT-C entry in the
Coach block, and the alcohol section lists it with a remove action.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:36:42 +02:00
1 parent e9a0b3d959
commit b99b70127d
17 files changed
+515 -87

No files matched your search

+31 -2
View File
@@ -26,10 +26,14 @@ import {
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import { auditCInstrument } from "@/lib/mental-health/audit-c";
import { createAuditC } from "@/lib/profile/life-circumstances";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditCRequestSchema } from "@/lib/validations/life-circumstances";
/** How far ahead of the server clock a `takenAt` may lie. */
const AUDIT_C_CLOCK_SKEW_MS = 5 * 60 * 1000;
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC));
async function postAuditC(request: NextRequest): Promise<Response> {
@@ -54,11 +58,36 @@ async function postAuditC(request: NextRequest): Promise<Response> {
}
const input = parsed.data;
// The cut-off follows the language of the wording that was answered, and
// the only wording this account is ever shown is the one the read route
// delivers for its language. The stored language is that one, decided
// here; a client naming another wording answered something this server
// did not offer (or the account language changed under an open form), and
// is refused rather than scored against the wrong table.
const offered = auditCInstrument(user.locale ?? "en").locale;
if (
input.locale !== undefined &&
auditCInstrument(input.locale).locale !== offered
) {
return apiError("The wording answered is not the one offered", 422, {
errorCode: "anamnesis.auditC.invalid",
});
}
// An entry dated in the future would hold the "newest" place for as long
// as the clock takes to reach it. A few minutes of device clock skew pass.
const takenAt = input.takenAt ? new Date(input.takenAt) : undefined;
if (takenAt && takenAt.getTime() > Date.now() + AUDIT_C_CLOCK_SKEW_MS) {
return apiError("takenAt lies in the future", 422, {
errorCode: "anamnesis.auditC.invalid",
});
}
const { result, replayed } = await createAuditC(user.id, {
items: input.items,
takenAt: input.takenAt ? new Date(input.takenAt) : undefined,
takenAt,
tz: input.tz,
locale: input.locale ?? user.locale ?? "en",
locale: offered,
source: input.source,
externalId: input.externalId,
});
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const state = vi.hoisted(() => ({
facts: null as unknown,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: state.facts }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
}));
vi.mock("@/components/outcome/outcome-toast", () => ({
toastWrittenOutcome: vi.fn(),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { auditCInstrument } from "@/lib/mental-health/audit-c";
import { AuditCPanel } from "../audit-c-panel";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AuditCPanel
data={{
latest: null,
history: [],
offerDue: true,
instrument: auditCInstrument("en"),
}}
onChanged={() => {}}
/>
</I18nProvider>,
);
}
beforeEach(() => {
state.facts = null;
});
describe("the old alcohol pattern in the alcohol section", () => {
it("lists the stored pattern with a way to remove it", () => {
state.facts = {
current: {
SMOKING_STATUS: null,
SHIFT_SCHEDULE: null,
ALCOHOL_PATTERN: { id: "fact-1", value: "WEEKLY" },
},
history: [],
};
const html = render();
expect(html).toContain('data-slot="audit-c-legacy-pattern"');
expect(html).toContain("Earlier answer: Weekly");
expect(html).toContain("audit-c-legacy-remove");
});
it("shows nothing when there is no stored pattern", () => {
state.facts = {
current: {
SMOKING_STATUS: null,
SHIFT_SCHEDULE: null,
ALCOHOL_PATTERN: null,
},
history: [],
};
expect(render()).not.toContain("audit-c-legacy-pattern");
});
});
+66 -2
View File
@@ -10,7 +10,7 @@
* and its source are the server's too; nothing is scored here.
*/
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
@@ -18,9 +18,10 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { HealthProfileFactsPayload } from "@/lib/profile/health-facts";
import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
import { queryKeys } from "@/lib/query-keys";
@@ -76,6 +77,34 @@ export function AuditCPanel({
const complete = answers.every((answer) => answer !== null);
// The one-word alcohol pattern asked before AUDIT-C. It still lives in the
// record, so it is shown here, where alcohol now lives, with a way out.
// The Coach stops reading it once an AUDIT-C entry exists.
const queryClient = useQueryClient();
const facts = useQuery({
queryKey: queryKeys.healthProfileFacts(),
queryFn: () => apiGet<HealthProfileFactsPayload>("/api/anamnesis/facts"),
});
const legacy = facts.data?.current.ALCOHOL_PATTERN ?? null;
const removeLegacy = useMutation({
mutationKey: queryKeys.healthProfileFacts(),
mutationFn: (id: string) => apiDelete(`/api/anamnesis/facts/${id}`),
onSuccess: () => {
toastWrittenOutcome(
"success",
t("records.life.auditC.legacyRemovedToast"),
);
void queryClient.invalidateQueries({
queryKey: queryKeys.healthProfileFacts(),
});
onChanged();
},
onError: (error) =>
showErrorToast(t("records.life.auditC.legacyRemoveError"), {
cause: error,
}),
});
return (
<div className="space-y-4" data-slot="audit-c-panel">
<p className="text-sm">{t("records.life.auditC.intro")}</p>
@@ -154,6 +183,41 @@ export function AuditCPanel({
</div>
) : null}
{legacy?.value ? (
<div
className="flex items-start justify-between gap-2"
data-slot="audit-c-legacy-pattern"
>
<div className="space-y-1">
<p className="text-sm">
{t("records.life.auditC.legacyLabel", {
value: t(
`records.profileFacts.values.ALCOHOL_PATTERN.${legacy.value}`,
),
})}
</p>
<p className="text-muted-foreground text-xs">
{t("records.life.auditC.legacyHint")}
</p>
</div>
<ConfirmButton
label={t("records.life.auditC.legacyRemove")}
ariaLabel={t("records.life.auditC.legacyRemove")}
title={t("records.life.auditC.legacyRemoveConfirmTitle")}
body={t("records.life.auditC.legacyRemoveConfirmBody")}
confirmLabel={t("records.life.auditC.legacyRemove")}
onConfirm={() => removeLegacy.mutate(legacy.id)}
pending={removeLegacy.isPending}
disabled={removeLegacy.isPending}
variant="ghost"
size="icon"
className="text-destructive min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden />}
slot="audit-c-legacy-remove"
/>
</div>
) : null}
{data.offerDue ? (
<p className="text-muted-foreground text-xs">
{t("records.life.auditC.offerDue")}
@@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({
allergyFindMany: vi.fn(),
familyFindMany: vi.fn(),
factFindMany: vi.fn(),
auditFindFirst: vi.fn(),
auditFindMany: vi.fn(),
decrypt: vi.fn(),
}));
@@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({
allergy: { findMany: mocks.allergyFindMany },
familyHistoryEntry: { findMany: mocks.familyFindMany },
healthProfileFactRevision: { findMany: mocks.factFindMany },
mentalHealthAssessment: { findFirst: mocks.auditFindFirst },
mentalHealthAssessment: { findMany: mocks.auditFindMany },
},
}));
@@ -46,13 +46,15 @@ beforeEach(() => {
mocks.allergyFindMany.mockResolvedValue([]);
mocks.familyFindMany.mockResolvedValue([]);
mocks.factFindMany.mockResolvedValue([]);
mocks.auditFindFirst.mockResolvedValue(null);
mocks.auditFindMany.mockResolvedValue([]);
mocks.decrypt.mockImplementation((bytes: Uint8Array) => {
if (bytes[0] === 1) return secret;
if (bytes[0] === 2) return "Asthma";
if (bytes[0] === 3) return "FORMER";
if (bytes[0] === 4) return "OCCASIONAL";
if (bytes[0] === 5) return "ROTATING";
if (bytes[0] === 6) return JSON.stringify({ schema: 1, items: [2, 2, 1] });
if (bytes[0] === 7) throw new Error("unknown key id");
return "unknown";
});
});
@@ -204,6 +206,11 @@ describe("profile section access in AI prompt paths", () => {
if (bytes[0] === 21) {
return JSON.stringify({ schema: 1, goals: ["sleep"] });
}
if (bytes[0] === 4) return "OCCASIONAL";
if (bytes[0] === 6) {
return JSON.stringify({ schema: 1, items: [2, 2, 1] });
}
if (bytes[0] === 7) throw new Error("unknown key id");
return "unknown";
});
});
@@ -235,11 +242,16 @@ describe("profile section access in AI prompt paths", () => {
});
it("gives AUDIT-C as a sum with its screening verdict under the alcohol switch only", async () => {
mocks.auditFindFirst.mockResolvedValue({
totalScore: 5,
takenAt: new Date("2026-10-01T09:00:00.000Z"),
locale: "de",
});
mocks.auditFindMany.mockResolvedValue([
{
id: "audit-1",
totalScore: 5,
takenAt: new Date("2026-10-01T09:00:00.000Z"),
locale: "de",
tz: null,
responsesEncrypted: new Uint8Array([6]),
},
]);
mocks.userFindUnique.mockResolvedValue({ gender: "MALE", locale: "de" });
mockIncluded(["ALCOHOL_PATTERN"]);
@@ -248,10 +260,59 @@ describe("profile section access in AI prompt paths", () => {
"AUDIT-C (Screening, keine Diagnose): 5 von 12 am 2026-10-01, Screening-Grenzwert 5 erreicht",
);
mocks.auditFindFirst.mockClear();
mocks.auditFindMany.mockClear();
mockIncluded(["GOALS"]);
await getSelfContextTextForUser("user-1", "de");
expect(mocks.auditFindFirst).not.toHaveBeenCalled();
expect(mocks.auditFindMany).not.toHaveBeenCalled();
});
it("drops the old alcohol pattern once an AUDIT-C entry exists", async () => {
mockIncluded(["ALCOHOL_PATTERN"]);
mocks.factFindMany.mockResolvedValue([
{ kind: "ALCOHOL_PATTERN", valueEncrypted: new Uint8Array([4]) },
]);
const alone = await getSelfContextTextForUser("user-1", "en");
expect(alone).toContain("Alcohol pattern: Occasional");
mocks.auditFindMany.mockResolvedValue([
{
id: "audit-1",
totalScore: 5,
takenAt: new Date("2026-10-01T09:00:00.000Z"),
locale: "en",
tz: null,
responsesEncrypted: new Uint8Array([6]),
},
]);
const text = await getSelfContextTextForUser("user-1", "en");
expect(text).toContain("AUDIT-C");
expect(text).not.toContain("Alcohol pattern");
});
it("reads the newest entry whose answers open, as the read route does", async () => {
mocks.userFindUnique.mockResolvedValue({ gender: null, locale: "en" });
mocks.auditFindMany.mockResolvedValue([
{
id: "audit-sealed",
totalScore: 11,
takenAt: new Date("2026-10-05T09:00:00.000Z"),
locale: "en",
tz: null,
responsesEncrypted: new Uint8Array([7]),
},
{
id: "audit-open",
totalScore: 5,
takenAt: new Date("2026-10-01T09:00:00.000Z"),
locale: "en",
tz: null,
responsesEncrypted: new Uint8Array([6]),
},
]);
mockIncluded(["ALCOHOL_PATTERN"]);
const text = await getSelfContextTextForUser("user-1", "en");
expect(text).toContain("5 of 12");
expect(text).not.toContain("11 of 12");
});
});
});
+9 -8
View File
@@ -47,6 +47,7 @@ import {
} from "@/lib/profile/health-facts";
import { LIFE_KIND_AI_SECTION } from "@/lib/profile/life-derived";
import { auditCCutoff } from "@/lib/mental-health/audit-c";
import { loadNewestReadableAuditC } from "@/lib/profile/audit-c-entries";
import {
LIFE_FACT_KINDS,
isLifeFactKind,
@@ -636,20 +637,16 @@ export function composeLifeCircumstancesText(
/**
* The newest AUDIT-C entry for a prompt: only when the alcohol section is
* released, and without decrypting anything (the sum is stored in the clear,
* like the screeners'). The profile sex for the cut-off is read only when
* there is an entry to judge.
* released. "Newest" is the newest entry whose answers open, the same entry
* the read route shows as the result (`audit-c-entries.ts`). The profile sex
* for the cut-off is read only when there is an entry to judge.
*/
async function loadAuditCForPrompt(
userId: string,
included: ReadonlySet<string>,
): Promise<AuditCForPrompt | null> {
if (!included.has("ALCOHOL_PATTERN")) return null;
const audit = await prisma.mentalHealthAssessment.findFirst({
where: { userId, instrument: "AUDIT_C", deletedAt: null },
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
select: { totalScore: true, takenAt: true, locale: true, tz: true },
});
const audit = await loadNewestReadableAuditC(userId);
if (!audit) return null;
const user = await prisma.user.findUnique({
where: { id: userId },
@@ -839,6 +836,10 @@ export async function getSelfContextTextForUser(
const factsText = composeStructuredHealthFactsText(
factRows
.filter((row) => !isLifeFactKind(row.kind))
// The old one-word alcohol pattern gives way to an AUDIT-C entry:
// the questionnaire is the measured answer to the same question, and
// the two side by side would let a years-old guess argue with it.
.filter((row) => !(auditC && row.kind === "ALCOHOL_PATTERN"))
.flatMap((row) => {
const decrypted = decryptHealthProfileFactValue(
row.kind as HealthProfileFactKind,
+11 -3
View File
@@ -118,7 +118,11 @@ const lifeCircumstances = z
screeningHints: z.array(screeningHint),
}),
auditC: z.object({
latest: auditCResult.nullable(),
latest: auditCResult
.nullable()
.describe(
"The newest entry whose answers this host can open; an entry it cannot open is listed in `history` only.",
),
history: z
.array(
z.object({
@@ -128,7 +132,11 @@ const lifeCircumstances = z
}),
)
.describe("Newest first, at most 20."),
offerDue: z.boolean(),
offerDue: z
.boolean()
.describe(
"No readable entry, or the newest one whose answers open (the one in `latest`) is 365 days old or older.",
),
instrument: z.object({
items: z
.array(
@@ -267,7 +275,7 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Anamnesis"],
summary: "Record an AUDIT-C (v1.43)",
description:
"Scores the three items on the server. A replay with the same `externalId` answers 200 with the stored result. 422 `anamnesis.auditC.invalid`; 429 `anamnesis.auditC.rateLimited` after 30 in an hour.",
"Scores the three items on the server. The entry is stored in the language of the wording the read route offers this account (`auditC.instrument.locale`), and the cut-off follows that wording; `locale`, when sent, must name that same wording. A replay with the same `externalId` answers 200 with the stored result. 422 `anamnesis.auditC.invalid` (also for another wording than the one offered, and for a `takenAt` more than five minutes ahead of the server clock); 429 `anamnesis.auditC.rateLimited` after 30 in an hour.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
+93
View File
@@ -0,0 +1,93 @@
/**
* v1.43 (#614) — which AUDIT-C entry is "the newest" for every reader.
*
* The answers are sealed; the sum is stored in the clear. An entry whose
* answers this host can no longer open (a lost or retired key) still has a
* readable sum, so a reader that skipped decryption used to take it as the
* newest result while the read route, which needs the answers, showed an
* older one beside it and offered the questionnaire again on a date neither
* of them showed. One rule now holds everywhere a result is read (the read
* route's `latest` and `offerDue`, the Coach block, the doctor report and
* FHIR): the newest entry whose answers open. The history keeps listing every
* entry by its sum, so an unreadable one can still be found and removed.
*/
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
/** How many entries back a reader looks for one it can open. */
export const AUDIT_C_HISTORY_LIMIT = 20;
/**
* The sealed answers of one AUDIT-C entry. `schema` versions the blob the same
* way the screener responses are versioned.
*/
export interface AuditCResponses {
schema: 1;
items: [number, number, number];
}
/** Open the sealed AUDIT-C answers; null when this host cannot read them. */
export 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;
}
/** The first entry of `rows` (newest first) whose answers open. */
export function newestReadableAuditC<
T extends { responsesEncrypted: Uint8Array },
>(rows: readonly T[]): { row: T; items: [number, number, number] } | null {
for (const row of rows) {
const opened = openAuditCResponses(row.responsesEncrypted);
if (opened) return { row, items: opened.items };
}
return null;
}
/** The newest readable entry of a record, for the readers that need one. */
export async function loadNewestReadableAuditC(userId: string): Promise<{
id: string;
takenAt: Date;
totalScore: number;
locale: string;
tz: string | null;
} | null> {
const rows = await 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,
tz: true,
responsesEncrypted: true,
},
});
const newest = newestReadableAuditC(rows);
if (!newest) return null;
const { responsesEncrypted: _sealed, ...entry } = newest.row;
return entry;
}
+19 -53
View File
@@ -15,6 +15,13 @@
import { Prisma } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { deriveAgeYears } from "@/lib/ai/coach/about-me";
import {
AUDIT_C_HISTORY_LIMIT,
loadNewestReadableAuditC,
newestReadableAuditC,
openAuditCResponses,
type AuditCResponses,
} from "@/lib/profile/audit-c-entries";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import {
@@ -63,22 +70,11 @@ import {
} 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;
@@ -160,31 +156,6 @@ function latest(...dates: Array<Date | null | undefined>): Date | null {
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 },
@@ -327,17 +298,16 @@ export async function buildLifeCircumstances(
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;
}
}
// AUDIT-C: the newest entry whose answers this host can open is the
// result, and the offer is timed from it, as every other reader times it
// (`audit-c-entries.ts`). The history lists totals, which need no
// decryption, so an unreadable entry can still be removed.
const readable = newestReadableAuditC(auditRows);
const latestAuditC: AuditCResult | null = readable
? toAuditCResult(readable.row, readable.items, sex, locale)
: null;
const newestAuditC = auditRows[0]?.takenAt ?? null;
const newestReadableAt = readable?.row.takenAt ?? null;
const sections = LIFE_SECTIONS.map((section) => {
const sectionFacts = facts.filter((fact) =>
@@ -398,8 +368,8 @@ export async function buildLifeCircumstances(
totalScore: row.totalScore,
})),
offerDue:
newestAuditC === null ||
now.getTime() - newestAuditC.getTime() >=
newestReadableAt === null ||
now.getTime() - newestReadableAt.getTime() >=
AUDIT_C_OFFER_AFTER_DAYS * MS_PER_DAY,
instrument: auditCInstrument(locale),
},
@@ -646,11 +616,7 @@ export async function loadLifeClinicalSummary(
},
select: { kind: true, valueEncrypted: true },
}),
prisma.mentalHealthAssessment.findFirst({
where: { userId, instrument: "AUDIT_C", deletedAt: null },
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
select: { id: true, takenAt: true, totalScore: true, locale: true },
}),
loadNewestReadableAuditC(userId),
]);
const value = <T>(kind: string): T | null => {
const row = rows.find((candidate) => candidate.kind === kind);