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:
Marc-André Bombeck committed 2026-08-22 09:48:10 +02:00
1 parent 79725a85e9
commit f4fe08c73e
11 files changed
+593 -5

No files matched your search

@@ -299,6 +299,8 @@ function sourceClient() {
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
consentReceipt: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
@@ -50,6 +50,7 @@ import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
import { restoreVisitsData } from "@/lib/export/visits-backup";
import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup";
import { restoreSensitiveData } from "@/lib/export/sensitive-backup";
import {
restoreCoachData,
restoreCoachMemoryData,
@@ -108,6 +109,8 @@ interface RestoreResponse {
coachFacts: number;
coachPlans: number;
coachReminders: number;
mentalHealthAssessments: number;
consentReceipts: number;
};
}
@@ -1605,6 +1608,16 @@ const handler = apiHandler(
skips,
);
// The screener history and the consent record. Neither references
// anything but the account, so the position here is free; it sits
// last because that is where the section was added, not because
// anything above it matters to these two.
const sensitiveCleared = await restoreSensitiveData(
tx,
ownerId,
payload,
);
const cleared = {
measurements: measurements.count,
medications: meds.count,
@@ -1645,6 +1658,8 @@ const handler = apiHandler(
coachFacts: coachMemoryCleared.coachFacts,
coachPlans: coachMemoryCleared.coachPlans,
coachReminders: coachMemoryCleared.coachReminders,
mentalHealthAssessments: sensitiveCleared.mentalHealthAssessments,
consentReceipts: sensitiveCleared.consentReceipts,
};
return { cleared, skipped: summarizeRestoreSkips(skips) };
},
@@ -45,6 +45,8 @@ vi.mock("@/lib/db", () => ({
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
consentReceipt: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
@@ -141,6 +143,10 @@ beforeEach(() => {
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
@@ -27,6 +27,8 @@ vi.mock("@/lib/db", () => ({
moodTag: { findMany: vi.fn() },
moodTagCategory: { findMany: vi.fn() },
moodTagHidden: { findMany: vi.fn() },
mentalHealthAssessment: { findMany: vi.fn() },
consentReceipt: { findMany: vi.fn() },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
user: { findUnique: vi.fn() },
passkey: { findMany: vi.fn() },
@@ -139,6 +141,10 @@ beforeEach(() => {
vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: null,
@@ -43,6 +43,8 @@ vi.mock("@/lib/db", () => ({
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
consentReceipt: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
@@ -358,6 +358,8 @@ function makePrisma() {
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
consentReceipt: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
+13 -4
View File
@@ -223,6 +223,9 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
// this file shares with nothing else, but each model still reads through its
// own delegate here for the reason the visits comment gives.
"src/lib/export/coach-backup.ts",
// The screener history and the consent record, disaster-recovery only. The
// reasons for that live in the module itself.
"src/lib/export/sensitive-backup.ts",
"src/lib/cycle/backup.ts",
];
@@ -235,6 +238,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
"src/lib/export/vaccinations-backup.ts",
"src/lib/export/reminders-backup.ts",
"src/lib/export/coach-backup.ts",
"src/lib/export/sensitive-backup.ts",
"src/lib/cycle/backup.ts",
];
@@ -375,6 +379,15 @@ export const TWO_ENDED_MODELS = [
// which is exactly why it could be cleared in an afternoon and the other
// could not be cleared at all.
"ReminderPhaseConfig",
// Two models that ride in a disaster-recovery file and deliberately not in a
// portable one. Everywhere else that split is about ENCODING; here it is
// about whether the row should leave the instance at all, and the two
// reasons are different from each other. Both are written out in
// `src/lib/export/sensitive-backup.ts`, and the portable file discloses the
// omission in its own manifest rather than reporting an account that has
// neither.
"MentalHealthAssessment",
"ConsentReceipt",
] as const;
/** One model claimed to travel both ways. */
@@ -415,8 +428,6 @@ export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
"The history of how a schedule changed. Without it a restored medication keeps today's schedule and loses the record of when the dose or timing moved, which is the part a doctor asks about.",
MedicationEfficacyTarget:
"What a medication was supposed to move, and by how much. The drug comes back with no statement of what it was for.",
MentalHealthAssessment:
"Completed WHO-5, PHQ and GAD instruments with their scores and dates. Clinically meaningful history that no integration can re-sync, and the single largest gap on this list.",
EcgRecording:
"ECG traces and their rhythm classification. The originating device may still hold them, but a self-hoster who exported and wiped has nothing to re-sync from.",
WorkoutRoute:
@@ -435,8 +446,6 @@ export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
"Which documents were filed against which condition. Documents and conditions both restore; the filing between them does not, so a restored vault is unsorted.",
ExtractedFact:
"Facts read out of a document by the AI pass, with their provenance back to the page. Re-derivable only by re-running the extraction against a provider, at the operator's cost.",
ConsentReceipt:
"The record of what the account agreed to and when. Arguably belongs with the instance rather than the account, and that question has to be settled before this one can be carried either way.",
};
/**
+27 -1
View File
@@ -55,6 +55,13 @@ import {
type RemindersBackupCounts,
type RemindersBackupSection,
} from "@/lib/export/reminders-backup";
import {
buildSensitiveBackupSection,
countSensitiveBackupSection,
type SensitiveBackupCounts,
type SensitiveBackupManifest,
type SensitiveBackupSection,
} from "@/lib/export/sensitive-backup";
import {
buildCoachBackupSection,
buildCoachMemoryBackupSection,
@@ -82,7 +89,8 @@ export interface FullBackupCounts
VaccinationsBackupCounts,
RemindersBackupCounts,
CoachBackupCounts,
CoachMemoryBackupCounts {
CoachMemoryBackupCounts,
SensitiveBackupCounts {
measurements: number;
medications: number;
intakeEvents: number;
@@ -308,6 +316,7 @@ export async function buildFullBackupPayload(
reminders,
coach,
coachMemory,
sensitive,
nutrientDays,
] = await Promise.all([
disasterRecovery
@@ -491,6 +500,12 @@ export async function buildFullBackupPayload(
buildCoachMemoryBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// The screener history and the consent record. Both ride in a
// disaster-recovery file only, for two different reasons that are written
// out in `src/lib/export/sensitive-backup.ts` rather than summarised here.
buildSensitiveBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// Nutrient day totals were absent from every export path, which
// contradicted the schema's own reason for denormalising the unit column
// ("rows stay self-describing in exports even if the catalog ever drifts").
@@ -522,6 +537,11 @@ export async function buildFullBackupPayload(
const remindersSection: RemindersBackupSection = reminders;
const coachSection: CoachBackupSection = coach;
const coachMemorySection: CoachMemoryBackupSection = coachMemory;
const sensitiveSection: SensitiveBackupSection = {
mentalHealthAssessments: sensitive.mentalHealthAssessments,
consentReceipts: sensitive.consentReceipts,
};
const sensitiveManifest: SensitiveBackupManifest = sensitive.manifest;
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
@@ -872,6 +892,11 @@ export async function buildFullBackupPayload(
...remindersSection,
...coachSection,
...coachMemorySection,
...sensitiveSection,
// Merged rather than added beside it: the records section already owns a
// `manifest` key, and two of them would silently shadow each other
// depending on spread order.
manifest: { ...recordsSection.manifest, ...sensitiveManifest },
nutrientDays: nutrientDays.map((n) => ({
day: n.day,
nutrient: n.nutrient,
@@ -923,6 +948,7 @@ export async function buildFullBackupPayload(
...countRemindersBackupSection(reminders),
...countCoachBackupSection(coach),
...countCoachMemoryBackupSection(coachMemory),
...countSensitiveBackupSection(sensitive),
},
};
}
+374
View File
@@ -0,0 +1,374 @@
/**
* Two models that travel in a disaster-recovery file and deliberately not in a
* portable one, for two entirely different reasons.
*
* Everywhere else in this directory the split between the two export purposes
* is about ENCODING: a portable file decrypts prose for the person who owns it,
* a disaster-recovery file carries the ciphertext their own instance can read
* back. Here the split is about whether the row should leave the instance at
* all. That is a different kind of decision and it is worth stating plainly
* rather than leaving a reader to infer it from a missing key.
*
* ## The screeners
*
* `MentalHealthAssessment` holds completed WHO-5, PHQ and GAD administrations.
* The per-item answers live in `responsesEncrypted`, and for PHQ-9 that blob
* contains item 9, the question about thoughts of being better off dead or of
* hurting oneself. The schema comment says it outright: the item-9 raw value
* lives ONLY there.
*
* The application already draws a line around that blob. It keeps
* `item9Flagged` as a coarse boolean precisely so the history view can
* re-surface crisis resources WITHOUT decrypting anything. Following the
* instance's own line was the intent here: carry the administration as the
* history view sees it, leave the answers encrypted.
*
* The schema settles it more firmly than that. `responsesEncrypted` is `Bytes`
* and NOT NULL, so there is no honest half-row to write: a portable file with
* no ciphertext could not restore an administration even if it wanted to. The
* choice is the whole row or nothing, and a plaintext JSON file sitting in
* somebody's downloads folder is the wrong home for that answer. So the whole
* row rides only in a disaster-recovery file, and the portable file SAYS it
* does not carry them rather than reporting an account with no screeners.
*
* ## The consent receipts
*
* `ConsentReceipt` is not sensitive in the same way. Its problem is that a
* consent is given to an OPERATOR, not to a file. The coverage register raised
* this itself, filed as "arguably belongs with the instance rather than the
* account", and the argument holds: a receipt restored onto a different
* instance asserts an agreement that instance's operator never obtained, with
* a signature timestamp to make it look settled.
*
* A disaster-recovery file goes back to the same instance and the same
* operator, so the consent it records still stands. A portable file can land
* anywhere. So the receipts travel in the first and not in the second, and
* again the file says so.
*
* ## What both of these are NOT
*
* Neither is an id-remap problem, an ordering problem, or unbuilt work. Both
* hang off the user directly with no reference to anything else, so a future
* reader looking for the reason they sat on the register will not find it in
* the code. It is here.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { AssessmentInstrument } from "@/generated/prisma/client";
export interface SensitiveBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
/** One completed screener administration, ciphertext and all. */
export interface MentalHealthAssessmentBackupEntry {
id: string;
instrument: AssessmentInstrument;
locale: string;
version: string;
/** Ciphertext as base64. The per-item answers, including PHQ-9 item 9. */
responsesEncrypted: string;
totalScore: number;
severityBand: string;
item9Flagged: boolean;
crisisShownAt: string | null;
takenAt: string;
tz: string | null;
source: string;
externalId: string | null;
syncVersion: number;
deletedAt: string | null;
createdAt: string;
updatedAt: string;
}
/** One record of what the account agreed to, and when. */
export interface ConsentReceiptBackupEntry {
id: string;
kind: string;
/** The artefact verbatim: a base64 PDF or a signed token. Never parsed. */
artefact: string;
signedAt: string;
revokedAt: string | null;
createdAt: string;
}
/**
* Both keys are always present, and empty on a portable payload. An absent key
* and an empty list read the same to a restore, but they read very differently
* to a person opening the file, and the manifest entry beside them explains
* which of the two they are looking at.
*/
export interface SensitiveBackupSection {
mentalHealthAssessments: MentalHealthAssessmentBackupEntry[];
consentReceipts: ConsentReceiptBackupEntry[];
}
export interface SensitiveBackupCounts {
mentalHealthAssessments: number;
consentReceipts: number;
}
/** What the file says about itself, merged into the payload manifest. */
export interface SensitiveBackupManifest {
mentalHealth: {
included: "full" | "omitted";
note: string;
};
consent: {
included: "full" | "omitted";
note: string;
};
}
const MENTAL_HEALTH_OMITTED_NOTE =
"Completed WHO-5, PHQ and GAD administrations are not included in this " +
"export. Their per-item answers are stored encrypted and include the " +
"PHQ-9 self-harm item, which is not written into a portable file. They are " +
"included in a disaster-recovery backup, which returns to the instance " +
"that can read them.";
const MENTAL_HEALTH_INCLUDED_NOTE =
"Completed screener administrations are included with their encrypted " +
"per-item answers, for restore onto the instance that holds the key.";
const CONSENT_OMITTED_NOTE =
"Consent receipts are not included in this export. A consent is given to " +
"one operator, and a receipt restored elsewhere would assert an agreement " +
"that operator never obtained. They are included in a disaster-recovery " +
"backup, which returns to the same instance.";
const CONSENT_INCLUDED_NOTE =
"Consent receipts are included, for restore onto the instance the consent " +
"was given to.";
const MENTAL_HEALTH_BACKUP_SELECT = {
id: true,
instrument: true,
locale: true,
version: true,
responsesEncrypted: true,
totalScore: true,
severityBand: true,
item9Flagged: true,
crisisShownAt: true,
takenAt: true,
tz: true,
source: true,
externalId: true,
syncVersion: true,
deletedAt: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.MentalHealthAssessmentSelect;
const CONSENT_RECEIPT_BACKUP_SELECT = {
id: true,
kind: true,
artefact: true,
signedAt: true,
revokedAt: true,
createdAt: true,
} satisfies Prisma.ConsentReceiptSelect;
export async function buildSensitiveBackupSection(
prisma: Pick<PrismaClient, "mentalHealthAssessment" | "consentReceipt">,
userId: string,
options: SensitiveBackupOptions = {},
): Promise<SensitiveBackupSection & { manifest: SensitiveBackupManifest }> {
const disasterRecovery = options.purpose === "disaster-recovery";
if (!disasterRecovery) {
return {
mentalHealthAssessments: [],
consentReceipts: [],
manifest: {
mentalHealth: {
included: "omitted",
note: MENTAL_HEALTH_OMITTED_NOTE,
},
consent: { included: "omitted", note: CONSENT_OMITTED_NOTE },
},
};
}
const [assessmentRows, receiptRows] = await Promise.all([
prisma.mentalHealthAssessment.findMany({
where: { userId },
orderBy: { takenAt: "asc" },
select: MENTAL_HEALTH_BACKUP_SELECT,
}),
prisma.consentReceipt.findMany({
where: { userId },
orderBy: { signedAt: "asc" },
select: CONSENT_RECEIPT_BACKUP_SELECT,
}),
]);
return {
mentalHealthAssessments: assessmentRows.map((row) => ({
id: row.id,
instrument: row.instrument,
locale: row.locale,
version: row.version,
responsesEncrypted: Buffer.from(row.responsesEncrypted).toString(
"base64",
),
totalScore: row.totalScore,
severityBand: row.severityBand,
item9Flagged: row.item9Flagged,
crisisShownAt: row.crisisShownAt?.toISOString() ?? null,
takenAt: row.takenAt.toISOString(),
tz: row.tz,
source: row.source,
externalId: row.externalId,
syncVersion: row.syncVersion,
deletedAt: row.deletedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
consentReceipts: receiptRows.map((row) => ({
id: row.id,
kind: row.kind,
artefact: row.artefact,
signedAt: row.signedAt.toISOString(),
revokedAt: row.revokedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
})),
manifest: {
mentalHealth: { included: "full", note: MENTAL_HEALTH_INCLUDED_NOTE },
consent: { included: "full", note: CONSENT_INCLUDED_NOTE },
},
};
}
/** Row counts for the audit trail, mirroring the other section counters. */
export function countSensitiveBackupSection(
section: SensitiveBackupSection,
): SensitiveBackupCounts {
return {
mentalHealthAssessments: section.mentalHealthAssessments.length,
consentReceipts: section.consentReceipts.length,
};
}
/** Counts the sensitive restore wiped, for the audit trail. */
export interface SensitiveRestoreCleared {
mentalHealthAssessments: number;
consentReceipts: number;
}
type OptionalSensitive<T> = { [K in keyof T]?: T[K] | undefined };
export type RestoredMentalHealthAssessment = Pick<
MentalHealthAssessmentBackupEntry,
| "id"
| "instrument"
| "locale"
| "responsesEncrypted"
| "totalScore"
| "severityBand"
| "takenAt"
| "createdAt"
| "updatedAt"
> &
OptionalSensitive<
Pick<
MentalHealthAssessmentBackupEntry,
| "version"
| "item9Flagged"
| "crisisShownAt"
| "tz"
| "source"
| "externalId"
| "syncVersion"
| "deletedAt"
>
>;
export type RestoredConsentReceipt = Pick<
ConsentReceiptBackupEntry,
"id" | "kind" | "artefact" | "signedAt"
> &
OptionalSensitive<Pick<ConsentReceiptBackupEntry, "revokedAt" | "createdAt">>;
export interface SensitiveRestoreInput {
mentalHealthAssessments: RestoredMentalHealthAssessment[];
consentReceipts: RestoredConsentReceipt[];
}
/**
* Re-create the screener history and the consent record.
*
* Delete-then-recreate inside the caller's transaction, matching every other
* section. Neither model references anything but the account, so this can run
* anywhere in the sequence.
*
* `item9Flagged` is restored from the file rather than recomputed from the
* decrypted answers. Recomputing would mean decrypting every administration
* during a restore, which is exactly the handling this data is kept away from,
* and the stored flag is what the history view has been reading all along.
*/
export async function restoreSensitiveData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: SensitiveRestoreInput,
): Promise<SensitiveRestoreCleared> {
const [clearedAssessments, clearedReceipts] = await Promise.all([
tx.mentalHealthAssessment.deleteMany({ where: { userId: ownerId } }),
tx.consentReceipt.deleteMany({ where: { userId: ownerId } }),
]);
if (payload.mentalHealthAssessments.length > 0) {
await tx.mentalHealthAssessment.createMany({
data: payload.mentalHealthAssessments.map((entry) => ({
id: entry.id,
userId: ownerId,
instrument: entry.instrument,
locale: entry.locale,
version: entry.version ?? "standard",
responsesEncrypted: decodeBase64(entry.responsesEncrypted),
totalScore: entry.totalScore,
severityBand: entry.severityBand,
item9Flagged: entry.item9Flagged ?? false,
crisisShownAt: entry.crisisShownAt
? new Date(entry.crisisShownAt)
: null,
takenAt: new Date(entry.takenAt),
tz: entry.tz ?? null,
source: entry.source ?? "WEB",
externalId: entry.externalId ?? null,
syncVersion: entry.syncVersion ?? 0,
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
createdAt: new Date(entry.createdAt),
updatedAt: new Date(entry.updatedAt),
})),
});
}
if (payload.consentReceipts.length > 0) {
await tx.consentReceipt.createMany({
data: payload.consentReceipts.map((entry) => ({
id: entry.id,
userId: ownerId,
kind: entry.kind,
artefact: entry.artefact,
signedAt: new Date(entry.signedAt),
revokedAt: entry.revokedAt ? new Date(entry.revokedAt) : null,
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
})),
});
}
return {
mentalHealthAssessments: clearedAssessments.count,
consentReceipts: clearedReceipts.count,
};
}
function decodeBase64(encoded: string): Uint8Array<ArrayBuffer> {
const decoded = Buffer.from(encoded, "base64");
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
bytes.set(decoded);
return bytes;
}
+51
View File
@@ -49,6 +49,7 @@ import {
MeasurementReminderEventKind,
MeasurementSource,
MeasurementType,
AssessmentInstrument,
MedicationCategory,
PhaseMode,
MedicationContainerType,
@@ -1239,6 +1240,46 @@ const hiddenMoodTagSchema = z
})
.passthrough();
/**
* A completed screener administration. `responsesEncrypted` is REQUIRED rather
* than optional, unlike every other ciphertext field in this file: the column
* is NOT NULL, so a file that omits it describes a row that cannot be written,
* and saying so at the schema is better than discovering it inside the
* transaction that has already wiped the account.
*/
const mentalHealthAssessmentBackupSchema = z
.object({
id: z.string().min(1),
instrument: z.enum(AssessmentInstrument),
locale: z.string().min(1),
version: z.string().optional(),
responsesEncrypted: base64BytesSchema,
totalScore: z.number().int(),
severityBand: z.string().min(1),
item9Flagged: z.boolean().optional(),
crisisShownAt: isoDateTime.nullable().optional(),
takenAt: isoDateTime,
tz: z.string().nullable().optional(),
source: z.string().optional(),
externalId: z.string().nullable().optional(),
syncVersion: z.number().int().optional(),
deletedAt: isoDateTime.nullable().optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
})
.passthrough();
const consentReceiptBackupSchema = z
.object({
id: z.string().min(1),
kind: z.string().min(1),
artefact: z.string().min(1),
signedAt: isoDateTime,
revokedAt: isoDateTime.nullable().optional(),
createdAt: isoDateTime.optional(),
})
.passthrough();
const allergyBackupSchema = z
.object({
id: z.string().min(1),
@@ -1408,6 +1449,10 @@ export const backupPayloadSchema = z
// written before the reminders travelled carries no key, and an account
// with none writes [].
coachConversations: z.array(coachConversationBackupSchema).default([]),
mentalHealthAssessments: z
.array(mentalHealthAssessmentBackupSchema)
.default([]),
consentReceipts: z.array(consentReceiptBackupSchema).default([]),
coachFacts: z.array(coachFactBackupSchema).default([]),
coachPlans: z.array(coachPlanBackupSchema).default([]),
coachReminders: z.array(coachReminderBackupSchema).default([]),
@@ -1509,6 +1554,10 @@ export interface BackupSummary {
coachFacts: number;
coachPlans: number;
coachReminders: number;
/** Completed screener administrations. Disaster-recovery payloads only. */
mentalHealthAssessments: number;
/** Consent records. Disaster-recovery payloads only. */
consentReceipts: number;
}
export function summarizeBackup(payload: BackupPayload): BackupSummary {
@@ -1573,6 +1622,8 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
coachFacts: payload.coachFacts.length,
coachPlans: payload.coachPlans.length,
coachReminders: payload.coachReminders.length,
mentalHealthAssessments: payload.mentalHealthAssessments.length,
consentReceipts: payload.consentReceipts.length,
};
}
@@ -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({