mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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
@@ -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