From 1ba422b87cbd08e30f48805a1726bb93827e7285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:14:43 +0200 Subject: [PATCH] fix(backup): restart the auto-take floor at the restore A portable file carries no removed doses, so a restored medication with automatic recording on had the pass fill a dose the person deleted in the days before the export. The restored floor is now the later of the file's value and the restore. --- src/lib/export/restore-backup.ts | 19 ++- tests/integration/backup-round-trip.test.ts | 123 +++++++++++++++++++- 2 files changed, 135 insertions(+), 7 deletions(-) diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 6ce6e2520..b2659d8ea 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -1232,14 +1232,21 @@ export async function restoreBackup( oneShot: m.oneShot ?? false, asNeeded: m.asNeeded ?? false, trackIntake: m.trackIntake ?? true, - // v1.42.2 — an option that is on always carries its floor; a - // file that has the flag without one starts it at the restore, - // so the auto-take pass never fills history it did not own. + // v1.42.2 — an option that is on always carries its floor, and + // a restored floor is never before the restore: a portable file + // carries no removed doses, so a dose the person deleted in the + // days before the export would otherwise be filled again as + // taken by the next auto-take pass. autoMarkTaken: m.autoMarkTaken ?? false, autoMarkTakenSince: m.autoMarkTaken - ? m.autoMarkTakenSince - ? new Date(m.autoMarkTakenSince) - : new Date() + ? new Date( + Math.max( + m.autoMarkTakenSince + ? Date.parse(m.autoMarkTakenSince) + : 0, + restoreStartedAt.getTime(), + ), + ) : null, deliveryForm: m.deliveryForm ?? "ORAL", trackInjectionSites: m.trackInjectionSites ?? false, diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6ba296592..7cde2c4cb 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -3620,6 +3620,16 @@ const COLUMN_EXCLUSIONS: Readonly> = { "readable copy of the sealed period location; the restore seals it and writes this empty", }; +/** + * Columns the restore carries on purpose as "set where it was set" rather + * than the same value, each with the reason. A presence check still fails a + * column that stops coming back at all. + */ +const COLUMN_PRESENCE_ONLY: Readonly> = { + "Medication.autoMarkTakenSince": + "the auto-take floor restarts at the restore, so doses a portable file cannot carry as removed are not filled again; pinned in the restore test for it", +}; + /** * Columns a release adds ahead of the backup ends that carry them, so the * packages built in parallel on its branch each own their own section of the @@ -3760,6 +3770,7 @@ function comparison( // A declared foreign key may be re-pointed at a row the restore minted, so // it is held to being set where it was set, not to the same id. if (column.isForeignKey) return "presence"; + if (key in COLUMN_PRESENCE_ONLY) return "presence"; return "value"; } @@ -3915,7 +3926,12 @@ describe("every column of every two-ended model survives a real restore", () => const rows = seededRows.get(model.name)!; expect(rows.length, `${model.name} seeded no row`).toBeGreaterThan(0); for (const column of model.columns) { - if (comparison(column) !== "value") continue; + if ( + comparison(column) !== "value" && + !(`${model.name}.${column.name}` in COLUMN_PRESENCE_ONLY) + ) { + continue; + } // Filling it would turn the fixture's row into a tombstone, and which // models carry tombstones is a row decision, not a column one. if (column.name === "deletedAt") continue; @@ -4092,6 +4108,7 @@ describe("every column of every two-ended model survives a real restore", () => ); const stale = [ ...Object.keys(COLUMN_EXCLUSIONS), + ...Object.keys(COLUMN_PRESENCE_ONLY), ...Object.keys(RELEASE_PENDING_COLUMNS), ...Object.keys(READ_THROUGH), ...Object.keys(FILL_OVERRIDES), @@ -4315,6 +4332,110 @@ describe("the account's own settings survive a real restore", () => { }); }); +describe("automatic dose recording after a restore", () => { + it("starts its floor at the restore, so a dose removed before the export stays removed", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { timezone: "UTC" }, + }); + const now = Date.now(); + const dayAt12 = (offset: number) => { + const d = new Date(now + offset * 86_400_000); + return new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12), + ); + }; + const since = dayAt12(-10); + const medication = await prisma.medication.create({ + data: { + userId: OWNER_ID, + name: "Evening supplement", + dose: "1", + createdAt: dayAt12(-20), + autoMarkTaken: true, + autoMarkTakenSince: since, + schedules: { + create: { + windowStart: "12:00", + windowEnd: "12:00", + timesOfDay: ["12:00"], + rrule: "FREQ=DAILY", + scheduleType: "SCHEDULED", + }, + }, + }, + select: { id: true }, + }); + // The pass filled the last days; the person removed the dose two days + // ago (they did not take it). A portable file carries no removal. + const removed = dayAt12(-2); + for (let offset = -6; offset <= -1; offset++) { + const at = dayAt12(offset); + await prisma.medicationIntakeEvent.create({ + data: { + userId: OWNER_ID, + medicationId: medication.id, + scheduledFor: at, + takenAt: at, + source: "REMINDER", + autoRecorded: true, + deletedAt: + at.getTime() === removed.getTime() + ? new Date(at.getTime() + 3_600_000) + : null, + }, + }); + } + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "portable-export", + }); + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + const restoredFrom = Date.now(); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + const restored = await prisma.medication.findFirstOrThrow({ + where: { userId: OWNER_ID }, + select: { id: true, autoMarkTaken: true, autoMarkTakenSince: true }, + }); + expect(restored.autoMarkTaken).toBe(true); + expect(restored.autoMarkTakenSince!.getTime()).toBeGreaterThanOrEqual( + restoredFrom, + ); + + const { runIntakeAutoTakePass } = + await import("@/lib/jobs/intake-auto-take"); + await runIntakeAutoTakePass(prisma, { nowMs: Date.now() }); + const onRemovedSlot = await prisma.medicationIntakeEvent.count({ + where: { + medicationId: restored.id, + deletedAt: null, + scheduledFor: removed, + }, + }); + expect(onRemovedSlot).toBe(0); + }); +}); + describe("a document kind added after the backup format", () => { it("restores a sick note as a sick note", async () => { const prisma = getPrismaClient();