mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
e9a0b3d959
commit
b99b70127d
17 files changed
+515
-87
No files matched your search
+13
-2
@@ -24630,8 +24630,11 @@ 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.
|
||||
description: 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:
|
||||
- *a6
|
||||
requestBody:
|
||||
@@ -38941,6 +38944,9 @@ components:
|
||||
id:
|
||||
type: string
|
||||
minLength: 1
|
||||
ref:
|
||||
type: string
|
||||
minLength: 1
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -40523,6 +40529,9 @@ components:
|
||||
id:
|
||||
type: string
|
||||
minLength: 1
|
||||
ref:
|
||||
type: string
|
||||
minLength: 1
|
||||
panel:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -86403,6 +86412,7 @@ components:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/AuditCResult"
|
||||
- type: "null"
|
||||
description: The newest entry whose answers this host can open; an entry it cannot open is listed in `history` only.
|
||||
history:
|
||||
type: array
|
||||
items:
|
||||
@@ -86426,6 +86436,7 @@ components:
|
||||
description: Newest first, at most 20.
|
||||
offerDue:
|
||||
type: boolean
|
||||
description: No readable entry, or the newest one whose answers open (the one in `latest`) is 365 days old or older.
|
||||
instrument:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+8
-1
@@ -11279,7 +11279,14 @@
|
||||
"removeConfirmBody": "Der Eintrag verschwindet aus deinem Verlauf. Das lässt sich hier nicht rückgängig machen.",
|
||||
"removedToast": "Eintrag entfernt",
|
||||
"removeError": "Der Eintrag konnte nicht entfernt werden.",
|
||||
"historyEntry": "{date}: {score} von 12"
|
||||
"historyEntry": "{date}: {score} von 12",
|
||||
"legacyLabel": "Frühere Angabe: {value}",
|
||||
"legacyHint": "Aus der kurzen Alkoholfrage vor diesem Fragebogen. Sobald ein Fragebogen-Ergebnis vorliegt, liest der Coach sie nicht mehr.",
|
||||
"legacyRemove": "Frühere Angabe entfernen",
|
||||
"legacyRemoveConfirmTitle": "Frühere Angabe entfernen?",
|
||||
"legacyRemoveConfirmBody": "Die frühere Angabe verschwindet aus deinen Daten. Fragebogen-Ergebnisse bleiben.",
|
||||
"legacyRemovedToast": "Frühere Angabe entfernt",
|
||||
"legacyRemoveError": "Die frühere Angabe konnte nicht entfernt werden."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "In Zustandstagebuch übernehmen",
|
||||
|
||||
+8
-1
@@ -11279,7 +11279,14 @@
|
||||
"removeConfirmBody": "The entry leaves your history. This cannot be undone here.",
|
||||
"removedToast": "Entry removed",
|
||||
"removeError": "The entry could not be removed.",
|
||||
"historyEntry": "{date}: {score} of 12"
|
||||
"historyEntry": "{date}: {score} of 12",
|
||||
"legacyLabel": "Earlier answer: {value}",
|
||||
"legacyHint": "From the short alcohol question asked before this questionnaire. Once a questionnaire result is on file, the Coach no longer reads it.",
|
||||
"legacyRemove": "Remove earlier answer",
|
||||
"legacyRemoveConfirmTitle": "Remove the earlier answer?",
|
||||
"legacyRemoveConfirmBody": "The earlier answer leaves your record. Questionnaire results stay.",
|
||||
"legacyRemovedToast": "Earlier answer removed",
|
||||
"legacyRemoveError": "The earlier answer could not be removed."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Add to condition journal",
|
||||
|
||||
+8
-1
@@ -11279,7 +11279,14 @@
|
||||
"removeConfirmBody": "La entrada sale de tu historial. Aquí no se puede deshacer.",
|
||||
"removedToast": "Entrada eliminada",
|
||||
"removeError": "No se pudo eliminar la entrada.",
|
||||
"historyEntry": "{date}: {score} de 12"
|
||||
"historyEntry": "{date}: {score} de 12",
|
||||
"legacyLabel": "Respuesta anterior: {value}",
|
||||
"legacyHint": "De la pregunta breve sobre alcohol anterior a este cuestionario. En cuanto hay un resultado del cuestionario, el Coach deja de leerla.",
|
||||
"legacyRemove": "Eliminar respuesta anterior",
|
||||
"legacyRemoveConfirmTitle": "¿Eliminar la respuesta anterior?",
|
||||
"legacyRemoveConfirmBody": "La respuesta anterior sale de tu registro. Los resultados del cuestionario se mantienen.",
|
||||
"legacyRemovedToast": "Respuesta anterior eliminada",
|
||||
"legacyRemoveError": "No se pudo eliminar la respuesta anterior."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Pasar al diario de afecciones",
|
||||
|
||||
+8
-1
@@ -11279,7 +11279,14 @@
|
||||
"removeConfirmBody": "L'entrée quitte votre historique. Impossible d'annuler ici.",
|
||||
"removedToast": "Entrée supprimée",
|
||||
"removeError": "Impossible de supprimer l'entrée.",
|
||||
"historyEntry": "{date} : {score} sur 12"
|
||||
"historyEntry": "{date} : {score} sur 12",
|
||||
"legacyLabel": "Réponse précédente : {value}",
|
||||
"legacyHint": "Issue de la courte question sur l’alcool posée avant ce questionnaire. Dès qu’un résultat du questionnaire existe, le Coach ne la lit plus.",
|
||||
"legacyRemove": "Supprimer la réponse précédente",
|
||||
"legacyRemoveConfirmTitle": "Supprimer la réponse précédente ?",
|
||||
"legacyRemoveConfirmBody": "La réponse précédente quitte votre dossier. Les résultats du questionnaire restent.",
|
||||
"legacyRemovedToast": "Réponse précédente supprimée",
|
||||
"legacyRemoveError": "La réponse précédente n’a pas pu être supprimée."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Reprendre dans le journal des affections",
|
||||
|
||||
+8
-1
@@ -11279,7 +11279,14 @@
|
||||
"removeConfirmBody": "La voce esce dalla tua cronologia. Qui non si può annullare.",
|
||||
"removedToast": "Voce rimossa",
|
||||
"removeError": "Impossibile rimuovere la voce.",
|
||||
"historyEntry": "{date}: {score} su 12"
|
||||
"historyEntry": "{date}: {score} su 12",
|
||||
"legacyLabel": "Risposta precedente: {value}",
|
||||
"legacyHint": "Dalla breve domanda sull’alcol posta prima di questo questionario. Quando c’è un risultato del questionario, il Coach non la legge più.",
|
||||
"legacyRemove": "Rimuovi risposta precedente",
|
||||
"legacyRemoveConfirmTitle": "Rimuovere la risposta precedente?",
|
||||
"legacyRemoveConfirmBody": "La risposta precedente esce dai tuoi dati. I risultati del questionario restano.",
|
||||
"legacyRemovedToast": "Risposta precedente rimossa",
|
||||
"legacyRemoveError": "Non è stato possibile rimuovere la risposta precedente."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Riporta nel diario delle condizioni",
|
||||
|
||||
+8
-1
@@ -11279,7 +11279,14 @@
|
||||
"removeConfirmBody": "기록이 내역에서 사라져요. 여기서는 되돌릴 수 없어요.",
|
||||
"removedToast": "기록을 삭제했어요",
|
||||
"removeError": "기록을 삭제하지 못했어요.",
|
||||
"historyEntry": "{date}: 12점 중 {score}점"
|
||||
"historyEntry": "{date}: 12점 중 {score}점",
|
||||
"legacyLabel": "이전 답변: {value}",
|
||||
"legacyHint": "이 설문 전에 묻던 짧은 음주 질문의 답변입니다. 설문 결과가 있으면 코치는 더 이상 이 답변을 읽지 않습니다.",
|
||||
"legacyRemove": "이전 답변 삭제",
|
||||
"legacyRemoveConfirmTitle": "이전 답변을 삭제할까요?",
|
||||
"legacyRemoveConfirmBody": "이전 답변이 기록에서 삭제됩니다. 설문 결과는 그대로 남습니다.",
|
||||
"legacyRemovedToast": "이전 답변을 삭제했습니다",
|
||||
"legacyRemoveError": "이전 답변을 삭제하지 못했습니다."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "상태 일지로 옮기기",
|
||||
|
||||
+8
-1
@@ -11279,7 +11279,14 @@
|
||||
"removeConfirmBody": "Wpis zniknie z historii. Tutaj nie da się tego cofnąć.",
|
||||
"removedToast": "Wpis usunięty",
|
||||
"removeError": "Nie udało się usunąć wpisu.",
|
||||
"historyEntry": "{date}: {score} z 12"
|
||||
"historyEntry": "{date}: {score} z 12",
|
||||
"legacyLabel": "Wcześniejsza odpowiedź: {value}",
|
||||
"legacyHint": "Z krótkiego pytania o alkohol sprzed tego kwestionariusza. Gdy jest wynik kwestionariusza, Coach przestaje ją czytać.",
|
||||
"legacyRemove": "Usuń wcześniejszą odpowiedź",
|
||||
"legacyRemoveConfirmTitle": "Usunąć wcześniejszą odpowiedź?",
|
||||
"legacyRemoveConfirmBody": "Wcześniejsza odpowiedź zniknie z Twoich danych. Wyniki kwestionariusza zostają.",
|
||||
"legacyRemovedToast": "Wcześniejsza odpowiedź usunięta",
|
||||
"legacyRemoveError": "Nie udało się usunąć wcześniejszej odpowiedzi."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Przenieś do dziennika stanów",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -396,6 +396,79 @@ describe("AUDIT-C", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("scores against the wording offered and refuses another one or a future date", async () => {
|
||||
// An English account is shown the English wording; a German cut-off for
|
||||
// it would be the stricter S3 table applied to questions never asked.
|
||||
const man = await makeUser({ locale: "en", gender: "MALE" });
|
||||
await signIn(man.id);
|
||||
const wrong = await postAuditC({
|
||||
items: [2, 1, 1],
|
||||
locale: "de",
|
||||
source: "IOS",
|
||||
});
|
||||
expect(wrong.status).toBe(422);
|
||||
const { result } = await data<{
|
||||
result: { locale: string; cutoff: { source: string } | null };
|
||||
}>(await postAuditC({ items: [2, 1, 1], source: "IOS" }), 201);
|
||||
expect(result.locale).toBe("en");
|
||||
expect(result.cutoff?.source).toBe("BRADLEY_2007");
|
||||
|
||||
// French has no validated wording, so the English one is offered and
|
||||
// stored, whatever the account language says.
|
||||
const french = await makeUser({ locale: "fr", gender: "MALE" });
|
||||
await signIn(french.id);
|
||||
const fr = await data<{ result: { locale: string } }>(
|
||||
await postAuditC({ items: [0, 0, 1], locale: "fr", source: "WEB" }),
|
||||
201,
|
||||
);
|
||||
expect(fr.result.locale).toBe("en");
|
||||
|
||||
const future = await postAuditC({
|
||||
items: [0, 0, 0],
|
||||
source: "WEB",
|
||||
takenAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
expect(future.status).toBe(422);
|
||||
const skew = await postAuditC({
|
||||
items: [0, 0, 0],
|
||||
source: "WEB",
|
||||
takenAt: new Date(Date.now() + 60 * 1000).toISOString(),
|
||||
});
|
||||
expect(skew.status).toBe(201);
|
||||
});
|
||||
|
||||
it("reads latest and the offer from the newest entry whose answers open", async () => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
const older = await data<{ result: { id: string } }>(
|
||||
await postAuditC({
|
||||
items: [1, 0, 0],
|
||||
source: "WEB",
|
||||
takenAt: "2025-01-10T09:00:00.000Z",
|
||||
}),
|
||||
201,
|
||||
);
|
||||
// A newer entry this host cannot open (its key is gone).
|
||||
const sealed = await getPrismaClient().mentalHealthAssessment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
instrument: "AUDIT_C",
|
||||
locale: "en",
|
||||
responsesEncrypted: new Uint8Array([9, 9, 9, 9]),
|
||||
totalScore: 11,
|
||||
severityBand: "unbanded",
|
||||
takenAt: new Date(),
|
||||
source: "WEB",
|
||||
},
|
||||
});
|
||||
const life = await readLife();
|
||||
expect(life.auditC.latest?.id).toBe(older.result.id);
|
||||
// Timed from the result shown, not from the entry nobody can read.
|
||||
expect(life.auditC.offerDue).toBe(true);
|
||||
// Still listed, so it can be removed.
|
||||
expect(life.auditC.history.map((h) => h.id)).toContain(sealed.id);
|
||||
});
|
||||
|
||||
it("soft-deletes an entry once, and only an AUDIT-C entry of the record", async () => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
|
||||
Reference in new issue
Block a user