mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(anamnesis): soft-delete AUDIT-C in its route, admit both AUDIT-C verbs at manage level, and settle the guards
This commit is contained in:
1 parent
28a8737124
commit
73bb13a982
26 files changed
+275
-154
No files matched your search
+3
-1
@@ -12429,7 +12429,9 @@
|
||||
"lifeEventUpdate": "{name} hat ein Lebensereignis geändert",
|
||||
"lifeEventDelete": "{name} hat ein Lebensereignis entfernt",
|
||||
"biomarkerMerge": "{name} hat Biomarker zusammengeführt",
|
||||
"medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert"
|
||||
"medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert",
|
||||
"auditCCreate": "{name} hat den AUDIT-C-Fragebogen ausgefüllt",
|
||||
"auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+3
-1
@@ -12429,7 +12429,9 @@
|
||||
"lifeEventUpdate": "{name} changed a life event",
|
||||
"lifeEventDelete": "{name} removed a life event",
|
||||
"biomarkerMerge": "{name} merged biomarkers",
|
||||
"medicationIngredientsUpdate": "{name} changed what a supplement contains"
|
||||
"medicationIngredientsUpdate": "{name} changed what a supplement contains",
|
||||
"auditCCreate": "{name} answered the AUDIT-C questions",
|
||||
"auditCDelete": "{name} removed an AUDIT-C entry"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+3
-1
@@ -12429,7 +12429,9 @@
|
||||
"lifeEventUpdate": "{name} cambió un acontecimiento vital",
|
||||
"lifeEventDelete": "{name} eliminó un acontecimiento vital",
|
||||
"biomarkerMerge": "{name} combinó biomarcadores",
|
||||
"medicationIngredientsUpdate": "{name} cambió la composición de un suplemento"
|
||||
"medicationIngredientsUpdate": "{name} cambió la composición de un suplemento",
|
||||
"auditCCreate": "{name} respondió el cuestionario AUDIT-C",
|
||||
"auditCDelete": "{name} eliminó una entrada del AUDIT-C"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+3
-1
@@ -12429,7 +12429,9 @@
|
||||
"lifeEventUpdate": "{name} a modifié un événement de vie",
|
||||
"lifeEventDelete": "{name} a supprimé un événement de vie",
|
||||
"biomarkerMerge": "{name} a fusionné des biomarqueurs",
|
||||
"medicationIngredientsUpdate": "{name} a modifié la composition d’un complément"
|
||||
"medicationIngredientsUpdate": "{name} a modifié la composition d’un complément",
|
||||
"auditCCreate": "{name} a répondu au questionnaire AUDIT-C",
|
||||
"auditCDelete": "{name} a supprimé une entrée AUDIT-C"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+3
-1
@@ -12429,7 +12429,9 @@
|
||||
"lifeEventUpdate": "{name} ha modificato un evento della vita",
|
||||
"lifeEventDelete": "{name} ha rimosso un evento della vita",
|
||||
"biomarkerMerge": "{name} ha unito dei biomarcatori",
|
||||
"medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore"
|
||||
"medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore",
|
||||
"auditCCreate": "{name} ha compilato il questionario AUDIT-C",
|
||||
"auditCDelete": "{name} ha eliminato una voce AUDIT-C"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+3
-1
@@ -12429,7 +12429,9 @@
|
||||
"lifeEventUpdate": "{name} 님이 삶의 사건을 수정했어요",
|
||||
"lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요",
|
||||
"biomarkerMerge": "{name} 님이 바이오마커를 합쳤어요",
|
||||
"medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요"
|
||||
"medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요",
|
||||
"auditCCreate": "{name} 님이 AUDIT-C 문항에 답했어요",
|
||||
"auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+3
-1
@@ -12429,7 +12429,9 @@
|
||||
"lifeEventUpdate": "{name} zmienił wydarzenie życiowe",
|
||||
"lifeEventDelete": "{name} usunął wydarzenie życiowe",
|
||||
"biomarkerMerge": "{name} scalił biomarkery",
|
||||
"medicationIngredientsUpdate": "{name} zmienił skład suplementu"
|
||||
"medicationIngredientsUpdate": "{name} zmienił skład suplementu",
|
||||
"auditCCreate": "{name} wypełnił kwestionariusz AUDIT-C",
|
||||
"auditCDelete": "{name} usunął wpis AUDIT-C"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
@@ -7034,7 +7034,7 @@ model UserHealthProfile {
|
||||
emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
|
||||
emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
|
||||
emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
|
||||
/// @pending-consumer(#v143-B6) v1.43 (#614) — "still true" confirmations of the life
|
||||
/// v1.43 (#614) — "still true" confirmations of the life
|
||||
/// circumstance sections: `{ "<section key>": "<ISO instant>" }`. Written by
|
||||
/// `POST /api/anamnesis/life-circumstances/confirm`, read as
|
||||
/// `lastConfirmedAt` / `reviewDue` by the life-circumstances read route.
|
||||
@@ -7130,7 +7130,11 @@ enum HealthProfileFactKind {
|
||||
/// v1.43 (#614) — life-circumstance facts. The value is a sealed JSON object
|
||||
/// with `schema: 1`, one Zod schema per kind. `GET /api/anamnesis/facts`
|
||||
/// never returns these (a closed decoder in older app builds); they are read
|
||||
/// through `GET /api/anamnesis/life-circumstances`.
|
||||
/// through `GET /api/anamnesis/life-circumstances`. Readers: the coach
|
||||
/// self-context (all six, per released AI section), the doctor report and
|
||||
/// FHIR (tobacco, activity, work), the chronotype (free weekdays), the
|
||||
/// environment read (work). `HABITS` and `HEALTH_GOALS` are coach-only by
|
||||
/// design: diet and goals are context for a conversation, not findings.
|
||||
TOBACCO_DETAIL
|
||||
ACTIVITY_HABIT
|
||||
WORK_CONTEXT
|
||||
|
||||
@@ -403,8 +403,23 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
confirm: ["DeleteButton"],
|
||||
},
|
||||
{
|
||||
file: "components/records/health-profile-facts-manager.tsx",
|
||||
destroys: "the current value of one effective-dated health-profile fact",
|
||||
file: "components/records/life-circumstances-manager.tsx",
|
||||
destroys:
|
||||
"the current answer of one life-circumstance fact, when a section is saved with it set back to no answer",
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: [],
|
||||
unconfirmed: [
|
||||
{
|
||||
control:
|
||||
"Save section with a field set back to no answer — life-circumstances-manager.tsx",
|
||||
reason:
|
||||
"Setting a question back to no answer and pressing Save is the deliberate act itself, made field by field inside a form the person is editing, with the old answer visible until they change it. The delete closes the answer's interval rather than erasing it, so the dated history keeps it, and answering again takes one select. A second dialog on top of an explicit form edit would make correcting an answer dearer than giving it.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/records/audit-c-panel.tsx",
|
||||
destroys: "one AUDIT-C entry from the alcohol history",
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
|
||||
@@ -893,7 +893,15 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
},
|
||||
"app/api/anamnesis/life-circumstances/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches, confirmations and AUDIT-C are written through their own routes, none of them delegable.",
|
||||
why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches and confirmations are written through their own owner-only routes; AUDIT-C through the manage-level pair below.",
|
||||
},
|
||||
"app/api/anamnesis/audit-c/route.ts": {
|
||||
domain: "profile",
|
||||
why: "Recording an AUDIT-C (v1.43) into the record, the alcohol section of the life circumstances. Reached only through its MANAGE arm, the level allergies and family history are written at; the entry is scored on the server against the record's profile.",
|
||||
},
|
||||
"app/api/anamnesis/audit-c/[id]/route.ts": {
|
||||
domain: "profile",
|
||||
why: "Removing one AUDIT-C entry of the record, scoped `userId: user.id` and `instrument: AUDIT_C` in the guarded update, so a foreign id or a screener id is a 404. Reached only through its MANAGE arm.",
|
||||
},
|
||||
"app/api/profile/summary/route.ts": {
|
||||
domain: "profile",
|
||||
@@ -1630,6 +1638,16 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
conditions: [],
|
||||
why: "Recording an allergy. Reachable through the record's settings on a managed profile, dormant on an invited one, and argued as such.",
|
||||
},
|
||||
"app/api/anamnesis/audit-c/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: ["C1"],
|
||||
why: "Recording an AUDIT-C (v1.43) in the record a manager runs, like an allergy. C1: the 30-an-hour bucket keys on the actor. Arithmetic on three answers, no generation; the trail files the entry id and instrument, never the answers.",
|
||||
},
|
||||
"app/api/anamnesis/audit-c/[id]/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: [],
|
||||
why: "Removing an AUDIT-C entry is a soft delete (`deletedAt`), so the row stays to reconstruct and the delete needs no C3; it is the counterpart of the create beside it.",
|
||||
},
|
||||
"app/api/allergies/[id]/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: ["C3", "C4"],
|
||||
@@ -2142,8 +2160,12 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* v1.43 -- a supplement's composition joins the record list and the manage
|
||||
* literal: its PUT replaces the composition every taken dose counts with.
|
||||
* 260 -> 262.
|
||||
*
|
||||
* v1.43 -- AUDIT-C's create and soft delete join the record list and the
|
||||
* manage literal under `profile`, the level allergies are written at.
|
||||
* 262 -> 266.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 262;
|
||||
const FROZEN_ENTRY_COUNT = 266;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
@@ -2904,7 +2926,7 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
|
||||
it("keeps the admitted mutation inventory complete and discoverable", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(90);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(92);
|
||||
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ handlerModule, action, level }) =>
|
||||
|
||||
@@ -498,7 +498,17 @@ const RESULT_SURFACES: ReadonlyArray<{
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/records/health-profile-facts-manager.tsx",
|
||||
path: "src/components/records/life-circumstances-manager.tsx",
|
||||
through: /toastWrittenOutcome/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/records/audit-c-panel.tsx",
|
||||
through: /toastWrittenOutcome/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/records/free-text-take-over.tsx",
|
||||
through: /toastWrittenOutcome/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(90);
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(92);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
|
||||
@@ -10,8 +10,9 @@ import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { removeAuditC } from "@/lib/profile/life-circumstances";
|
||||
import { invalidateHealthProfileFactConsumers } from "@/lib/profile/health-facts";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -20,12 +21,20 @@ export const DELETE = apiHandler(
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
const { id } = await params;
|
||||
|
||||
const removed = await removeAuditC(user.id, id);
|
||||
if (!removed) {
|
||||
// Soft delete, scoped to a live AUDIT-C entry of the record: a foreign
|
||||
// id, a screener's id or an entry already removed is the same 404.
|
||||
const now = new Date();
|
||||
const result = await prisma.mentalHealthAssessment.updateMany({
|
||||
where: { id, userId: user.id, instrument: "AUDIT_C", deletedAt: null },
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
if (result.count !== 1) {
|
||||
return apiError("AUDIT-C entry not found", 404, {
|
||||
errorCode: "anamnesis.auditC.notFound",
|
||||
});
|
||||
}
|
||||
await invalidateHealthProfileFactConsumers(user.id, prisma);
|
||||
const removed = { id, removedAt: now.toISOString() };
|
||||
|
||||
await auditLog("anamnesis.audit_c.delete", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -106,6 +106,8 @@ describe("anamnesis facts API", () => {
|
||||
"user-1",
|
||||
"SMOKING_STATUS",
|
||||
"FORMER",
|
||||
expect.any(Date),
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.audit).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -135,7 +137,13 @@ describe("anamnesis facts API", () => {
|
||||
where: expect.objectContaining({ id: "fact-1", userId: "user-1" }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.correct).toHaveBeenCalledWith("user-1", "fact-1", "NEVER");
|
||||
expect(mocks.correct).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"fact-1",
|
||||
"NEVER",
|
||||
expect.any(Date),
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.audit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ vi.mock("@/lib/db", () => ({
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
healthProfileFactRevision: { findMany: vi.fn() },
|
||||
// v1.43 — the anamnesis leaf also carries the newest AUDIT-C entry.
|
||||
mentalHealthAssessment: { findFirst: vi.fn(async () => null) },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
// The whole-record gate reads the session's own stamps: a sign-in a
|
||||
// moment ago is the proof, on an account without a second factor.
|
||||
|
||||
@@ -250,7 +250,12 @@ describe("POST /api/mental-health/assessments", () => {
|
||||
expect(body.data.assessment.id).toBe("mha_dup");
|
||||
expect(prisma.mentalHealthAssessment.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "user-1", externalId: "outbox-7f3c" },
|
||||
// Screeners only: an AUDIT-C entry (v1.43) is never a dedup match.
|
||||
where: {
|
||||
userId: "user-1",
|
||||
externalId: "outbox-7f3c",
|
||||
instrument: { in: ["PHQ9", "GAD7", "WHO5", "SCI"] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
|
||||
|
||||
@@ -35,8 +35,8 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
|
||||
vi.mock("@/components/records/conditions-manager", () => ({
|
||||
ConditionsManager: () => null,
|
||||
}));
|
||||
vi.mock("@/components/records/health-profile-facts-manager", () => ({
|
||||
HealthProfileFactsManager: () => null,
|
||||
vi.mock("@/components/records/life-circumstances-manager", () => ({
|
||||
LifeCircumstancesManager: () => null,
|
||||
}));
|
||||
vi.mock("@/components/records/allergy-manager", () => ({
|
||||
AllergyManager: ({
|
||||
|
||||
@@ -18,8 +18,8 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
|
||||
vi.mock("@/components/records/conditions-manager", () => ({
|
||||
ConditionsManager: () => <div data-slot="conditions" />,
|
||||
}));
|
||||
vi.mock("@/components/records/health-profile-facts-manager", () => ({
|
||||
HealthProfileFactsManager: () => null,
|
||||
vi.mock("@/components/records/life-circumstances-manager", () => ({
|
||||
LifeCircumstancesManager: () => null,
|
||||
}));
|
||||
vi.mock("@/components/records/allergy-manager", () => ({
|
||||
AllergyManager: () => null,
|
||||
|
||||
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
allergyFindMany: vi.fn(),
|
||||
familyFindMany: vi.fn(),
|
||||
factFindMany: vi.fn(),
|
||||
auditFindFirst: vi.fn(),
|
||||
decrypt: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -16,6 +17,7 @@ vi.mock("@/lib/db", () => ({
|
||||
allergy: { findMany: mocks.allergyFindMany },
|
||||
familyHistoryEntry: { findMany: mocks.familyFindMany },
|
||||
healthProfileFactRevision: { findMany: mocks.factFindMany },
|
||||
mentalHealthAssessment: { findFirst: mocks.auditFindFirst },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -44,6 +46,7 @@ beforeEach(() => {
|
||||
mocks.allergyFindMany.mockResolvedValue([]);
|
||||
mocks.familyFindMany.mockResolvedValue([]);
|
||||
mocks.factFindMany.mockResolvedValue([]);
|
||||
mocks.auditFindFirst.mockResolvedValue(null);
|
||||
mocks.decrypt.mockImplementation((bytes: Uint8Array) => {
|
||||
if (bytes[0] === 1) return secret;
|
||||
if (bytes[0] === 2) return "Asthma";
|
||||
|
||||
@@ -48,6 +48,7 @@ import { LIFE_KIND_AI_SECTION } from "@/lib/profile/life-derived";
|
||||
import { auditCCutoff } from "@/lib/mental-health/audit-c";
|
||||
import {
|
||||
LIFE_FACT_KINDS,
|
||||
isLifeFactKind,
|
||||
type ActivityHabitValue,
|
||||
type FreeWeekdaysValue,
|
||||
type HabitsValue,
|
||||
@@ -631,65 +632,36 @@ export function composeLifeCircumstancesText(
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the life-circumstance context a prompt may see. Only the kinds whose
|
||||
* AI section is switched on are read, so a section left out is never
|
||||
* decrypted for a prompt; AUDIT-C rides the alcohol switch and needs no
|
||||
* decryption (the sum is stored in the clear, like the screeners').
|
||||
* The newest AUDIT-C entry for a prompt: only when the alcohol section is
|
||||
* released, and without decrypting anything (the sum is stored in the clear,
|
||||
* like the screeners'). The profile sex for the cut-off is read only when
|
||||
* there is an entry to judge.
|
||||
*/
|
||||
async function loadLifeContextForPrompt(
|
||||
async function loadAuditCForPrompt(
|
||||
userId: string,
|
||||
included: ReadonlySet<string>,
|
||||
): Promise<{ facts: LifeFactForPrompt[]; auditC: AuditCForPrompt | null }> {
|
||||
const kinds = LIFE_FACT_KINDS.filter((kind) =>
|
||||
included.has(LIFE_KIND_AI_SECTION[kind]),
|
||||
);
|
||||
const [rows, audit, user] = await Promise.all([
|
||||
kinds.length > 0
|
||||
? prisma.healthProfileFactRevision.findMany({
|
||||
where: {
|
||||
userId,
|
||||
kind: { in: kinds },
|
||||
validUntil: null,
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
orderBy: { kind: "asc" },
|
||||
select: { kind: true, valueEncrypted: true, validFrom: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
included.has("ALCOHOL_PATTERN")
|
||||
? prisma.mentalHealthAssessment.findFirst({
|
||||
where: { userId, instrument: "AUDIT_C", deletedAt: null },
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
select: { totalScore: true, takenAt: true, locale: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
included.has("ALCOHOL_PATTERN")
|
||||
? prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { gender: true, locale: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const facts = rows.flatMap((row) => {
|
||||
const kind = row.kind as LifeFactKind;
|
||||
const opened = decryptLifeFactValue(kind, row.valueEncrypted);
|
||||
return opened.value
|
||||
? [{ kind, value: opened.value, validFrom: row.validFrom }]
|
||||
: [];
|
||||
): 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 },
|
||||
});
|
||||
const cutoff = audit
|
||||
? auditCCutoff(audit.locale, user?.gender ?? null, user?.locale ?? "en")
|
||||
: null;
|
||||
if (!audit) return null;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { gender: true, locale: true },
|
||||
});
|
||||
const cutoff = auditCCutoff(
|
||||
audit.locale,
|
||||
user?.gender ?? null,
|
||||
user?.locale ?? "en",
|
||||
);
|
||||
return {
|
||||
facts,
|
||||
auditC: audit
|
||||
? {
|
||||
totalScore: audit.totalScore,
|
||||
takenAt: audit.takenAt,
|
||||
cutoff: cutoff?.value ?? null,
|
||||
aboveCutoff: cutoff ? audit.totalScore >= cutoff.value : null,
|
||||
}
|
||||
: null,
|
||||
totalScore: audit.totalScore,
|
||||
takenAt: audit.takenAt,
|
||||
cutoff: cutoff?.value ?? null,
|
||||
aboveCutoff: cutoff ? audit.totalScore >= cutoff.value : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -785,11 +757,19 @@ export async function getSelfContextTextForUser(
|
||||
const includedStored: ReadonlySet<string> = new Set<string>(
|
||||
controlRow?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
);
|
||||
const includedFactKinds = (
|
||||
["SMOKING_STATUS", "ALCOHOL_PATTERN", "SHIFT_SCHEDULE"] as const
|
||||
).filter((kind) => included.has(kind));
|
||||
// One read for every fact kind a released section covers: the three
|
||||
// closed kinds and the life-circumstance kinds (v1.43). A kind whose
|
||||
// section is not released is never read, so it is never decrypted.
|
||||
const includedFactKinds = [
|
||||
...(["SMOKING_STATUS", "ALCOHOL_PATTERN", "SHIFT_SCHEDULE"] as const),
|
||||
...LIFE_FACT_KINDS,
|
||||
].filter((kind) =>
|
||||
includedStored.has(
|
||||
isLifeFactKind(kind) ? LIFE_KIND_AI_SECTION[kind] : kind,
|
||||
),
|
||||
);
|
||||
|
||||
const [ctx, user, allergyRows, familyRows, factRows, life] =
|
||||
const [ctx, user, allergyRows, familyRows, factRows, auditC] =
|
||||
await Promise.all([
|
||||
getSelfContextForUser(userId, prisma, included),
|
||||
included.has("ABOUT_ME")
|
||||
@@ -827,10 +807,10 @@ export async function getSelfContextTextForUser(
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
orderBy: { kind: "asc" },
|
||||
select: { kind: true, valueEncrypted: true },
|
||||
select: { kind: true, valueEncrypted: true, validFrom: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
loadLifeContextForPrompt(userId, includedStored),
|
||||
loadAuditCForPrompt(userId, includedStored),
|
||||
]);
|
||||
|
||||
const selfText = composeSelfContextText(
|
||||
@@ -853,27 +833,32 @@ export async function getSelfContextTextForUser(
|
||||
locale,
|
||||
);
|
||||
const factsText = composeStructuredHealthFactsText(
|
||||
factRows.flatMap((row) => {
|
||||
const decrypted = decryptHealthProfileFactValue(
|
||||
row.kind as HealthProfileFactKind,
|
||||
row.valueEncrypted,
|
||||
);
|
||||
return decrypted.value
|
||||
? [
|
||||
{
|
||||
kind: row.kind as HealthProfileFactKind,
|
||||
value: decrypted.value,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
locale,
|
||||
);
|
||||
const lifeText = composeLifeCircumstancesText(
|
||||
life.facts,
|
||||
life.auditC,
|
||||
factRows
|
||||
.filter((row) => !isLifeFactKind(row.kind))
|
||||
.flatMap((row) => {
|
||||
const decrypted = decryptHealthProfileFactValue(
|
||||
row.kind as HealthProfileFactKind,
|
||||
row.valueEncrypted,
|
||||
);
|
||||
return decrypted.value
|
||||
? [
|
||||
{
|
||||
kind: row.kind as HealthProfileFactKind,
|
||||
value: decrypted.value,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
locale,
|
||||
);
|
||||
const lifeFacts = factRows.flatMap((row) => {
|
||||
if (!isLifeFactKind(row.kind)) return [];
|
||||
const opened = decryptLifeFactValue(row.kind, row.valueEncrypted);
|
||||
return opened.value
|
||||
? [{ kind: row.kind, value: opened.value, validFrom: row.validFrom }]
|
||||
: [];
|
||||
});
|
||||
const lifeText = composeLifeCircumstancesText(lifeFacts, auditC, locale);
|
||||
if (!selfText && !recordsText && !factsText && !lifeText) return null;
|
||||
return [selfText, recordsText, factsText, lifeText]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { decryptLifeFactValue } from "@/lib/profile/health-facts";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import type { WorkContextValue } from "@/lib/validations/life-circumstances";
|
||||
|
||||
/** Days listed one by one; the summary covers the whole window. */
|
||||
@@ -173,6 +174,22 @@ const RELEVANT_EXPOSURES = ["heat", "dust", "fumes_solvents"] as const;
|
||||
*/
|
||||
async function loadWorkContextForEnvironment(
|
||||
userId: string,
|
||||
): Promise<EnvironmentToolData["workContext"] | null> {
|
||||
try {
|
||||
return await readWorkContext(userId);
|
||||
} catch (error) {
|
||||
// Context, not data: a failed read leaves the note out, never the days.
|
||||
getEvent()?.addWarning(
|
||||
`environment work context read failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readWorkContext(
|
||||
userId: string,
|
||||
): Promise<EnvironmentToolData["workContext"] | null> {
|
||||
const profile = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId },
|
||||
|
||||
@@ -54,6 +54,10 @@ const { calls, rows, prismaMock } = vi.hoisted(() => {
|
||||
calls.push(name);
|
||||
return Promise.resolve(rows[`${name}Unique`]?.[0] ?? null);
|
||||
},
|
||||
findFirst: () => {
|
||||
calls.push(name);
|
||||
return Promise.resolve(rows[`${name}First`]?.[0] ?? null);
|
||||
},
|
||||
});
|
||||
return {
|
||||
calls,
|
||||
@@ -463,6 +467,8 @@ describe("zero read for unchosen leaves", () => {
|
||||
["ANAMNESIS", "userHealthProfile"],
|
||||
["EMERGENCY", "userHealthProfile"],
|
||||
["ANAMNESIS", "healthProfileFactRevision"],
|
||||
// v1.43 — the newest AUDIT-C entry rides the anamnesis leaf.
|
||||
["ANAMNESIS", "mentalHealthAssessment"],
|
||||
];
|
||||
|
||||
// ANAMNESIS and EMERGENCY both read `userHealthProfile` (and VISITS and
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserInsights: mocks.invalidateInsights,
|
||||
invalidateUserDashboardSnapshot: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ getEvent: vi.fn(() => null) }));
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/lib/mental-health/audit-c";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
decryptHealthProfileFactValue,
|
||||
decryptLifeFactValue,
|
||||
invalidateHealthProfileFactConsumers,
|
||||
toLifeFactRevisionDto,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
type ActivityHabitValue,
|
||||
type FreeWeekdaysValue,
|
||||
type LifeAiSection,
|
||||
type LifeFactKind,
|
||||
type LifeSectionKey,
|
||||
type TobaccoDetailValue,
|
||||
type WorkContextValue,
|
||||
@@ -203,28 +205,42 @@ export function toAuditCResult(
|
||||
};
|
||||
}
|
||||
|
||||
/** The dated free-weekday answers of a record, oldest first, for the night-by-night day type. */
|
||||
/**
|
||||
* The dated free-weekday answers of a record, oldest first, for the
|
||||
* night-by-night day type. Fail-soft: the answers refine a chronotype the
|
||||
* weekend default already produces, so a failed read falls back to that
|
||||
* default with a warning instead of taking the sleep surfaces down with it.
|
||||
*/
|
||||
export async function loadFreeWeekdaySpans(
|
||||
userId: string,
|
||||
): Promise<FreeWeekdaysSpan[]> {
|
||||
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,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
try {
|
||||
const rows = await prisma.healthProfileFactRevision.findMany({
|
||||
where: { userId, kind: "FREE_WEEKDAYS" },
|
||||
orderBy: { validFrom: "asc" },
|
||||
select: { validFrom: true, validUntil: true, valueEncrypted: true },
|
||||
});
|
||||
return rows.flatMap((row) => {
|
||||
const opened = decryptLifeFactValue("FREE_WEEKDAYS", row.valueEncrypted);
|
||||
const value = opened.value as FreeWeekdaysValue | null;
|
||||
return value
|
||||
? [
|
||||
{
|
||||
validFrom: row.validFrom,
|
||||
validUntil: row.validUntil,
|
||||
days: value.days,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
} catch (error) {
|
||||
getEvent()?.addWarning(
|
||||
`free weekdays read failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Assemble the whole read model for one record. */
|
||||
@@ -480,17 +496,17 @@ export async function confirmLifeSection(
|
||||
where: { userId },
|
||||
select: { lifeConfirmedJson: true },
|
||||
});
|
||||
const next: Record<string, string> = {};
|
||||
const confirmedAt: Record<string, string> = {};
|
||||
for (const [key, at] of confirmations(row?.lifeConfirmedJson)) {
|
||||
if ((LIFE_SECTION_KEYS as readonly string[]).includes(key)) {
|
||||
next[key] = at.toISOString();
|
||||
confirmedAt[key] = at.toISOString();
|
||||
}
|
||||
}
|
||||
next[section] = stamp;
|
||||
confirmedAt[section] = stamp;
|
||||
await tx.userHealthProfile.upsert({
|
||||
where: { userId },
|
||||
create: { userId, lifeConfirmedJson: next },
|
||||
update: { lifeConfirmedJson: next },
|
||||
create: { userId, lifeConfirmedJson: confirmedAt },
|
||||
update: { lifeConfirmedJson: confirmedAt },
|
||||
});
|
||||
});
|
||||
return stamp;
|
||||
@@ -603,21 +619,6 @@ export async function createAuditC(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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() };
|
||||
}
|
||||
|
||||
// ── Clinical summary (doctor report, FHIR) ───────────────────────────────────
|
||||
|
||||
export async function loadLifeClinicalSummary(
|
||||
@@ -643,14 +644,7 @@ export async function loadLifeClinicalSummary(
|
||||
validUntil: null,
|
||||
supersededByRevisionId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
valueEncrypted: true,
|
||||
validFrom: true,
|
||||
provenance: true,
|
||||
createdAt: true,
|
||||
},
|
||||
select: { kind: true, valueEncrypted: true },
|
||||
}),
|
||||
prisma.mentalHealthAssessment.findFirst({
|
||||
where: { userId, instrument: "AUDIT_C", deletedAt: null },
|
||||
@@ -658,9 +652,15 @@ export async function loadLifeClinicalSummary(
|
||||
select: { id: true, takenAt: true, totalScore: true, locale: true },
|
||||
}),
|
||||
]);
|
||||
const facts = rows.map(toLifeFactRevisionDto);
|
||||
const value = <T>(kind: string): T | null =>
|
||||
(facts.find((fact) => fact.kind === kind)?.value as T | undefined) ?? null;
|
||||
const value = <T>(kind: string): T | null => {
|
||||
const row = rows.find((candidate) => candidate.kind === kind);
|
||||
if (!row) return null;
|
||||
const opened =
|
||||
kind === "SMOKING_STATUS"
|
||||
? decryptHealthProfileFactValue("SMOKING_STATUS", row.valueEncrypted)
|
||||
: decryptLifeFactValue(kind as LifeFactKind, row.valueEncrypted);
|
||||
return (opened.value as T | null) ?? null;
|
||||
};
|
||||
const packYears = computePackYears(
|
||||
value<string>("SMOKING_STATUS"),
|
||||
value<TobaccoDetailValue>("TOBACCO_DETAIL"),
|
||||
|
||||
@@ -70,6 +70,10 @@ export function recordActivityVerbLine(
|
||||
return t("recordSharing.activityVerb.allergyCreate", { name });
|
||||
case "family-history.create":
|
||||
return t("recordSharing.activityVerb.familyHistoryCreate", { name });
|
||||
case "anamnesis.audit_c.create":
|
||||
return t("recordSharing.activityVerb.auditCCreate", { name });
|
||||
case "anamnesis.audit_c.delete":
|
||||
return t("recordSharing.activityVerb.auditCDelete", { name });
|
||||
case "illness.episode.create":
|
||||
return t("recordSharing.activityVerb.illnessEpisodeCreate", { name });
|
||||
case "symptoms.event.create":
|
||||
|
||||
+16
@@ -570,6 +570,22 @@ export const ADMITTED_MUTATING_HANDLERS = [
|
||||
"manage",
|
||||
"delete",
|
||||
),
|
||||
handler(
|
||||
"app/api/anamnesis/audit-c/route.ts",
|
||||
"/api/anamnesis/audit-c",
|
||||
"POST",
|
||||
"profile",
|
||||
"manage",
|
||||
"create",
|
||||
),
|
||||
handler(
|
||||
"app/api/anamnesis/audit-c/[id]/route.ts",
|
||||
"/api/anamnesis/audit-c/[id]",
|
||||
"DELETE",
|
||||
"profile",
|
||||
"manage",
|
||||
"delete",
|
||||
),
|
||||
handler(
|
||||
"app/api/medications/[id]/ingredients/route.ts",
|
||||
"/api/medications/[id]/ingredients",
|
||||
|
||||
Reference in new issue
Block a user