mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge remote-tracking branch 'origin/work/side-effect-backup' into work/sharing-write-integration
This commit is contained in:
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user