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