Merge remote-tracking branch 'origin/work/side-effect-backup' into work/sharing-write-integration

This commit is contained in:
Marc-André Bombeck committed 2026-08-03 21:18:13 +02:00
commit 7eed26eb01
10 files changed
+297 -12

No files matched your search

@@ -2,10 +2,11 @@
* POST /api/admin/backups/[id]/restore — admin-only disaster recovery.
*
* After owner and schema validation, one transaction replaces every
* serialized owner-scoped class: measurements, medication history, mood and
* rated factors, cycle data, labs/biomarkers, illness history, allergies,
* family history, workout summaries, and inbound documents. Document content
* and summary ciphertext are decoded from base64 and persisted verbatim.
* serialized owner-scoped class: measurements, medication history including the
* side effects recorded against a drug, mood and rated factors, cycle data,
* labs/biomarkers, illness history, allergies, family history, workout
* summaries, and inbound documents. Document content and summary ciphertext are
* decoded from base64 and persisted verbatim.
*
* Metadata-only portable document exports are rejected before mutation; the
* importer never fabricates content. Audit rows remain outside the wipe, and
@@ -584,6 +585,32 @@ const handler = apiHandler(
doseWindows: (s.doseWindows ?? null) as never,
})),
},
// Written in the same `create` as the drug, so the FK binds to
// the id this row actually got. A canonical DR file preserves
// the medication's id and a portable one mints a fresh cuid;
// neither case needs the old `medicationId` from the file,
// which is why the payload does not carry it. The note follows
// the measurement contract next to it: ciphertext verbatim when
// the file has it, legacy plaintext encrypted on the way in,
// and the plaintext column left null either way.
sideEffects: {
create: m.sideEffects.map((s) => ({
...(s.id ? { id: s.id } : {}),
userId: ownerId,
occurredAt: new Date(s.occurredAt),
category: s.category,
entry: s.entry,
severity: s.severity,
notes: null,
notesEncrypted:
s.notesEncrypted == null
? encryptNote(s.notes ?? null)
: decodeEncryptedBytes(s.notesEncrypted),
...(s.createdAt
? { createdAt: new Date(s.createdAt) }
: {}),
})),
},
},
});
restoredMedicationIds.add(created.id);
+1
View File
@@ -105,6 +105,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
export_measurements_count: counts.measurements,
export_medications_count: counts.medications,
export_intake_count: counts.intakeEvents,
export_medication_side_effect_count: counts.medicationSideEffects,
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
+1
View File
@@ -64,6 +64,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
export_measurements_count: counts.measurements,
export_medications_count: counts.medications,
export_intake_count: counts.intakeEvents,
export_medication_side_effect_count: counts.medicationSideEffects,
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
+4
View File
@@ -324,6 +324,9 @@ export function BackupsSection() {
medications: number;
intakeEvents: number;
moodEntries: number;
// Optional for the same reason as the keys below: a file written
// before side effects rode the wire carries no key at all.
medicationSideEffects?: number;
cycles?: number;
cycleDayLogs?: number;
// v1.28 backup-completeness — optional so an older-schema upload
@@ -354,6 +357,7 @@ export function BackupsSection() {
data.summary.medications +
data.summary.intakeEvents +
data.summary.moodEntries +
(data.summary.medicationSideEffects ?? 0) +
(data.summary.cycles ?? 0) +
(data.summary.cycleDayLogs ?? 0) +
(data.summary.labResults ?? 0) +
@@ -37,6 +37,7 @@ import { restoreProfileData } from "../profile-backup";
const deletedAt = new Date("2026-07-19T12:00:00.000Z");
const measurementNote = encryptToBytes("canonical measurement note");
const sideEffectNote = encryptToBytes("nausea after the evening dose");
const appSettings = {
id: "singleton",
@@ -133,6 +134,29 @@ function makePrisma() {
],
},
],
sideEffects: [
{
id: "side-effect-canonical",
occurredAt: new Date("2026-07-18T21:00:00.000Z"),
category: "GI",
entry: "NAUSEA",
severity: 3,
notes: null,
notesEncrypted: sideEffectNote,
createdAt: new Date("2026-07-18T21:05:00.000Z"),
},
{
// A row the note backfill has not reached: plaintext only.
id: "side-effect-legacy",
occurredAt: new Date("2026-07-17T21:00:00.000Z"),
category: "COGNITIVE",
entry: "DIZZINESS",
severity: 1,
notes: "legacy plaintext note",
notesEncrypted: null,
createdAt: new Date("2026-07-17T21:05:00.000Z"),
},
],
},
]),
},
@@ -492,6 +516,90 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
});
});
/**
* Side effects ride inside their medication, and the note rides with them.
*
* The two shapes differ on purpose: a portable export is the human-readable
* artefact, so it carries the DECRYPTED note and no ciphertext at all; the
* disaster-recovery file carries the ciphertext verbatim so the same instance's
* key reads it back unchanged. Both are asserted, because emitting ciphertext
* into a portable export and losing the note are opposite failures of the same
* decision.
*/
describe("buildFullBackupPayload — medication side effects", () => {
it("decrypts the note and emits no ciphertext in a portable export", async () => {
installSectionMocks();
const { payload, counts } = await buildFullBackupPayload(
makePrisma() as never,
"user-1",
{
purpose: "portable-export",
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
},
);
const parsed = parseBackupPayload(payload);
expect(parsed.medications[0].sideEffects).toEqual([
{
occurredAt: "2026-07-18T21:00:00.000Z",
category: "GI",
entry: "NAUSEA",
severity: 3,
notes: "nausea after the evening dose",
},
{
occurredAt: "2026-07-17T21:00:00.000Z",
category: "COGNITIVE",
entry: "DIZZINESS",
severity: 1,
notes: "legacy plaintext note",
},
]);
expect(JSON.stringify(payload)).not.toContain(
Buffer.from(sideEffectNote).toString("base64"),
);
expect(counts.medicationSideEffects).toBe(2);
});
it("carries the ciphertext verbatim in a disaster-recovery payload", async () => {
installSectionMocks();
const { payload } = await buildFullBackupPayload(
makePrisma() as never,
"user-1",
{
purpose: "disaster-recovery",
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
},
);
const parsed = parseBackupPayload(payload);
expect(parsed.medications[0].sideEffects).toEqual([
{
id: "side-effect-canonical",
occurredAt: "2026-07-18T21:00:00.000Z",
category: "GI",
entry: "NAUSEA",
severity: 3,
notes: null,
notesEncrypted: Buffer.from(sideEffectNote).toString("base64"),
createdAt: "2026-07-18T21:05:00.000Z",
},
{
id: "side-effect-legacy",
occurredAt: "2026-07-17T21:00:00.000Z",
category: "COGNITIVE",
entry: "DIZZINESS",
severity: 1,
notes: "legacy plaintext note",
notesEncrypted: null,
createdAt: "2026-07-17T21:05:00.000Z",
},
]);
});
});
/**
* `aggregationProvenance` is what stops an export.xml source-day estimate from
* overwriting a native HealthKit statistic. A disaster-recovery backup that
+1 -2
View File
@@ -210,6 +210,7 @@ export const TWO_ENDED_MODELS = [
"Medication",
"MedicationSchedule",
"MedicationIntakeEvent",
"MedicationSideEffect",
"MoodEntry",
"MoodEntryTagLink",
"MoodTag",
@@ -276,8 +277,6 @@ export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
"Titration history — when a dose went up or down and by how much. A restore rebuilds the current dose and drops the ramp that led to it.",
MedicationPauseEra:
"The spans where a medication was deliberately paused. Without them the compliance recomputation counts a deliberate pause as missed doses and the restored account looks non-adherent.",
MedicationSideEffect:
"Side effects the person recorded against a drug. Restoring the drug without them loses the reason someone may have stopped taking it.",
MedicationEfficacyTarget:
"What a medication was supposed to move, and by how much. The drug comes back with no statement of what it was for.",
MedicationInventoryItem:
+38 -1
View File
@@ -53,6 +53,7 @@ export interface FullBackupCounts
measurements: number;
medications: number;
intakeEvents: number;
medicationSideEffects: number;
moodEntries: number;
cycles: number;
cycleDayLogs: number;
@@ -186,7 +187,17 @@ export async function buildFullBackupPayload(
})(),
prisma.medication.findMany({
where: { userId },
include: { schedules: true },
// Side effects ride INSIDE their medication, exactly as the schedules
// beside them do. The alternative — a top-level array carrying
// `medicationId` — would have to survive a restore that mints new
// medication ids for a portable file, which is the id-remap the intake
// events already have to work around by drug name. A nested create has
// no id to remap: Prisma binds the child to whatever id the parent row
// actually got.
include: {
schedules: true,
sideEffects: { orderBy: { occurredAt: "desc" } },
},
}),
prisma.medicationIntakeEvent.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
@@ -336,6 +347,28 @@ export async function buildFullBackupPayload(
label: s.label,
dose: s.dose,
})),
// What the person recorded against the drug, and the reason a drug may
// have been stopped. `notes` is the same dual-column arrangement
// `Measurement` has and gets the same treatment: a portable export
// carries the DECRYPTED note and no ciphertext, a disaster-recovery
// payload carries the ciphertext verbatim plus whatever legacy plaintext
// the row still holds, and the restore re-encrypts the plaintext case.
sideEffects: m.sideEffects.map((s) => ({
...(disasterRecovery
? {
id: s.id,
notes: s.notes,
notesEncrypted: s.notesEncrypted
? Buffer.from(s.notesEncrypted).toString("base64")
: null,
createdAt: s.createdAt.toISOString(),
}
: { notes: readNote(s.notesEncrypted, s.notes) }),
occurredAt: s.occurredAt.toISOString(),
category: s.category,
entry: s.entry,
severity: s.severity,
})),
})),
intakeEvents: intakeEvents.map((e) => ({
...(disasterRecovery
@@ -428,6 +461,10 @@ export async function buildFullBackupPayload(
measurements: measurements.length,
medications: medications.length,
intakeEvents: intakeEvents.length,
medicationSideEffects: medications.reduce(
(total, m) => total + m.sideEffects.length,
0,
),
moodEntries: moodEntries.length,
cycles: cycle.cycles.length,
cycleDayLogs: cycle.cycleDayLogs.length,
+33
View File
@@ -49,6 +49,8 @@ import {
MedicationCategory,
MedicationDeliveryForm,
MedicationScheduleType,
MedicationSideEffectCategory,
MedicationSideEffectEntry,
OvulationTest,
RhythmClassification,
SecondarySymptom,
@@ -129,6 +131,28 @@ const medicationScheduleSchema = z
})
.passthrough();
/**
* One recorded side effect, carried inside its medication.
*
* `notes` is the decrypted note in a portable export and the row's legacy
* plaintext column in a canonical DR file; `notesEncrypted` is the base64
* ciphertext and rides only in the DR case. The restore prefers the ciphertext
* and encrypts the plaintext when that is all the file has, so neither shape
* loses the note and neither writes plaintext back into the column.
*/
const medicationSideEffectSchema = z
.object({
id: z.string().min(1).optional(),
occurredAt: isoDateTime,
category: z.enum(MedicationSideEffectCategory),
entry: z.enum(MedicationSideEffectEntry),
severity: z.number().int().min(1).max(5),
notes: z.string().nullable().optional(),
notesEncrypted: base64BytesSchema.nullable().optional(),
createdAt: isoDateTime.optional(),
})
.passthrough();
const medicationSchema = z
.object({
id: z.string().min(1).optional(),
@@ -160,6 +184,9 @@ const medicationSchema = z
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
schedules: z.array(medicationScheduleSchema).default([]),
// Defaulted so a file written before side effects rode the wire still
// parses, and a drug with none writes [].
sideEffects: z.array(medicationSideEffectSchema).default([]),
})
.passthrough();
@@ -907,6 +934,8 @@ export interface BackupSummary {
measurements: number;
medications: number;
intakeEvents: number;
/** Side effects recorded against a drug, across every medication. */
medicationSideEffects: number;
moodEntries: number;
/** v1.15.0 — observed cycle spans in the backup. */
cycles: number;
@@ -953,6 +982,10 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
measurements: payload.measurements.length,
medications: payload.medications.length,
intakeEvents: payload.intakeEvents.length,
medicationSideEffects: payload.medications.reduce(
(sum, medication) => sum + medication.sideEffects.length,
0,
),
moodEntries: payload.moodEntries.length,
cycles: payload.cycles.length,
cycleDayLogs: payload.cycleDayLogs.length,
@@ -284,6 +284,19 @@ describe("canonical disaster-recovery backup round-trip", () => {
},
include: { schedules: true },
});
const sideEffect = await prisma.medicationSideEffect.create({
data: {
id: "side-effect-dr",
userId: ownerId,
medicationId: medication.id,
occurredAt: new Date("2026-06-27T21:00:00.000Z"),
category: "INJECTION_SITE",
entry: "INJECTION_SWELLING",
severity: 4,
notesEncrypted: encryptToBytes("swollen for a day after the injection"),
createdAt: new Date("2026-06-27T21:10:00.000Z"),
},
});
const intake = await prisma.medicationIntakeEvent.create({
data: {
id: "intake-dr",
@@ -583,6 +596,15 @@ describe("canonical disaster-recovery backup round-trip", () => {
where: { id: intake.id },
}),
).toEqual(intake);
// Byte-for-byte, ciphertext included: a canonical payload carries the
// encrypted note verbatim, so the same instance's key reads back exactly
// what it wrote. Re-encrypting here would still decrypt to the same words
// and would still be a different row than the one the file described.
expect(
await prisma.medicationSideEffect.findUniqueOrThrow({
where: { id: sideEffect.id },
}),
).toEqual(sideEffect);
expect(
await prisma.cycleProfile.findUniqueOrThrow({
where: { id: cycleProfile.id },
+58 -5
View File
@@ -26,11 +26,18 @@
* The registry below is keyed by `TwoEndedModel`, so a name added to the plan's
* two-ended list without a row seeded and counted here does not compile.
*
* What this still does not prove: that a restored row carries the right VALUES.
* It asks whether the rows came back, not whether they came back intact —
* `admin-backups-canonical-roundtrip.test.ts` is where field-level fidelity is
* asserted. A restore that wrote one row per model with every column defaulted
* would satisfy this file and fail that one.
* What this still does not prove, with one exception: that a restored row
* carries the right VALUES. It asks whether the rows came back, not whether
* they came back intact — `admin-backups-canonical-roundtrip.test.ts` is where
* field-level fidelity is asserted. A restore that wrote one row per model with
* every column defaulted would satisfy this file and fail that one.
*
* The exception is the medication side effect. Its note lives in an encrypted
* column beside a legacy plaintext one, so "the row came back" and "the note
* came back" are genuinely different answers here: a restore that dropped the
* ciphertext would still be counted as recovered by everything above. The
* severity, the category and the entry are asserted alongside it, because a
* side effect without them says something happened and not what.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -40,6 +47,7 @@ process.env.ENCRYPTION_KEY ??=
import type { PrismaClient } from "@/generated/prisma/client";
import { encrypt, encryptBytes } from "@/lib/crypto";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { readNote } from "@/lib/crypto/note-cipher";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
@@ -74,6 +82,7 @@ vi.mock("@/lib/cache/invalidate", () => ({
const OWNER_ID = "round-trip-owner";
const AT = (iso: string) => new Date(iso);
const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
@@ -94,6 +103,8 @@ const COUNT_BACK: Record<
p.medicationSchedule.count({ where: { medication: { userId } } }),
MedicationIntakeEvent: (p, userId) =>
p.medicationIntakeEvent.count({ where: { userId } }),
MedicationSideEffect: (p, userId) =>
p.medicationSideEffect.count({ where: { userId } }),
MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }),
MoodEntryTagLink: (p, userId) =>
p.moodEntryTagLink.count({ where: { moodEntry: { userId } } }),
@@ -183,6 +194,19 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
takenAt: AT("2026-07-01T08:04:00.000Z"),
},
});
// The one row in this fixture that is also checked field by field after the
// restore — see the assertion at the end of the test for why.
await prisma.medicationSideEffect.create({
data: {
userId: OWNER_ID,
medicationId: medication.id,
occurredAt: AT("2026-07-01T21:00:00.000Z"),
category: "GI",
entry: "NAUSEA",
severity: 3,
notesEncrypted: encryptToBytes(SIDE_EFFECT_NOTE),
},
});
// A RATED tag the account defined itself — the export carries only rated
// links, so a BINARY tag would leave `MoodEntryTagLink` empty.
@@ -477,5 +501,34 @@ describe("every model the plan claims two-ended survives a real restore", () =>
"without them, which is a backup that reports success and hands back " +
"less than it was given",
).toEqual([]);
const sideEffect = await prisma.medicationSideEffect.findFirstOrThrow({
where: { userId: OWNER_ID },
include: { medication: { select: { name: true, userId: true } } },
});
expect({
category: sideEffect.category,
entry: sideEffect.entry,
severity: sideEffect.severity,
occurredAt: sideEffect.occurredAt.toISOString(),
medication: sideEffect.medication.name,
medicationOwner: sideEffect.medication.userId,
note: readNote(sideEffect.notesEncrypted, sideEffect.notes),
}).toEqual({
category: "GI",
entry: "NAUSEA",
severity: 3,
occurredAt: "2026-07-01T21:00:00.000Z",
medication: "Round-trip tablet",
medicationOwner: OWNER_ID,
note: SIDE_EFFECT_NOTE,
});
// The note must be BACK IN THE COLUMN it came from, not carried as
// plaintext: a restore that writes a portable export's decrypted note into
// `notes` reads identically through `readNote` above and has quietly
// un-encrypted the account's free text.
expect(sideEffect.notes, "plaintext must not come back in the column").toBe(
null,
);
});
});