mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user