diff --git a/src/lib/export/__tests__/medication-created-at.test.ts b/src/lib/export/__tests__/medication-created-at.test.ts new file mode 100644 index 000000000..8a25de309 --- /dev/null +++ b/src/lib/export/__tests__/medication-created-at.test.ts @@ -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, + 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(); + }); +}); diff --git a/src/lib/export/medication-created-at.ts b/src/lib/export/medication-created-at.ts new file mode 100644 index 000000000..c52385466 --- /dev/null +++ b/src/lib/export/medication-created-at.ts @@ -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 = []; + 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())); +} diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index e9dad974f..0814e1187 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -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) => ({ diff --git a/tests/integration/backup-restore-job.test.ts b/tests/integration/backup-restore-job.test.ts index 11e228980..d21403c4b 100644 --- a/tests/integration/backup-restore-job.test.ts +++ b/tests/integration/backup-restore-job.test.ts @@ -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"]);