Merge pull request #811 from MBombeck/feat/backup-medication-history

Carry medication dose changes through backup and restore
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-21 23:39:46 +02:00
commit 3f570b6f53
6 files changed
+152 -2

No files matched your search

@@ -644,6 +644,26 @@ const handler = apiHandler(
: {}),
})),
},
// The titration steps, nested for the same reason. The note
// takes the measurement contract next to it: ciphertext when
// the file has it, legacy plaintext encrypted on the way in,
// and the plaintext column left null either way.
doseChanges: {
create: m.doseChanges.map((d) => ({
...(d.id ? { id: d.id } : {}),
effectiveFrom: new Date(d.effectiveFrom),
doseValue: d.doseValue,
doseUnit: d.doseUnit,
note: null,
noteEncrypted:
d.noteEncrypted == null
? encryptNote(d.note ?? null)
: decodeEncryptedBytes(d.noteEncrypted),
...(d.createdAt
? { createdAt: new Date(d.createdAt) }
: {}),
})),
},
},
});
restoredMedicationIds.add(created.id);
@@ -38,6 +38,7 @@ import { restoreProfileData } from "../profile-backup";
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 moodNotePlaintext = "quiet evening, slept badly";
const moodNote = encryptToBytes(moodNotePlaintext);
@@ -180,6 +181,28 @@ function makePrisma() {
createdAt: new Date("2026-07-15T08:01:00.000Z"),
},
],
// A two-step ramp, one row carrying an encrypted note and one
// without, so both arms of the note contract are exercised.
doseChanges: [
{
id: "dose-ramp-up",
effectiveFrom: new Date("2026-04-01T08:00:00.000Z"),
doseValue: 5,
doseUnit: "mg",
note: null,
noteEncrypted: doseChangeNote,
createdAt: new Date("2026-04-01T08:01:00.000Z"),
},
{
id: "dose-ramp-hold",
effectiveFrom: new Date("2026-06-01T08:00:00.000Z"),
doseValue: 10,
doseUnit: "mg",
note: null,
noteEncrypted: null,
createdAt: new Date("2026-06-01T08:01:00.000Z"),
},
],
},
]),
},
+1 -2
View File
@@ -250,6 +250,7 @@ export const TWO_ENDED_MODELS = [
"MedicationIntakeEvent",
"MedicationSideEffect",
"MedicationPauseEra",
"MedicationDoseChange",
"MoodEntry",
"MoodContext",
"MoodEntryTagLink",
@@ -343,8 +344,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly<Record<string, string>> = {
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
MedicationScheduleRevision:
"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.",
MedicationDoseChange:
"Titration history — when a dose went up or down and by how much. A restore rebuilds the current dose and drops the ramp that led to it.",
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:
+29
View File
@@ -76,6 +76,7 @@ export interface FullBackupCounts
intakeEvents: number;
medicationSideEffects: number;
medicationPauseEras: number;
medicationDoseChanges: number;
moodEntries: number;
cycles: number;
cycleDayLogs: number;
@@ -358,6 +359,7 @@ export async function buildFullBackupPayload(
schedules: true,
sideEffects: { orderBy: { occurredAt: "desc" } },
pauseEras: { orderBy: { pausedAt: "asc" } },
doseChanges: { orderBy: { effectiveFrom: "asc" } },
},
}),
prisma.medicationIntakeEvent.findMany({
@@ -577,6 +579,29 @@ export async function buildFullBackupPayload(
resumedAt: p.resumedAt ? p.resumedAt.toISOString() : null,
createdAt: p.createdAt.toISOString(),
})),
// Titration: when the dose moved, to what, and why. The drug comes
// back at today's dose either way; without these the ramp that led to
// it is gone, and that ramp is what a prescriber asks about.
//
// The note follows the side-effect contract beside it: a portable
// export carries the DECRYPTED note and no ciphertext, a
// disaster-recovery payload carries the ciphertext verbatim plus
// whatever legacy plaintext the row still holds.
doseChanges: m.doseChanges.map((d) => ({
...(disasterRecovery
? {
id: d.id,
note: d.note,
noteEncrypted: d.noteEncrypted
? Buffer.from(d.noteEncrypted).toString("base64")
: null,
}
: { note: readNote(d.noteEncrypted, d.note) }),
effectiveFrom: d.effectiveFrom.toISOString(),
doseValue: d.doseValue,
doseUnit: d.doseUnit,
createdAt: d.createdAt.toISOString(),
})),
})),
intakeEvents: intakeEvents.map((e) => ({
...(disasterRecovery
@@ -740,6 +765,10 @@ export async function buildFullBackupPayload(
(total, m) => total + m.pauseEras.length,
0,
),
medicationDoseChanges: medications.reduce(
(total, m) => total + m.doseChanges.length,
0,
),
moodEntries: moodEntries.length,
cycles: cycle.cycles.length,
cycleDayLogs: cycle.cycleDayLogs.length,
+21
View File
@@ -184,6 +184,24 @@ const medicationPauseEraSchema = z
})
.passthrough();
/**
* One step of a titration: when the dose moved and to what.
*
* The note follows the side-effect contract — decrypted prose in a portable
* file, ciphertext plus any legacy plaintext in a recovery one.
*/
const medicationDoseChangeSchema = z
.object({
id: z.string().min(1).optional(),
effectiveFrom: isoDateTime,
doseValue: z.number(),
doseUnit: z.string().min(1),
note: z.string().nullable().optional(),
noteEncrypted: base64BytesSchema.nullable().optional(),
createdAt: isoDateTime.optional(),
})
.passthrough();
const medicationSchema = z
.object({
id: z.string().min(1).optional(),
@@ -221,6 +239,9 @@ const medicationSchema = z
// Defaulted for the same reason: a file written before pause eras rode the
// wire still parses, and a drug that was never paused writes [].
pauseEras: z.array(medicationPauseEraSchema).default([]),
// Same default, same reason: an older file parses, a drug never titrated
// writes [].
doseChanges: z.array(medicationDoseChangeSchema).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 DOSE_CHANGE_NOTE = "titration note, encrypted at rest";
const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose";
beforeEach(async () => {
@@ -107,6 +108,9 @@ const COUNT_BACK: Record<
p.medicationSideEffect.count({ where: { userId } }),
MedicationPauseEra: (p, userId) =>
p.medicationPauseEra.count({ where: { userId } }),
// No own `userId` column — reached through the drug, like the schedules.
MedicationDoseChange: (p, userId) =>
p.medicationDoseChange.count({ where: { medication: { userId } } }),
MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }),
MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }),
MoodEntryTagLink: (p, userId) =>
@@ -230,6 +234,27 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
// drug is paused right now; a restore that closed it at restore time would
// still count one row back here and read as recovered. The field-level
// assertion at the end of the test is what holds the null.
// 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.
await prisma.medicationDoseChange.createMany({
data: [
{
medicationId: medication.id,
effectiveFrom: AT("2026-04-01T08:00:00.000Z"),
doseValue: 5,
doseUnit: "mg",
noteEncrypted: encryptToBytes(DOSE_CHANGE_NOTE),
},
{
medicationId: medication.id,
effectiveFrom: AT("2026-06-01T08:00:00.000Z"),
doseValue: 10,
doseUnit: "mg",
noteEncrypted: null,
},
],
});
await prisma.medicationPauseEra.createMany({
data: [
{
@@ -732,6 +757,39 @@ describe("every model the plan claims two-ended survives a real restore", () =>
{ pausedAt: "2026-07-15T08:00:00.000Z", resumedAt: null },
]);
// 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.
const doses = await prisma.medicationDoseChange.findMany({
where: { medication: { userId: OWNER_ID } },
orderBy: { effectiveFrom: "asc" },
});
expect(
doses.map((d) => ({
effectiveFrom: d.effectiveFrom.toISOString(),
doseValue: d.doseValue,
doseUnit: d.doseUnit,
note: readNote(d.noteEncrypted, d.note),
plaintextColumn: d.note,
})),
"the titration must come back as the same ramp, in order",
).toEqual([
{
effectiveFrom: "2026-04-01T08:00:00.000Z",
doseValue: 5,
doseUnit: "mg",
note: DOSE_CHANGE_NOTE,
plaintextColumn: null,
},
{
effectiveFrom: "2026-06-01T08:00:00.000Z",
doseValue: 10,
doseUnit: "mg",
note: null,
plaintextColumn: null,
},
]);
// The dose, read back column by column. The count above says a row
// returned; a restore that wrote one row with every optional column
// defaulted would satisfy it and hand back an Impfpass line with no