mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Carry medication dose changes through backup and restore
The titration history was classified as backed up and travelled with nothing. An account restored from a backup showed the current dose and no record of how it got there: the ramp, the reductions, the note the prescriber gave for each step. For a long-running drug that history is often the more clinically interesting half. Export writes the rows nested inside their drug, ordered by effectiveFrom, so a portable restore that mints fresh ids needs no remap. The note follows the contract the side effects already use: a portable export decrypts it for a human reader, a disaster-recovery export carries the ciphertext verbatim, and a restore never writes it into the legacy plaintext column. Order is the content here, not decoration. Two rows on the same drug in the same unit, 5 mg then 10 mg, describe a ramp; the same two rows returned in the other order describe a taper. Counting them back cannot tell those apart, so the round-trip test asserts the sequence and the values, not the count. MedicationDoseChange moves from the coverage-pending register into the two-ended list, which makes the round-trip registry incomplete until a row is seeded and counted. That is a compile error, not a review note.
This commit is contained in:
1 parent
aca5b2d30b
commit
5e0994aab9
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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user