From 86231ed23e5119d9de4ad0419237b08418af77f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 00:55:52 +0200 Subject: [PATCH] Carry medication stock and its ledger through backup and restore A restored account reported no supply at all. Every pack was gone, and with it every low-stock reminder, on a feature that became first-class when medication moved to one stock per drug. The two travel together because the register itself paired them: without the events the count "cannot be rebuilt even in principle", and without the packs the events are a list of deltas against nothing. The count comes back exactly as recorded, not recalculated from the ledger. It is the value the server had resolved and the person was shown, and every reminder already sent was computed from it. The fixture makes the difference visible on purpose: thirty in, eight consumed, and a remaining count of twenty-one, because somebody dropped one without recording it. A restore that "corrected" that to twenty-two would disagree with the account's own history and look like a bug fix while doing it. Both decimal columns cross the wire as strings. Half tablets are a real prescription and a JSON number would round them. The note follows the contract the side effects and dose changes beside it already use, and the packs nest inside their drug, so a portable restore minting fresh ids needs no remap. Verified by breaking it: recomputing the remaining count from the ledger fails the round trip with twenty-one against twenty-two. --- .../api/admin/backups/[id]/restore/route.ts | 43 ++++++++ .../__tests__/full-backup-payload.test.ts | 36 +++++++ src/lib/export/backup-plan.ts | 15 ++- src/lib/export/full-backup-payload.ts | 51 +++++++++ src/lib/validations/backup.ts | 38 +++++++ tests/integration/backup-round-trip.test.ts | 101 ++++++++++++++++++ 6 files changed, 280 insertions(+), 4 deletions(-) diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index adee20393..f913a7a75 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -674,6 +674,49 @@ const handler = apiHandler( : {}), })), }, + // The packs, and the ledger behind their counts. Nested for + // the same reason as everything above it, and `unitsRemaining` + // is written VERBATIM rather than recomputed from the events — + // it is what the server had resolved and what the person was + // shown, and recomputing here would silently correct a count + // they may have adjusted by hand. + inventoryItems: { + create: m.inventoryItems.map((i) => ({ + ...(i.id ? { id: i.id } : {}), + userId: ownerId, + state: (i.state ?? "ACTIVE") as never, + containerType: (i.containerType ?? "OTHER") as never, + unitsTotal: i.unitsTotal, + unitsRemaining: i.unitsRemaining, + firstUseAt: i.firstUseAt ? new Date(i.firstUseAt) : null, + expiresAt: i.expiresAt ? new Date(i.expiresAt) : null, + printedExpiry: i.printedExpiry + ? new Date(i.printedExpiry) + : null, + purchasedAt: i.purchasedAt ? new Date(i.purchasedAt) : null, + manufacturer: i.manufacturer ?? null, + doseStrength: i.doseStrength ?? null, + notes: null, + notesEncrypted: + i.notesEncrypted == null + ? encryptNote(i.notes ?? null) + : decodeEncryptedBytes(i.notesEncrypted), + ...(i.createdAt + ? { createdAt: new Date(i.createdAt) } + : {}), + ...(i.updatedAt + ? { updatedAt: new Date(i.updatedAt) } + : {}), + })), + }, + inventoryEvents: { + create: m.inventoryEvents.map((e) => ({ + ...(e.id ? { id: e.id } : {}), + delta: e.delta, + reason: e.reason, + occurredAt: new Date(e.occurredAt), + })), + }, }, }); restoredMedicationIds.add(created.id); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index c1ef893ab..27f4436ae 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -39,6 +39,7 @@ 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 doseChangeNote = encryptToBytes("titration note, encrypted at rest"); +const inventoryNote = encryptToBytes("second pack, kept in the kitchen drawer"); const moodNotePlaintext = "quiet evening, slept badly"; const moodNote = encryptToBytes(moodNotePlaintext); @@ -203,6 +204,41 @@ function makePrisma() { createdAt: new Date("2026-06-01T08:01:00.000Z"), }, ], + // One open pack and one used up, so the state enum is exercised on + // both arms, plus two ledger rows behind the open one. + inventoryItems: [ + { + id: "pack-open", + state: "ACTIVE", + containerType: "BLISTER", + unitsTotal: { toString: () => "30.0000" }, + unitsRemaining: { toString: () => "21.0000" }, + firstUseAt: new Date("2026-07-01T08:00:00.000Z"), + expiresAt: new Date("2027-01-31T00:00:00.000Z"), + printedExpiry: null, + purchasedAt: null, + manufacturer: "Fixture Pharma", + doseStrength: "10 mg", + notes: null, + notesEncrypted: inventoryNote, + createdAt: new Date("2026-07-01T08:01:00.000Z"), + updatedAt: new Date("2026-07-09T08:01:00.000Z"), + }, + ], + inventoryEvents: [ + { + id: "stock-in", + delta: 30, + reason: "purchased", + occurredAt: new Date("2026-07-01T07:00:00.000Z"), + }, + { + id: "stock-out", + delta: -8, + reason: "consumed", + occurredAt: new Date("2026-07-09T07:00:00.000Z"), + }, + ], }, ]), }, diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 3c2369a5c..bd37f4bba 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -343,6 +343,17 @@ export const TWO_ENDED_MODELS = [ "CoachFact", "CoachPlan", "CoachReminder", + // The packs on the shelf and the ledger behind their counts. The two travel + // together because the register itself paired them: without the events the + // count "cannot be rebuilt even in principle", and without the packs the + // events are a list of deltas against nothing. + // + // `unitsRemaining` is restored verbatim rather than recomputed from the + // ledger. It is the value the server had resolved and the person was shown, + // and every low-stock reminder already sent was computed from it — a restore + // that recalculated would disagree with the account's own history. + "MedicationInventoryItem", + "MedicationInventoryEvent", ] as const; /** One model claimed to travel both ways. */ @@ -383,10 +394,6 @@ export const COVERAGE_PENDING: Readonly> = { "The history of how a schedule changed. Without it a restored medication keeps today's schedule and loses the record of when the dose or timing moved, which is the part a doctor asks about.", 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: - "Pack sizes and remaining stock. A restored account reports no supply and every low-stock reminder has to be reconstructed by hand.", - MedicationInventoryEvent: - "The ledger of refills and consumption behind the stock count. Without it the count above cannot be rebuilt even in principle.", MoodTagCategory: "Categories a person created to group their own mood factors. Custom tags restore into a grouping that no longer exists.", MoodTagHidden: diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index b57e94c1c..07c6f1d63 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -89,6 +89,8 @@ export interface FullBackupCounts medicationSideEffects: number; medicationPauseEras: number; medicationDoseChanges: number; + medicationInventoryItems: number; + medicationInventoryEvents: number; moodEntries: number; cycles: number; cycleDayLogs: number; @@ -374,6 +376,8 @@ export async function buildFullBackupPayload( sideEffects: { orderBy: { occurredAt: "desc" } }, pauseEras: { orderBy: { pausedAt: "asc" } }, doseChanges: { orderBy: { effectiveFrom: "asc" } }, + inventoryItems: { orderBy: { createdAt: "asc" } }, + inventoryEvents: { orderBy: { occurredAt: "asc" } }, }, }), prisma.medicationIntakeEvent.findMany({ @@ -630,6 +634,45 @@ export async function buildFullBackupPayload( doseUnit: d.doseUnit, createdAt: d.createdAt.toISOString(), })), + // The packs on the shelf, and the ledger behind the number on them. + // + // `unitsRemaining` is carried VERBATIM rather than recomputed from the + // events, for the same reason `nextDueAt` is: it is what the server had + // resolved and what the person was shown. Recomputing on the way back + // would silently correct a count the account may have adjusted by hand, + // and would disagree with every low-stock reminder already sent. + // + // Both Decimal columns cross the wire as strings. A pack of 0.5 mg + // halves is a real prescription, and JSON numbers would round it. + inventoryItems: m.inventoryItems.map((i) => ({ + ...(disasterRecovery + ? { + id: i.id, + notes: i.notes, + notesEncrypted: i.notesEncrypted + ? Buffer.from(i.notesEncrypted).toString("base64") + : null, + } + : { notes: readNote(i.notesEncrypted, i.notes) }), + state: i.state, + containerType: i.containerType, + unitsTotal: i.unitsTotal.toString(), + unitsRemaining: i.unitsRemaining.toString(), + firstUseAt: i.firstUseAt?.toISOString() ?? null, + expiresAt: i.expiresAt?.toISOString() ?? null, + printedExpiry: i.printedExpiry?.toISOString() ?? null, + purchasedAt: i.purchasedAt?.toISOString() ?? null, + manufacturer: i.manufacturer, + doseStrength: i.doseStrength, + createdAt: i.createdAt.toISOString(), + updatedAt: i.updatedAt.toISOString(), + })), + inventoryEvents: m.inventoryEvents.map((e) => ({ + ...(disasterRecovery ? { id: e.id } : {}), + delta: e.delta, + reason: e.reason, + occurredAt: e.occurredAt.toISOString(), + })), })), intakeEvents: intakeEvents.map((e) => ({ ...(disasterRecovery @@ -799,6 +842,14 @@ export async function buildFullBackupPayload( (total, m) => total + m.doseChanges.length, 0, ), + medicationInventoryItems: medications.reduce( + (total, m) => total + m.inventoryItems.length, + 0, + ), + medicationInventoryEvents: medications.reduce( + (total, m) => total + m.inventoryEvents.length, + 0, + ), moodEntries: moodEntries.length, cycles: cycle.cycles.length, cycleDayLogs: cycle.cycleDayLogs.length, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index a0f370395..3f074d48a 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -50,6 +50,8 @@ import { MeasurementSource, MeasurementType, MedicationCategory, + MedicationContainerType, + MedicationInventoryState, MedicationDeliveryForm, MedicationScheduleType, MedicationSideEffectCategory, @@ -202,6 +204,40 @@ const medicationDoseChangeSchema = z }) .passthrough(); +/** + * A pack on the shelf. `unitsTotal` / `unitsRemaining` cross the wire as + * STRINGS: the columns are `Decimal(12,4)` because half tablets are a real + * prescription, and a JSON number would round them on the way through. + */ +const medicationInventoryItemSchema = z + .object({ + id: z.string().min(1).optional(), + state: z.enum(MedicationInventoryState).optional(), + containerType: z.enum(MedicationContainerType).optional(), + unitsTotal: z.string().min(1), + unitsRemaining: z.string().min(1), + firstUseAt: isoDateTime.nullable().optional(), + expiresAt: isoDateTime.nullable().optional(), + printedExpiry: isoDateTime.nullable().optional(), + purchasedAt: isoDateTime.nullable().optional(), + manufacturer: z.string().nullable().optional(), + doseStrength: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); + +const medicationInventoryEventSchema = z + .object({ + id: z.string().min(1).optional(), + delta: z.number().int(), + reason: z.string().min(1), + occurredAt: isoDateTime, + }) + .passthrough(); + const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -242,6 +278,8 @@ const medicationSchema = z // Same default, same reason: an older file parses, a drug never titrated // writes []. doseChanges: z.array(medicationDoseChangeSchema).default([]), + inventoryItems: z.array(medicationInventoryItemSchema).default([]), + inventoryEvents: z.array(medicationInventoryEventSchema).default([]), }) .passthrough(); diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 340719aa3..3ec2f67da 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -82,6 +82,7 @@ vi.mock("@/lib/cache/invalidate", () => ({ const OWNER_ID = "round-trip-owner"; const AT = (iso: string) => new Date(iso); +const INVENTORY_NOTE = "second pack, kept in the kitchen drawer"; const COACH_FACT = "does not tolerate ACE inhibitors"; const COACH_PLAN_CUE = "if the evening reading is over 140"; const COACH_PLAN_ACTION = "then walk twenty minutes before dinner tomorrow"; @@ -182,6 +183,11 @@ const COUNT_BACK: Record< CoachFact: (p, userId) => p.coachFact.count({ where: { userId } }), CoachPlan: (p, userId) => p.coachPlan.count({ where: { userId } }), CoachReminder: (p, userId) => p.coachReminder.count({ where: { userId } }), + MedicationInventoryItem: (p, userId) => + p.medicationInventoryItem.count({ where: { userId } }), + // No own `userId` column — reached through the drug, like the schedules. + MedicationInventoryEvent: (p, userId) => + p.medicationInventoryEvent.count({ where: { medication: { userId } } }), }; /** @@ -256,6 +262,54 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { // A two-step ramp. The ORDER is the content here: same drug, same unit, // 5 mg then 10 mg. A restore that returned them reversed would still count // two rows back and would describe the opposite clinical story. + // One open pack and one used up, plus the two ledger rows behind the open + // one. The count on the pack is deliberately NOT the sum of its events: 30 + // in, 8 consumed, and the remaining count says 21 because the person threw + // one away without recording it. A restore that recomputed the count from + // the ledger would "fix" that to 22 and disagree with every low-stock + // reminder the account has already received. + const openPack = await prisma.medicationInventoryItem.create({ + data: { + userId: OWNER_ID, + medicationId: medication.id, + state: "ACTIVE", + containerType: "BLISTER", + unitsTotal: "30", + unitsRemaining: "21", + firstUseAt: AT("2026-07-01T08:00:00.000Z"), + expiresAt: AT("2027-01-31T00:00:00.000Z"), + manufacturer: "Fixture Pharma", + doseStrength: "10 mg", + notesEncrypted: encryptToBytes(INVENTORY_NOTE), + }, + }); + await prisma.medicationInventoryItem.create({ + data: { + userId: OWNER_ID, + medicationId: medication.id, + state: "USED_UP", + containerType: "BOTTLE", + unitsTotal: "60", + unitsRemaining: "0", + }, + }); + await prisma.medicationInventoryEvent.createMany({ + data: [ + { + medicationId: medication.id, + delta: 30, + reason: "purchased", + occurredAt: AT("2026-07-01T07:00:00.000Z"), + }, + { + medicationId: medication.id, + delta: -8, + reason: "consumed", + occurredAt: AT("2026-07-09T07:00:00.000Z"), + }, + ], + }); + await prisma.medicationDoseChange.createMany({ data: [ { @@ -1014,6 +1068,53 @@ describe("every model the plan claims two-ended survives a real restore", () => status: "active", }); + // The shelf, and the count that must not be recomputed. + const packs = await prisma.medicationInventoryItem.findMany({ + where: { userId: OWNER_ID }, + orderBy: { createdAt: "asc" }, + }); + expect( + packs.map((pack) => ({ + state: pack.state, + containerType: pack.containerType, + total: pack.unitsTotal.toString(), + remaining: pack.unitsRemaining.toString(), + manufacturer: pack.manufacturer, + note: readNote(pack.notesEncrypted, pack.notes), + plaintextColumn: pack.notes, + })), + "the remaining count comes back as recorded, not as recalculated", + ).toEqual([ + { + state: "ACTIVE", + containerType: "BLISTER", + total: "30", + remaining: "21", + manufacturer: "Fixture Pharma", + note: INVENTORY_NOTE, + plaintextColumn: null, + }, + { + state: "USED_UP", + containerType: "BOTTLE", + total: "60", + remaining: "0", + manufacturer: null, + note: null, + plaintextColumn: null, + }, + ]); + const stockLedger = await prisma.medicationInventoryEvent.findMany({ + where: { medication: { userId: OWNER_ID } }, + orderBy: { occurredAt: "asc" }, + }); + expect( + stockLedger.map((e) => ({ delta: e.delta, reason: e.reason })), + ).toEqual([ + { delta: 30, reason: "purchased" }, + { delta: -8, reason: "consumed" }, + ]); + // The ramp, in order, with the note back in its column. Counting says two // rows returned; only this says they came back as the same ramp, and that // the encrypted note did not land in the plaintext column on the way.