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.