Carry per-drug reminder phase timings, and stop claiming the notification preferences could travel

Two entries sat side by side on the coverage register, described as "the
same shape of loss". They are not the same shape at all, and the
difference is worth writing down.

ReminderPhaseConfig hangs off a medication, one row per drug, nested
inside something that already travels. It needed nothing new and it
travels now. The round trip tunes all four thresholds away from their
defaults and puts one of them in PERCENT rather than MINUTES, because a
restore that quietly fell back to the schema defaults would hand back
four plausible numbers and change when the drug turns orange.

NotificationPreference cannot travel, and the register was wrong to
imply it was merely unbuilt. A preference addresses ONE notification
channel by a hard foreign key. The channel model is deliberately
excluded from every backup because it carries channel secrets, and the
restore deletes every channel it finds. A carried preference would point
at a row that cannot exist: the same constraint violation the custom
mood categories were causing, waiting for somebody to implement it in
good faith.

So it moves to the deliberate exclusions with a reason that names the
dependency instead of the symptom. Re-applying somebody's tuning to a
channel they add after a restore is a feature about channel types, not a
backup contract about rows, and it should be argued for on its own
terms rather than smuggled in as debt repayment.
This commit is contained in:
Marc-André Bombeck committed 2026-08-22 09:16:38 +02:00
1 parent e56b54cf24
commit 16b10c7855
6 files changed
+147 -5

No files matched your search

