mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #810 from MBombeck/feat/backup-mental-health-consent
Carry medication pause eras through backup and restore
This commit is contained in:
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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user