mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(backup): date a restored medication by its earliest record when the file has no date
Portable files exported before v1.39.1 carry no creation date for a medication, so the restore stamped the restore time. The dose history reads that date as the day the medication began to exist, and every miss before it, including the day before the restore, dropped out of the record. When the file has no creation date, the restore now uses the earliest thing the file records for the medication: its first dose (scheduled, taken or written), the start of its first schedule revision, its course start, its first pause, dose change or side effect, and never a date after the restore. A file that records nothing about the medication keeps the old default.
This commit is contained in:
1 parent
d7bc873691
commit
4897752c3c
4 files changed
+218
-1
No files matched your search
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* A medication restored from a file without `createdAt` is dated by the
|
||||
* earliest thing the file says happened to it, never later than the restore.
|
||||
*
|
||||
* Mutation check: return `undefined` whenever the file has no `createdAt`
|
||||
* (the old behaviour) and the evidence cases go red; drop the cap at `now`
|
||||
* and the future-course case goes red.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at";
|
||||
import { backupPayloadSchema } from "@/lib/validations/backup";
|
||||
|
||||
const NOW = new Date("2026-09-25T12:00:00.000Z");
|
||||
|
||||
function parse(
|
||||
medication: Record<string, unknown>,
|
||||
intakeEvents: unknown[] = [],
|
||||
) {
|
||||
const payload = backupPayloadSchema.parse({
|
||||
schemaVersion: "1",
|
||||
exportedAt: "2026-09-20T00:00:00.000Z",
|
||||
userId: "user-1",
|
||||
medications: [
|
||||
{ name: "Ramipril", dose: "5mg", schedules: [], ...medication },
|
||||
],
|
||||
intakeEvents,
|
||||
});
|
||||
return {
|
||||
medication: payload.medications[0]!,
|
||||
intakeEvents: payload.intakeEvents,
|
||||
};
|
||||
}
|
||||
|
||||
describe("restoredMedicationCreatedAt", () => {
|
||||
it("keeps the file's own creation date", () => {
|
||||
const { medication, intakeEvents } = parse({
|
||||
createdAt: "2024-01-02T00:00:00.000Z",
|
||||
});
|
||||
expect(restoredMedicationCreatedAt(medication, intakeEvents, NOW)).toEqual(
|
||||
new Date("2024-01-02T00:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the earliest dose of the medication when the file has no date", () => {
|
||||
const { medication, intakeEvents } = parse({}, [
|
||||
{
|
||||
medication: "Ramipril",
|
||||
scheduledFor: "2025-03-10T08:00:00.000Z",
|
||||
takenAt: "2025-03-10T08:05:00.000Z",
|
||||
},
|
||||
{
|
||||
medication: "Ramipril",
|
||||
scheduledFor: "2025-03-09T08:00:00.000Z",
|
||||
autoMissed: true,
|
||||
},
|
||||
// Another medication's dose is not evidence for this one.
|
||||
{ medication: "Other", scheduledFor: "2020-01-01T08:00:00.000Z" },
|
||||
]);
|
||||
expect(restoredMedicationCreatedAt(medication, intakeEvents, NOW)).toEqual(
|
||||
new Date("2025-03-09T08:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("takes an earlier schedule revision or course start over the doses", () => {
|
||||
const { medication, intakeEvents } = parse(
|
||||
{
|
||||
startsOn: "2025-02-01T00:00:00.000Z",
|
||||
scheduleRevisions: [
|
||||
{
|
||||
validFrom: "2025-01-15T00:00:00.000Z",
|
||||
validUntil: "2025-02-01T00:00:00.000Z",
|
||||
payload: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
[{ medication: "Ramipril", scheduledFor: "2025-03-09T08:00:00.000Z" }],
|
||||
);
|
||||
expect(restoredMedicationCreatedAt(medication, intakeEvents, NOW)).toEqual(
|
||||
new Date("2025-01-15T00:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("is never later than the restore", () => {
|
||||
const { medication, intakeEvents } = parse({
|
||||
startsOn: "2026-10-01T00:00:00.000Z",
|
||||
});
|
||||
expect(restoredMedicationCreatedAt(medication, intakeEvents, NOW)).toEqual(
|
||||
NOW,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the default when the file says nothing about the medication", () => {
|
||||
const { medication, intakeEvents } = parse({});
|
||||
expect(
|
||||
restoredMedicationCreatedAt(medication, intakeEvents, NOW),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The creation date a restored medication gets when the file does not say.
|
||||
*
|
||||
* Portable files written before v1.39.1 carry no `createdAt` for a
|
||||
* medication, and the restore used to let the column default to the moment
|
||||
* of the restore. The dose history reads that date as the day the
|
||||
* medication began to exist: `reconstructDoseHistory` and migration 0350
|
||||
* treat every slot before it as belonging to no medication, so the misses in
|
||||
* the day before the restore (and everything earlier) disappeared from the
|
||||
* record.
|
||||
*
|
||||
* The earliest thing the file itself says happened to the medication is the
|
||||
* closest honest stand-in: its first recorded dose (scheduled, taken or
|
||||
* written), the start of its first schedule revision, its course start, its
|
||||
* first pause, dose change or side effect. Never later than the restore:
|
||||
* a course that starts next week was still created by now.
|
||||
*/
|
||||
import type { BackupPayload } from "@/lib/validations/backup";
|
||||
|
||||
type BackupMedication = BackupPayload["medications"][number];
|
||||
type BackupIntakeEvent = BackupPayload["intakeEvents"][number];
|
||||
|
||||
/**
|
||||
* The file's own creation date when it has one; otherwise the earliest
|
||||
* evidence, capped at `now`; otherwise `undefined`, which leaves the column
|
||||
* to its default (the restore time) because the file says nothing at all.
|
||||
*/
|
||||
export function restoredMedicationCreatedAt(
|
||||
medication: BackupMedication,
|
||||
intakeEvents: readonly BackupIntakeEvent[],
|
||||
now: Date,
|
||||
): Date | undefined {
|
||||
if (medication.createdAt) return new Date(medication.createdAt);
|
||||
|
||||
const instants: Array<string | null | undefined> = [];
|
||||
for (const event of intakeEvents) {
|
||||
// Mirrors how the restore attaches an event: by id when the file names
|
||||
// one, by name otherwise.
|
||||
const belongs = event.medicationId
|
||||
? event.medicationId === medication.id ||
|
||||
(!medication.id && event.medication === medication.name)
|
||||
: event.medication === medication.name;
|
||||
if (!belongs) continue;
|
||||
instants.push(event.scheduledFor, event.takenAt, event.createdAt);
|
||||
}
|
||||
for (const revision of medication.scheduleRevisions) {
|
||||
instants.push(revision.validFrom);
|
||||
}
|
||||
instants.push(medication.startsOn);
|
||||
for (const era of medication.pauseEras) instants.push(era.pausedAt);
|
||||
for (const change of medication.doseChanges) {
|
||||
instants.push(change.effectiveFrom);
|
||||
}
|
||||
for (const effect of medication.sideEffects) instants.push(effect.occurredAt);
|
||||
|
||||
let earliest = Number.POSITIVE_INFINITY;
|
||||
for (const instant of instants) {
|
||||
if (!instant) continue;
|
||||
const ms = Date.parse(instant);
|
||||
if (Number.isFinite(ms) && ms < earliest) earliest = ms;
|
||||
}
|
||||
if (!Number.isFinite(earliest)) return undefined;
|
||||
return new Date(Math.min(earliest, now.getTime()));
|
||||
}
|
||||
@@ -87,6 +87,7 @@ import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup";
|
||||
import { restoreAwardsData } from "@/lib/export/awards-backup";
|
||||
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
|
||||
import { restoreEcgData } from "@/lib/export/ecg-backup";
|
||||
import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
import { foldLegacyCoachAvailability } from "@/lib/modules/operator-availability";
|
||||
|
||||
@@ -887,6 +888,7 @@ export async function restoreBackup(
|
||||
const unresolvedRevisionLinks: string[] = [];
|
||||
let medicationIndex = 0;
|
||||
reportSection("medications");
|
||||
const restoreStartedAt = new Date();
|
||||
for (const m of payload.medications) {
|
||||
const created = await tx.medication.create({
|
||||
data: {
|
||||
@@ -920,7 +922,17 @@ export async function restoreBackup(
|
||||
reorderLeadDays: m.reorderLeadDays ?? null,
|
||||
externalSource: m.externalSource ?? null,
|
||||
externalId: m.externalId ?? null,
|
||||
...(m.createdAt ? { createdAt: new Date(m.createdAt) } : {}),
|
||||
// A file from before v1.39.1 carries no creation date, and the
|
||||
// restore time would hide every dose-history miss before it.
|
||||
// The earliest thing the file records for the drug stands in.
|
||||
...(() => {
|
||||
const createdAt = restoredMedicationCreatedAt(
|
||||
m,
|
||||
payload.intakeEvents,
|
||||
restoreStartedAt,
|
||||
);
|
||||
return createdAt ? { createdAt } : {};
|
||||
})(),
|
||||
...(m.updatedAt ? { updatedAt: new Date(m.updatedAt) } : {}),
|
||||
schedules: {
|
||||
create: m.schedules.map((s) => ({
|
||||
|
||||
@@ -539,6 +539,48 @@ describe("backup restore as a background job", () => {
|
||||
expect(job?.failure?.code).toBe("failed_after_commit");
|
||||
});
|
||||
|
||||
it("dates a medication from a file without a creation date by its earliest dose", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const prisma = getPrismaClient();
|
||||
// A portable file from before v1.39.1: no medication createdAt.
|
||||
const payload = backupPayloadSchema.parse({
|
||||
schemaVersion: "1",
|
||||
exportedAt: "2026-09-20T00:00:00.000Z",
|
||||
userId: admin.id,
|
||||
medications: [{ name: "Ramipril", dose: "5mg", schedules: [] }],
|
||||
intakeEvents: [
|
||||
{
|
||||
medication: "Ramipril",
|
||||
scheduledFor: "2025-03-10T08:00:00.000Z",
|
||||
takenAt: "2025-03-10T08:04:00.000Z",
|
||||
},
|
||||
{
|
||||
medication: "Ramipril",
|
||||
scheduledFor: "2025-03-09T08:00:00.000Z",
|
||||
autoMissed: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
type: "MANUAL_UPLOAD_OLD_PORTABLE",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
const queued = await requestRestore(backup.id);
|
||||
const { data } = (await queued.json()) as { data: { jobId: string } };
|
||||
await runBackupRestoreJob(data.jobId);
|
||||
expect((await readBackupRestoreJob(data.jobId))?.status).toBe("succeeded");
|
||||
|
||||
const medication = await prisma.medication.findFirstOrThrow({
|
||||
where: { userId: admin.id },
|
||||
});
|
||||
// Not the restore time: the dose history reads this as the day the
|
||||
// medication began, and every miss before it would vanish.
|
||||
expect(medication.createdAt.toISOString()).toBe("2025-03-09T08:00:00.000Z");
|
||||
});
|
||||
|
||||
it("a live job's heartbeat keeps it out of the sweep", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const backup = await storeBackup(admin.id, ["m-a"]);
|
||||
|
||||
Reference in new issue
Block a user