@@ -709,6 +709,42 @@ const handler = apiHandler(
: {}),
})),
},
// One row or none, so `create` on the optional side rather
// than a list. A file that says nothing leaves the drug
// untuned, which is what it was.
...(m.phaseConfig
? {
phaseConfig: {
create: {
...(m.phaseConfig.id ? { id: m.phaseConfig.id } : {}),
...(m.phaseConfig.greenValue !== undefined
? { greenValue: m.phaseConfig.greenValue }
: {}),
...(m.phaseConfig.greenMode
? { greenMode: m.phaseConfig.greenMode }
: {}),
...(m.phaseConfig.yellowValue !== undefined
? { yellowValue: m.phaseConfig.yellowValue }
: {}),
...(m.phaseConfig.yellowMode
? { yellowMode: m.phaseConfig.yellowMode }
: {}),
...(m.phaseConfig.orangeValue !== undefined
? { orangeValue: m.phaseConfig.orangeValue }
: {}),
...(m.phaseConfig.orangeMode
? { orangeMode: m.phaseConfig.orangeMode }
: {}),
...(m.phaseConfig.redValue !== undefined
? { redValue: m.phaseConfig.redValue }
: {}),
...(m.phaseConfig.redMode
? { redMode: m.phaseConfig.redMode }
: {}),
},
},
}
: {}),
inventoryEvents: {
create: m.inventoryEvents.map((e) => ({
...(e.id ? { id: e.id } : {}),
@@ -225,6 +225,17 @@ function makePrisma() {
updatedAt: new Date("2026-07-09T08:01:00.000Z"),
},
],
phaseConfig: {
id: "phase-canonical",
greenValue: 45,
greenMode: "MINUTES",
yellowValue: 20,
yellowMode: "PERCENT",
orangeValue: 5,
orangeMode: "MINUTES",
redValue: 180,
redMode: "MINUTES",
},
inventoryEvents: [
{
id: "stock-in",
+23 -5
View File
@@ -170,7 +170,10 @@ export const BACKED_UP_MODELS = [
"CoachReminder",
// ── Preferences and consent ───────────────────────────────────────────────
"NotificationPreference",
// `NotificationPreference` used to sit here. It moved to the deliberate
// exclusions: it addresses a notification channel by a hard foreign key, and
// the channel itself is excluded for carrying secrets, so there is nothing
// for a restored preference to attach to.
"ReminderPhaseConfig",
"ConsentReceipt",
] as const;
@@ -365,6 +368,13 @@ export const TWO_ENDED_MODELS = [
// almost always a seeded tag whose id differs on every instance.
"MoodTagCategory",
"MoodTagHidden",
// Per-drug reminder phase timings: one row per medication, nested inside it,
// needing nothing the drug does not already bring. The register filed it
// next to the notification preferences as "the same shape of loss". It is
// not the same shape at all — this one hangs off something that travels,
// which is exactly why it could be cleared in an afternoon and the other
// could not be cleared at all.
"ReminderPhaseConfig",
] as const;
/** One model claimed to travel both ways. */
@@ -425,10 +435,6 @@ export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
"Which documents were filed against which condition. Documents and conditions both restore; the filing between them does not, so a restored vault is unsorted.",
ExtractedFact:
"Facts read out of a document by the AI pass, with their provenance back to the page. Re-derivable only by re-running the extraction against a provider, at the operator's cost.",
NotificationPreference:
"Per-event channel and quiet-hours choices. A restore reverts to defaults, so an account that had deliberately silenced a category starts being notified again.",
ReminderPhaseConfig:
"Custom reminder phase timings. Same shape of loss as the preferences above.",
ConsentReceipt:
"The record of what the account agreed to and when. Arguably belongs with the instance rather than the account, and that question has to be settled before this one can be carried either way.",
};
@@ -504,6 +510,18 @@ export const NOT_IN_BACKUP_MODELS: Readonly<Record<string, string>> = {
"A Web Push endpoint issued by one browser for one origin. Another host cannot send to it, and the browser that owns it re-subscribes on its own.",
NotificationChannel:
"Carries channel secrets (Telegram chat binding, ntfy topic). Same reasoning as ApiToken.",
// Moved here from the coverage-pending register. The entry there read "a
// restore reverts to defaults, so an account that had deliberately silenced
// a category starts being notified again", which implies the tuning is
// recoverable and merely unbuilt. It is not. A preference addresses ONE
// channel by a hard foreign key, the restore deletes every channel, and the
// channel model is deliberately excluded right above this line. A carried
// preference would point at a row that cannot exist — the same constraint
// violation the custom mood categories were causing, waiting to happen.
// Re-applying somebody's tuning to a channel they add AFTER a restore is a
// feature about channel TYPES, not a backup contract about rows.
NotificationPreference:
"Per-event choices, each bound to one notification channel by a foreign key. The channel is excluded above and the restore deletes every one, so a carried preference would address a channel that will never exist.",
UserKnownDevice:
"A device-recognition ledger. Restoring it would pre-trust devices on a host that has never seen them.",
StepUpElevation:
+20
View File
@@ -91,6 +91,7 @@ export interface FullBackupCounts
medicationDoseChanges: number;
medicationInventoryItems: number;
medicationInventoryEvents: number;
reminderPhaseConfigs: number;
moodEntries: number;
customMoodTagCategories: number;
hiddenMoodTags: number;
@@ -382,6 +383,7 @@ export async function buildFullBackupPayload(
doseChanges: { orderBy: { effectiveFrom: "asc" } },
inventoryItems: { orderBy: { createdAt: "asc" } },
inventoryEvents: { orderBy: { occurredAt: "asc" } },
phaseConfig: true,
},
}),
prisma.medicationIntakeEvent.findMany({
@@ -691,6 +693,23 @@ export async function buildFullBackupPayload(
createdAt: i.createdAt.toISOString(),
updatedAt: i.updatedAt.toISOString(),
})),
// The four reminder phase thresholds this drug was tuned to. One row or
// none, so it rides as an object rather than a list — and null means the
// person never tuned it, which is a different statement from "the file
// forgot to say".
phaseConfig: m.phaseConfig
? {
...(disasterRecovery ? { id: m.phaseConfig.id } : {}),
greenValue: m.phaseConfig.greenValue,
greenMode: m.phaseConfig.greenMode,
yellowValue: m.phaseConfig.yellowValue,
yellowMode: m.phaseConfig.yellowMode,
orangeValue: m.phaseConfig.orangeValue,
orangeMode: m.phaseConfig.orangeMode,
redValue: m.phaseConfig.redValue,
redMode: m.phaseConfig.redMode,
}
: null,
inventoryEvents: m.inventoryEvents.map((e) => ({
...(disasterRecovery ? { id: e.id } : {}),
delta: e.delta,
@@ -888,6 +907,7 @@ export async function buildFullBackupPayload(
(total, m) => total + m.inventoryEvents.length,
0,
),
reminderPhaseConfigs: medications.filter((m) => m.phaseConfig).length,
moodEntries: moodEntries.length,
customMoodTagCategories: customMoodTagCategories.length,
hiddenMoodTags: hiddenMoodTags.length,
+22
View File
@@ -50,6 +50,7 @@ import {
MeasurementSource,
MeasurementType,
MedicationCategory,
PhaseMode,
MedicationContainerType,
MedicationInventoryState,
MedicationDeliveryForm,
@@ -238,6 +239,26 @@ const medicationInventoryEventSchema = z
})
.passthrough();
/**
* The four reminder thresholds a drug was tuned to. Every field optional, so a
* file written before a threshold existed restores the schema default rather
* than refusing — but the OBJECT is nullable rather than absent, because "no
* tuning" and "this file does not say" are different claims.
*/
const reminderPhaseConfigSchema = z
.object({
id: z.string().min(1).optional(),
greenValue: z.number().int().optional(),
greenMode: z.enum(PhaseMode).optional(),
yellowValue: z.number().int().optional(),
yellowMode: z.enum(PhaseMode).optional(),
orangeValue: z.number().int().optional(),
orangeMode: z.enum(PhaseMode).optional(),
redValue: z.number().int().optional(),
redMode: z.enum(PhaseMode).optional(),
})
.passthrough();
const medicationSchema = z
.object({
id: z.string().min(1).optional(),
@@ -280,6 +301,7 @@ const medicationSchema = z
doseChanges: z.array(medicationDoseChangeSchema).default([]),
inventoryItems: z.array(medicationInventoryItemSchema).default([]),
inventoryEvents: z.array(medicationInventoryEventSchema).default([]),
phaseConfig: reminderPhaseConfigSchema.nullable().optional(),
})
.passthrough();
@@ -191,6 +191,8 @@ const COUNT_BACK: Record<
MoodTagCategory: (p, userId) =>
p.moodTagCategory.count({ where: { userId } }),
MoodTagHidden: (p, userId) => p.moodTagHidden.count({ where: { userId } }),
ReminderPhaseConfig: (p, userId) =>
p.reminderPhaseConfig.count({ where: { medication: { userId } } }),
};
/**
@@ -271,6 +273,23 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
// 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.
// Tuned away from every default, and one threshold in PERCENT rather than
// MINUTES: a restore that fell back to the schema defaults would return four
// plausible numbers and silently change when this drug turns orange.
await prisma.reminderPhaseConfig.create({
data: {
medicationId: medication.id,
greenValue: 45,
greenMode: "MINUTES",
yellowValue: 20,
yellowMode: "PERCENT",
orangeValue: 5,
orangeMode: "MINUTES",
redValue: 180,
redMode: "MINUTES",
},
});
const openPack = await prisma.medicationInventoryItem.create({
data: {
userId: OWNER_ID,
@@ -1123,6 +1142,22 @@ describe("every model the plan claims two-ended survives a real restore", () =>
expect(hidden).toHaveLength(1);
expect(hidden[0].moodTag.userId).toBeNull();
// Every threshold as tuned, including the one that is not in minutes.
const phase = await prisma.reminderPhaseConfig.findFirstOrThrow({
where: { medication: { userId: OWNER_ID } },
});
expect({
green: [phase.greenValue, phase.greenMode],
yellow: [phase.yellowValue, phase.yellowMode],
orange: [phase.orangeValue, phase.orangeMode],
red: [phase.redValue, phase.redMode],
}).toEqual({
green: [45, "MINUTES"],
yellow: [20, "PERCENT"],
orange: [5, "MINUTES"],
red: [180, "MINUTES"],
});
// The shelf, and the count that must not be recomputed.
const packs = await prisma.medicationInventoryItem.findMany({
where: { userId: OWNER_ID },