diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index cb50cf30d..ca833bf74 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -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 } : {}), diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 8c7f090c8..d37ce9db4 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -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", diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index efa0d596c..e7da56e77 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -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> = { "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> = { "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: diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 9169729c9..4e6ea46f8 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -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, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 7d768e730..8d76eb2e2 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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(); diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6f1ec739d..3ae0d3130 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -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 { // 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 },