mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
This commit is contained in:
1 parent
38900656a3
commit
1ba422b87c
2 files changed
+135
-7
No files matched your search
@@ -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,
|
||||
|
||||
@@ -3620,6 +3620,16 @@ const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
|
||||
"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<Record<string, string>> = {
|
||||
"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();
|
||||
|
||||
Reference in new issue
Block a user