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,