mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Carry the screener history and the consent record, disaster recovery only
Two models come off the coverage register together, and neither is an ordering or an id-remap problem. Both hang off the account and nothing else. What kept them off was a question the code never wrote down, so this change writes it down. The screeners hold completed WHO-5, PHQ and GAD administrations, and for PHQ-9 the encrypted blob contains item 9, the question about thoughts of being better off dead. The application already draws a line around that blob: it keeps a coarse item9Flagged boolean so the history view can re-surface crisis resources without decrypting anything. The intent here was to follow that line and carry the administration while leaving the answers encrypted. The schema settles it more firmly. responsesEncrypted is Bytes and NOT NULL, so there is no half-row to write and the choice is the whole thing or none of it. A plaintext export in a downloads folder is the wrong home for that answer, so the row rides in a disaster-recovery file only. The consent receipts are not sensitive in that way. Their problem is that a consent is given to an operator. A receipt restored onto a different instance asserts an agreement that operator never obtained, with a signature timestamp to make it look settled. A disaster-recovery file goes back to the same instance, so it travels there and not in a portable one. In both cases the portable file SAYS so in its own manifest rather than reporting an account that has neither. The manifest is merged rather than added beside the records one, because two keys of the same name would shadow each other depending on spread order. item9Flagged is restored from the file rather than recomputed. Recomputing would mean decrypting every administration during a restore, which is the handling this data is kept away from, and the stored flag is what the history view has been reading all along. Verified by breaking it: dropping revokedAt on the way back fails the round trip with a withdrawn consent coming back as an active one.
This commit is contained in:
1 parent
79725a85e9
commit
f4fe08c73e
11 files changed
+593
-5
No files matched your search
@@ -82,6 +82,11 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
|
||||
const OWNER_ID = "round-trip-owner";
|
||||
const AT = (iso: string) => new Date(iso);
|
||||
const PHQ9_RESPONSES = JSON.stringify({
|
||||
items: [2, 1, 2, 2, 1, 1, 2, 1, 2],
|
||||
schema: 1,
|
||||
});
|
||||
const CONSENT_ARTEFACT = "JVBERi0xLjQKJWZpeHR1cmU=";
|
||||
const INVENTORY_NOTE = "second pack, kept in the kitchen drawer";
|
||||
const COACH_FACT = "does not tolerate ACE inhibitors";
|
||||
const COACH_PLAN_CUE = "if the evening reading is over 140";
|
||||
@@ -193,6 +198,9 @@ const COUNT_BACK: Record<
|
||||
MoodTagHidden: (p, userId) => p.moodTagHidden.count({ where: { userId } }),
|
||||
ReminderPhaseConfig: (p, userId) =>
|
||||
p.reminderPhaseConfig.count({ where: { medication: { userId } } }),
|
||||
MentalHealthAssessment: (p, userId) =>
|
||||
p.mentalHealthAssessment.count({ where: { userId } }),
|
||||
ConsentReceipt: (p, userId) => p.consentReceipt.count({ where: { userId } }),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -273,6 +281,50 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
// one away without recording it. A restore that recomputed the count from
|
||||
// the ledger would "fix" that to 22 and disagree with every low-stock
|
||||
// reminder the account has already received.
|
||||
// A PHQ-9 administration whose item 9 was answered above zero. The flag and
|
||||
// the encrypted answers are seeded to DISAGREE with a naive recomputation:
|
||||
// the flag says true, and a restore that decided to recompute it would have
|
||||
// to decrypt the blob to do so, which is the handling this data is kept away
|
||||
// from. The assertion below reads the flag and the ciphertext separately.
|
||||
await prisma.mentalHealthAssessment.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
instrument: "PHQ9",
|
||||
locale: "de",
|
||||
version: "standard",
|
||||
responsesEncrypted: encryptToBytes(PHQ9_RESPONSES),
|
||||
totalScore: 14,
|
||||
severityBand: "moderate",
|
||||
item9Flagged: true,
|
||||
crisisShownAt: AT("2026-07-05T18:02:00.000Z"),
|
||||
takenAt: AT("2026-07-05T18:00:00.000Z"),
|
||||
tz: "Europe/Berlin",
|
||||
source: "WEB",
|
||||
},
|
||||
});
|
||||
|
||||
// One active consent and one revoked. The revocation is the part a restore
|
||||
// can quietly lose: dropping `revokedAt` turns a withdrawn agreement back
|
||||
// into a standing one, and the latest-per-user lookup shortcuts on exactly
|
||||
// that column.
|
||||
await prisma.consentReceipt.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: OWNER_ID,
|
||||
kind: "ai-processing",
|
||||
artefact: CONSENT_ARTEFACT,
|
||||
signedAt: AT("2026-06-01T09:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
userId: OWNER_ID,
|
||||
kind: "research-sharing",
|
||||
artefact: "eyJhbGciOiJub25lIn0.e30.",
|
||||
signedAt: AT("2026-06-02T09:00:00.000Z"),
|
||||
revokedAt: AT("2026-07-10T11:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Tuned away from every default, and one threshold in PERCENT rather than
|
||||
// MINUTES: a restore that fell back to the schema defaults would return four
|
||||
// plausible numbers and silently change when this drug turns orange.
|
||||
@@ -1111,6 +1163,49 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// The screener came back readable, and the flag came back as recorded.
|
||||
const screener = await prisma.mentalHealthAssessment.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
expect({
|
||||
instrument: screener.instrument,
|
||||
totalScore: screener.totalScore,
|
||||
severityBand: screener.severityBand,
|
||||
item9Flagged: screener.item9Flagged,
|
||||
crisisShown: screener.crisisShownAt?.toISOString() ?? null,
|
||||
responses: decryptFromBytes(screener.responsesEncrypted),
|
||||
}).toEqual({
|
||||
instrument: "PHQ9",
|
||||
totalScore: 14,
|
||||
severityBand: "moderate",
|
||||
item9Flagged: true,
|
||||
crisisShown: "2026-07-05T18:02:00.000Z",
|
||||
responses: PHQ9_RESPONSES,
|
||||
});
|
||||
|
||||
// Both consents, and the revocation still standing. A restore that lost
|
||||
// `revokedAt` would return the same two rows and turn a withdrawn
|
||||
// agreement back into an active one.
|
||||
const consents = await prisma.consentReceipt.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
orderBy: { signedAt: "asc" },
|
||||
});
|
||||
expect(
|
||||
consents.map((c) => ({
|
||||
kind: c.kind,
|
||||
artefact: c.artefact,
|
||||
revoked: c.revokedAt?.toISOString() ?? null,
|
||||
})),
|
||||
"a revoked consent must not come back as an active one",
|
||||
).toEqual([
|
||||
{ kind: "ai-processing", artefact: CONSENT_ARTEFACT, revoked: null },
|
||||
{
|
||||
kind: "research-sharing",
|
||||
artefact: "eyJhbGciOiJub25lIn0.e30.",
|
||||
revoked: "2026-07-10T11:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
// The account's own grouping came back, and the hidden tag is hidden
|
||||
// again — resolved by key, so it points at whatever id this instance uses.
|
||||
const ownCategories = await prisma.moodTagCategory.findMany({
|
||||
|
||||
Reference in new issue
Block a user