Merge pull request #810 from MBombeck/feat/backup-mental-health-consent

Carry medication pause eras through backup and restore
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-21 22:19:29 +02:00
commit aca5b2d30b
6 files changed
+132 -2

No files matched your search

@@ -624,6 +624,26 @@ const handler = apiHandler(
: {}),
})),
},
// Nested for the same reason as the two above — the FK binds
// to the id this drug actually got, so a portable file that
// mints fresh ids needs no remap.
//
// `resumedAt` passes through as null when the era is open. It
// is not defaulted to the restore time: a pause that was still
// running when the backup was taken is still running after the
// restore, and closing it here would silently convert an
// ongoing decision into a historical one.
pauseEras: {
create: m.pauseEras.map((p) => ({
...(p.id ? { id: p.id } : {}),
userId: ownerId,
pausedAt: new Date(p.pausedAt),
resumedAt: p.resumedAt ? new Date(p.resumedAt) : null,
...(p.createdAt
? { createdAt: new Date(p.createdAt) }
: {}),
})),
},
},
});
restoredMedicationIds.add(created.id);
@@ -161,6 +161,25 @@ function makePrisma() {
createdAt: new Date("2026-07-17T21:05:00.000Z"),
},
],
// One closed era and one still running. The open one is why
// `resumedAt` is nullable rather than absent: a reader treats null
// as "paused right now" and runs the span to the present, so a
// payload that dropped the distinction would report a resumption
// that never happened.
pauseEras: [
{
id: "pause-closed",
pausedAt: new Date("2026-05-02T08:00:00.000Z"),
resumedAt: new Date("2026-05-20T08:00:00.000Z"),
createdAt: new Date("2026-05-02T08:01:00.000Z"),
},
{
id: "pause-open",
pausedAt: new Date("2026-07-15T08:00:00.000Z"),
resumedAt: null,
createdAt: new Date("2026-07-15T08:01:00.000Z"),
},
],
},
]),
},
+1 -2
View File
@@ -249,6 +249,7 @@ export const TWO_ENDED_MODELS = [
"MedicationSchedule",
"MedicationIntakeEvent",
"MedicationSideEffect",
"MedicationPauseEra",
"MoodEntry",
"MoodContext",
"MoodEntryTagLink",
@@ -344,8 +345,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.",
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.",
MedicationPauseEra:
"The spans where a medication was deliberately paused. Without them the compliance recomputation counts a deliberate pause as missed doses and the restored account looks non-adherent.",
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:
+25
View File
@@ -75,6 +75,7 @@ export interface FullBackupCounts
medications: number;
intakeEvents: number;
medicationSideEffects: number;
medicationPauseEras: number;
moodEntries: number;
cycles: number;
cycleDayLogs: number;
@@ -356,6 +357,7 @@ export async function buildFullBackupPayload(
include: {
schedules: true,
sideEffects: { orderBy: { occurredAt: "desc" } },
pauseEras: { orderBy: { pausedAt: "asc" } },
},
}),
prisma.medicationIntakeEvent.findMany({
@@ -556,6 +558,25 @@ export async function buildFullBackupPayload(
entry: s.entry,
severity: s.severity,
})),
// Nested for the same reason the two above are: a pause era is bound to
// its medication by id, and a nested create binds it to whatever id the
// parent actually got.
//
// These spans are what separates "stopped taking it" from "was told to
// stop taking it". Without them the compliance recomputation counts a
// deliberate pause as missed doses, so a restored account reads as
// non-adherent for a stretch during which it did exactly what it was
// told. Nothing else in the payload can reconstruct them: an era is a
// decision, not an observation, and no intake row records its absence.
//
// `resumedAt` stays nullable end to end — an open era means the
// medication is still paused, and readers run it to `now`.
pauseEras: m.pauseEras.map((p) => ({
...(disasterRecovery ? { id: p.id } : {}),
pausedAt: p.pausedAt.toISOString(),
resumedAt: p.resumedAt ? p.resumedAt.toISOString() : null,
createdAt: p.createdAt.toISOString(),
})),
})),
intakeEvents: intakeEvents.map((e) => ({
...(disasterRecovery
@@ -715,6 +736,10 @@ export async function buildFullBackupPayload(
(total, m) => total + m.sideEffects.length,
0,
),
medicationPauseEras: medications.reduce(
(total, m) => total + m.pauseEras.length,
0,
),
moodEntries: moodEntries.length,
cycles: cycle.cycles.length,
cycleDayLogs: cycle.cycleDayLogs.length,
+20
View File
@@ -167,6 +167,23 @@ const medicationSideEffectSchema = z
})
.passthrough();
/**
* A span during which the drug was deliberately not taken.
*
* `resumedAt` is nullable rather than optional, and the writer always emits
* it. An absent key and an explicit `null` would otherwise both have to mean
* "still paused", and a file that simply forgot the field would assert an open
* era that never existed. Readers run an open era to `now`.
*/
const medicationPauseEraSchema = z
.object({
id: z.string().min(1).optional(),
pausedAt: isoDateTime,
resumedAt: isoDateTime.nullable(),
createdAt: isoDateTime.optional(),
})
.passthrough();
const medicationSchema = z
.object({
id: z.string().min(1).optional(),
@@ -201,6 +218,9 @@ const medicationSchema = z
// Defaulted so a file written before side effects rode the wire still
// parses, and a drug with none writes [].
sideEffects: z.array(medicationSideEffectSchema).default([]),
// 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([]),
})
.passthrough();
@@ -105,6 +105,8 @@ const COUNT_BACK: Record<
p.medicationIntakeEvent.count({ where: { userId } }),
MedicationSideEffect: (p, userId) =>
p.medicationSideEffect.count({ where: { userId } }),
MedicationPauseEra: (p, userId) =>
p.medicationPauseEra.count({ where: { userId } }),
MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }),
MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }),
MoodEntryTagLink: (p, userId) =>
@@ -224,6 +226,26 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
notesEncrypted: encryptToBytes(SIDE_EFFECT_NOTE),
},
});
// Two eras, and the open one carries the weight. `resumedAt: null` means the
// 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.
await prisma.medicationPauseEra.createMany({
data: [
{
userId: OWNER_ID,
medicationId: medication.id,
pausedAt: AT("2026-05-02T08:00:00.000Z"),
resumedAt: AT("2026-05-20T08:00:00.000Z"),
},
{
userId: OWNER_ID,
medicationId: medication.id,
pausedAt: AT("2026-07-15T08:00:00.000Z"),
resumedAt: null,
},
],
});
// A RATED tag the account defined itself — the export carries only rated
// links, so a BINARY tag would leave `MoodEntryTagLink` empty.
@@ -685,6 +707,31 @@ describe("every model the plan claims two-ended survives a real restore", () =>
null,
);
// Both pause eras, and the open one specifically. The count above only
// says two rows returned; a restore that closed the running pause at
// restore time would satisfy it exactly, and the account would come back
// saying a medication it is still off had been resumed — which the
// compliance recomputation then reads as doses missed on purpose-taken
// days. `null` is the assertion that matters here.
const eras = await prisma.medicationPauseEra.findMany({
where: { userId: OWNER_ID },
orderBy: { pausedAt: "asc" },
select: { pausedAt: true, resumedAt: true },
});
expect(
eras.map((e) => ({
pausedAt: e.pausedAt.toISOString(),
resumedAt: e.resumedAt ? e.resumedAt.toISOString() : null,
})),
"an open pause era must come back open",
).toEqual([
{
pausedAt: "2026-05-02T08:00:00.000Z",
resumedAt: "2026-05-20T08:00:00.000Z",
},
{ pausedAt: "2026-07-15T08:00:00.000Z", resumedAt: 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