mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user