Merge pull request #815 from MBombeck/feat/backup-medication-stock

Carry medication stock and its ledger through backup and restore
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-22 01:15:55 +02:00
commit 460ed37a2a
6 files changed
+280 -4

No files matched your search

@@ -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);
@@ -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"),
},
],
},
]),
},
+11 -4
View File
@@ -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<Record<string, string>> = {
"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:
+51
View File
@@ -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,
+38
View File
@@ -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();
+101
View File
@@ -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<void> {
// 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.