diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index 00811393c..fca11a857 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -462,6 +462,20 @@ describe("every backed-up model travels both ways, or is named as debt", () => { ).toEqual([]); }); + it("proves the reminder engine models are on the covered side", () => { + // v1.37.20 (#223 / iOS #68) — the reminder came off the debt register and + // the completion ledger landed carried from the release that introduced + // it. Pinned by name so a future edit cannot quietly move either back to + // COVERAGE_PENDING: the ledger is the only copy of every satisfy and skip + // before the latest one, so "pending" would be a silent history loss, not + // a deferral. + for (const model of ["MeasurementReminder", "MeasurementReminderEvent"]) { + expect(BACKED_UP_MODELS).toContain(model); + expect(TWO_ENDED_MODELS).toContain(model); + expect(COVERAGE_PENDING).not.toHaveProperty(model); + } + }); + it("proves the structured-profile models are on the covered side", () => { // These account-owned rows are exported and restored explicitly rather // than being treated as reconstructible data. diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 4d42c8519..9d6921831 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -292,6 +292,10 @@ function sourceClient() { encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the + // same reason as the visit tables above. + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 13976d771..20506c963 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -50,6 +50,7 @@ import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; import { restoreVisitsData } from "@/lib/export/visits-backup"; import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup"; +import { restoreRemindersData } from "@/lib/export/reminders-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -95,6 +96,8 @@ interface RestoreResponse { encounterLinks: number; vaccinations: number; vaccinationLinks: number; + measurementReminders: number; + measurementReminderEvents: number; }; } @@ -1329,12 +1332,28 @@ const handler = apiHandler( }); } - // Visits, the address book and the three link tables. Last on - // purpose: a link is written only when both of its ends exist, and - // the documents above are the final far side to land. Both ends of - // this section live in `src/lib/export/visits-backup.ts`, so a grep - // of THIS file alone will not find them — the same delegation as the - // cycle, profile, intraday and score restores above. + // The Vorsorge reminders and their completion ledger (v1.37.20, + // #223 / iOS #68), BEFORE the visits and the vaccinations: both of + // those remap a `reminderId` against the reminders in the database, + // so the reminders have to be in the database first or every + // appointment and booster reference would read as unresolvable and + // drop. Both ends of this section live in + // `src/lib/export/reminders-backup.ts`. + const remindersCleared = await restoreRemindersData( + tx, + ownerId, + payload, + skips, + ); + + // Visits, the address book and the three link tables. After the + // documents and the reminders on purpose: a link is written only + // when both of its ends exist, and the encounter's reminder + // reference remaps against the rows the branch above has just + // written. Both ends of this section live in + // `src/lib/export/visits-backup.ts`, so a grep of THIS file alone + // will not find them — the same delegation as the cycle, profile, + // intraday and score restores above. const visitsCleared = await restoreVisitsData( tx, ownerId, @@ -1343,10 +1362,10 @@ const handler = apiHandler( ); // The immunization log, after the visits: a dose remaps its - // practitioner and its encounter against rows the branch above has - // just written, and its document link needs the documents that - // landed before that. Both ends of this section live in - // `src/lib/export/vaccinations-backup.ts`. + // practitioner, its encounter and its booster reminder against rows + // the branches above have just written, and its document link needs + // the documents that landed before that. Both ends of this section + // live in `src/lib/export/vaccinations-backup.ts`. const vaccinationsCleared = await restoreVaccinationsData( tx, ownerId, @@ -1385,6 +1404,9 @@ const handler = apiHandler( encounterLinks: visitsCleared.encounterLinks, vaccinations: vaccinationsCleared.vaccinations, vaccinationLinks: vaccinationsCleared.vaccinationLinks, + measurementReminders: remindersCleared.measurementReminders, + measurementReminderEvents: + remindersCleared.measurementReminderEvents, }; return { cleared, skipped: summarizeRestoreSkips(skips) }; }, @@ -1537,6 +1559,8 @@ const handler = apiHandler( encounterLinks: summary.encounterLinks, vaccinations: summary.vaccinations, vaccinationLinks: summary.vaccinationLinks, + measurementReminders: summary.measurementReminders, + measurementReminderEvents: summary.measurementReminderEvents, }, }, }); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 2237b5c1f..dbd70b192 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -38,6 +38,10 @@ vi.mock("@/lib/db", () => ({ encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the + // same reason as the visit tables above. + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -121,6 +125,10 @@ beforeEach(() => { vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue( [] as never, ); + vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurementReminderEvent.findMany).mockResolvedValue( + [] as never, + ); }); afterEach(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 47fc52e16..e124d475f 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -36,6 +36,10 @@ vi.mock("@/lib/db", () => ({ encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the + // same reason as the visit tables above. + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 522296d04..311b68487 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -60,6 +60,10 @@ export const WIPE_MODELS = [ // the account was doing, day by day, after a confirmation that said // otherwise. "HealthScoreRecord", + // The completion ledger before the reminder it hangs off: it cascades either + // way, but the child deleted first is what keeps its own count truthful on + // the audit row (v1.37.20, #223 / iOS #68). + "MeasurementReminderEvent", "MeasurementReminder", // ── Medication ────────────────────────────────────────────────────────── diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 1bfe3d447..0992c9f81 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -259,6 +259,10 @@ function makePrisma() { encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the + // same reason as the visit tables above. + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index d3f42b992..2302b88f4 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -99,6 +99,12 @@ export const BACKED_UP_MODELS = [ "UserHealthProfile", "HealthProfileFactRevision", "MeasurementReminder", + // The Vorsorge completion ledger (v1.37.20, #223 / iOS #68). Looks derived + // and is the opposite: the reminder engine is single-cursor, so each satisfy + // overwrites the previous one on the reminder row and this table is the only + // copy of everything before the latest. Nothing can rebuild it, which is the + // exact test for BACKED_UP. + "MeasurementReminderEvent", // ── Cycle ───────────────────────────────────────────────────────────────── "CycleProfile", @@ -202,6 +208,9 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // reason: `documentLinks` is a field name on three models now, so each of // these reads through its OWN delegate here. "src/lib/export/vaccinations-backup.ts", + // The Vorsorge reminders and their completion ledger, both ends beside each + // other like the visits and vaccinations above. + "src/lib/export/reminders-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -212,6 +221,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/health-score-backup.ts", "src/lib/export/visits-backup.ts", "src/lib/export/vaccinations-backup.ts", + "src/lib/export/reminders-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -264,12 +274,11 @@ export const TWO_ENDED_MODELS = [ "Workout", "NutrientIntakeDay", "InboundDocument", - // Visits travel both ways from the release that introduces them. Two - // COVERAGE_PENDING neighbours already say what the alternative costs: - // reminders restore silent, and the filing between documents and conditions - // is lost. Leaving the visit links pending would return visits and documents - // with nothing between them — the same loss, one layer deeper — so the links - // land carried rather than owed. + // Visits travel both ways from the release that introduces them. A + // COVERAGE_PENDING neighbour already says what the alternative costs — the + // filing between documents and conditions is lost. Leaving the visit links + // pending would return visits and documents with nothing between them — the + // same loss, one layer deeper — so the links land carried rather than owed. "Practitioner", "Encounter", "EncounterDocumentLink", @@ -284,6 +293,17 @@ export const TWO_ENDED_MODELS = [ // else. "VaccinationRecord", "VaccinationDocumentLink", + // The Vorsorge reminders, off the debt register at last (v1.37.20, #223 / + // iOS #68), and the completion ledger carried with them from the release + // that introduces it. The pairing is deliberate: the ledger arriving is + // what turned "a restore leaves the account silent" from an inconvenience + // into a history loss, because the single-cursor engine keeps no other copy + // of past satisfies and skips. Restoring the reminder without its ledger + // would hand back a cadence with no evidence it was ever followed; the two + // land together. The encounter and vaccination `reminderId` references stop + // dropping to NULL in the same release — the rows they name come back now. + "MeasurementReminder", + "MeasurementReminderEvent", ] as const; /** One model claimed to travel both ways. */ @@ -340,8 +360,6 @@ export const COVERAGE_PENDING: Readonly> = { "Completed WHO-5, PHQ and GAD instruments with their scores and dates. Clinically meaningful history that no integration can re-sync, and the single largest gap on this list.", EcgRecording: "ECG traces and their rhythm classification. The originating device may still hold them, but a self-hoster who exported and wiped has nothing to re-sync from.", - MeasurementReminder: - "Per-metric reminder cadences the person configured. A restore leaves the account silent until each one is set up again.", WorkoutRoute: "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", WorkoutSamples: diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 22b31a229..af51b9d1d 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -49,6 +49,12 @@ import { type VaccinationsBackupCounts, type VaccinationsBackupSection, } from "@/lib/export/vaccinations-backup"; +import { + buildRemindersBackupSection, + countRemindersBackupSection, + type RemindersBackupCounts, + type RemindersBackupSection, +} from "@/lib/export/reminders-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -63,7 +69,8 @@ export interface FullBackupCounts IntradayProfileBackupCounts, HealthScoreBackupCounts, VisitsBackupCounts, - VaccinationsBackupCounts { + VaccinationsBackupCounts, + RemindersBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -277,6 +284,7 @@ export async function buildFullBackupPayload( healthScoreRecords, visits, vaccinations, + reminders, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -416,6 +424,13 @@ export async function buildFullBackupPayload( buildVaccinationsBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The Vorsorge reminders and their completion ledger (v1.37.20, #223 / + // iOS #68). Both ends live in `src/lib/export/reminders-backup.ts` beside + // each other, the same arrangement as the two sections above and for the + // same reason. + buildRemindersBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -444,6 +459,7 @@ export async function buildFullBackupPayload( const healthScoreSection: HealthScoreBackupSection = healthScoreRecords; const visitsSection: VisitsBackupSection = visits; const vaccinationsSection: VaccinationsBackupSection = vaccinations; + const remindersSection: RemindersBackupSection = reminders; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -679,6 +695,7 @@ export async function buildFullBackupPayload( ...healthScoreSection, ...visitsSection, ...vaccinationsSection, + ...remindersSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -708,6 +725,7 @@ export async function buildFullBackupPayload( ...countHealthScoreBackupSection(healthScoreRecords), ...countVisitsBackupSection(visits), ...countVaccinationsBackupSection(vaccinations), + ...countRemindersBackupSection(reminders), }, }; } diff --git a/src/lib/export/reminders-backup.ts b/src/lib/export/reminders-backup.ts new file mode 100644 index 000000000..5ad1a8b15 --- /dev/null +++ b/src/lib/export/reminders-backup.ts @@ -0,0 +1,394 @@ +/** + * The Vorsorge reminders and their completion ledger, with both backup ends + * in one file. + * + * Same arrangement as `visits-backup.ts` and `vaccinations-backup.ts`, for + * the same reason: a reader asking "is this carried at both ends?" answers it + * here, and a reader who greps only the restore ROUTE gets a false negative + * because the route delegates. + * + * Two models ride: the reminder and the ledger row behind it + * (v1.37.20, #223 / iOS #68). Both are carried rather than owed. The + * reminder spent its time on the coverage-pending register — "a restore + * leaves the account silent until each one is set up again" — and the ledger + * raised the cost of that debt the release it landed: the engine is + * single-cursor, so the ledger is the ONLY copy of every satisfy and skip + * before the latest one. A restore without this section hands back a silent + * account with no history of it ever having been diligent. + * + * Each model uses its own named select constant and its own delegate call. + * `events` is a relation field name here and nowhere else today, but the + * delegate-per-model shape is kept anyway — `documentLinks` taught this + * directory what a shared relation name does to a structural matcher. + * + * One reference needs care on the way back: `MeasurementReminderEvent.reminderId` + * addresses a reminder in this same section. The builder filters the ledger to + * the reminders it actually carries — a portable export omits tombstoned + * reminders, and a ledger row whose reminder is not in the file could never + * restore — so a file this release writes always resolves completely. A + * hand-edited or truncated file that does not is reported, never guessed at. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import type { + MeasurementReminderEventKind, + MeasurementType, + ReminderOrigin, +} from "@/generated/prisma/client"; + +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +export interface RemindersBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** One configured Vorsorge cadence, exactly as the engine holds it. */ +export interface MeasurementReminderBackupEntry { + /** Always carried: the ledger rows, an encounter and a vaccination record + * all address a reminder by it. */ + id: string; + label: string; + measurementType: MeasurementType | null; + intervalDays: number | null; + rrule: string | null; + anchorDate: string | null; + endsOn: string | null; + origin: ReminderOrigin; + notifyHour: number; + location: string | null; + /** Server-computed and carried as computed — the restore writes it back + * verbatim rather than recomputing, so the account resumes exactly the + * due-state it was exported with. */ + nextDueAt: string | null; + lastSatisfiedAt: string | null; + enabled: boolean; + vaccinationAntigen: string | null; + /** v1.37.20 — the snooze cursor. Verbatim: a snooze that has since expired + * restores as an instant in the past, which every consumer already treats + * as "not snoozed". */ + snoozedUntil: string | null; + /** v1.37.20 — last honest skip. Never folded into `lastSatisfiedAt`; done + * and skipped stay distinguishable through a restore too. */ + lastSkippedAt: string | null; + /** v1.37.20 — lifetime skip counter. */ + skipCount: number; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; +} + +/** One completion-ledger row: an honest satisfy or an honest skip. */ +export interface MeasurementReminderEventBackupEntry { + id: string; + reminderId: string; + kind: MeasurementReminderEventKind; + occurredAt: string; + /** Derived at write time against the pre-event `nextDueAt`, which is gone. + * Carried verbatim because nothing can ever re-derive it. */ + onTime: boolean; + source: string; + createdAt: string; +} + +export interface RemindersBackupSection { + measurementReminders: MeasurementReminderBackupEntry[]; + measurementReminderEvents: MeasurementReminderEventBackupEntry[]; +} + +export interface RemindersBackupCounts { + measurementReminders: number; + measurementReminderEvents: number; +} + +/** + * Named select constants, one per model — a structural matcher binds a model + * to the literal beside its delegate call, matching the other section files. + */ +const MEASUREMENT_REMINDER_BACKUP_SELECT = { + id: true, + label: true, + measurementType: true, + intervalDays: true, + rrule: true, + anchorDate: true, + endsOn: true, + origin: true, + notifyHour: true, + location: true, + nextDueAt: true, + lastSatisfiedAt: true, + enabled: true, + vaccinationAntigen: true, + snoozedUntil: true, + lastSkippedAt: true, + skipCount: true, + createdAt: true, + updatedAt: true, + deletedAt: true, +} as const; + +const MEASUREMENT_REMINDER_EVENT_BACKUP_SELECT = { + id: true, + reminderId: true, + kind: true, + occurredAt: true, + onTime: true, + source: true, + createdAt: true, +} as const; + +/** + * Build the reminders slice of a user's full backup. + * + * Takes the delegates it uses rather than a whole client, matching the other + * section builders. A portable export omits tombstoned reminders so a restore + * cannot resurrect them — and it omits the ledger rows of an omitted reminder + * with it, because a ledger row whose reminder is not in the file could only + * ever restore as a reported drop. A disaster-recovery payload keeps both so + * the account comes back as itself. + */ +export async function buildRemindersBackupSection( + prisma: Pick< + PrismaClient, + "measurementReminder" | "measurementReminderEvent" + >, + userId: string, + options: RemindersBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + const [reminderRows, eventRows] = await Promise.all([ + prisma.measurementReminder.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + orderBy: { createdAt: "asc" }, + select: MEASUREMENT_REMINDER_BACKUP_SELECT, + }), + prisma.measurementReminderEvent.findMany({ + where: { userId }, + orderBy: { occurredAt: "asc" }, + select: MEASUREMENT_REMINDER_EVENT_BACKUP_SELECT, + }), + ]); + + // The ledger is filtered to the reminders the file actually carries. On a + // DR payload every reminder is carried and this is a no-op; on a portable + // export it removes the ledger of each omitted tombstone, so the file never + // states an event against a reminder it refuses to restore. + const carriedReminderIds = new Set(reminderRows.map((row) => row.id)); + + return { + measurementReminders: reminderRows.map((row) => ({ + id: row.id, + label: row.label, + measurementType: row.measurementType, + intervalDays: row.intervalDays, + rrule: row.rrule, + anchorDate: row.anchorDate?.toISOString() ?? null, + endsOn: row.endsOn?.toISOString() ?? null, + origin: row.origin, + notifyHour: row.notifyHour, + location: row.location, + nextDueAt: row.nextDueAt?.toISOString() ?? null, + lastSatisfiedAt: row.lastSatisfiedAt?.toISOString() ?? null, + enabled: row.enabled, + vaccinationAntigen: row.vaccinationAntigen, + snoozedUntil: row.snoozedUntil?.toISOString() ?? null, + lastSkippedAt: row.lastSkippedAt?.toISOString() ?? null, + skipCount: row.skipCount, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(disasterRecovery + ? { deletedAt: row.deletedAt?.toISOString() ?? null } + : {}), + })), + measurementReminderEvents: eventRows + .filter((row) => carriedReminderIds.has(row.reminderId)) + .map((row) => ({ + id: row.id, + reminderId: row.reminderId, + kind: row.kind, + occurredAt: row.occurredAt.toISOString(), + onTime: row.onTime, + source: row.source, + createdAt: row.createdAt.toISOString(), + })), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countRemindersBackupSection( + section: RemindersBackupSection, +): RemindersBackupCounts { + return { + measurementReminders: section.measurementReminders.length, + measurementReminderEvents: section.measurementReminderEvents.length, + }; +} + +/** Counts the reminder restore wiped, for the audit trail. */ +export interface RemindersRestoreCleared { + measurementReminders: number; + measurementReminderEvents: number; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: the payload schema defaults every key, so + * every caller already satisfies this, and one that stops satisfying it fails + * to compile instead of passing `undefined` into a loop that iterates zero + * times and reports success. + */ +export interface RemindersRestoreInput { + measurementReminders: RestoredMeasurementReminder[]; + measurementReminderEvents: RestoredMeasurementReminderEvent[]; +} + +/** + * What the restore reads, as the parsed file actually presents it — a wider + * set than what this release writes: a portable export omits the tombstone, + * and every optional column arrives as `undefined` rather than `null`. + */ +type OptionalNullable = { [K in keyof T]?: T[K] | undefined }; + +export type RestoredMeasurementReminder = Pick< + MeasurementReminderBackupEntry, + "id" | "label" | "createdAt" | "updatedAt" +> & + OptionalNullable< + Pick< + MeasurementReminderBackupEntry, + | "measurementType" + | "intervalDays" + | "rrule" + | "anchorDate" + | "endsOn" + | "origin" + | "notifyHour" + | "location" + | "nextDueAt" + | "lastSatisfiedAt" + | "enabled" + | "vaccinationAntigen" + | "snoozedUntil" + | "lastSkippedAt" + | "skipCount" + > + > & { deletedAt?: string | null }; + +export type RestoredMeasurementReminderEvent = Pick< + MeasurementReminderEventBackupEntry, + "id" | "reminderId" | "occurredAt" | "onTime" | "source" | "createdAt" +> & { + kind: MeasurementReminderEventKind; +}; + +/** + * Re-create the account's Vorsorge reminders and their completion ledger. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. The ledger is deleted before the reminders so its cleared count is + * truthful (the cascade would empty it anyway, silently), and recreated after + * them, because a ledger row addresses its reminder by id. + * + * MUST be called BEFORE the encounters and the vaccination records are + * restored: both carry a `reminderId` that they remap against the reminders + * in the database, and calling this later would make every one of those + * references read as unresolvable and be dropped — a restore counted as + * successful while it quietly disconnected every booster and appointment. + */ +export async function restoreRemindersData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: RemindersRestoreInput, + skips: RestoreSkipLog, +): Promise { + const clearedEvents = await tx.measurementReminderEvent.deleteMany({ + where: { userId: ownerId }, + }); + const clearedReminders = await tx.measurementReminder.deleteMany({ + where: { userId: ownerId }, + }); + + if (payload.measurementReminders.length > 0) { + await tx.measurementReminder.createMany({ + data: payload.measurementReminders.map((entry) => ({ + id: entry.id, + userId: ownerId, + label: entry.label, + measurementType: entry.measurementType ?? null, + intervalDays: entry.intervalDays ?? null, + rrule: entry.rrule ?? null, + anchorDate: entry.anchorDate ? new Date(entry.anchorDate) : null, + endsOn: entry.endsOn ? new Date(entry.endsOn) : null, + // A file written before a column existed says nothing about it, and + // the schema default is what those rows were living as. + origin: entry.origin ?? "VORSORGE", + notifyHour: entry.notifyHour ?? 9, + location: entry.location ?? null, + // Verbatim, not recomputed: `nextDueAt` is server-authoritative and + // the exported value IS what the server had resolved. Recomputing + // here would move a due date the person had already been shown. + nextDueAt: entry.nextDueAt ? new Date(entry.nextDueAt) : null, + lastSatisfiedAt: entry.lastSatisfiedAt + ? new Date(entry.lastSatisfiedAt) + : null, + enabled: entry.enabled ?? true, + vaccinationAntigen: entry.vaccinationAntigen ?? null, + snoozedUntil: entry.snoozedUntil ? new Date(entry.snoozedUntil) : null, + lastSkippedAt: entry.lastSkippedAt + ? new Date(entry.lastSkippedAt) + : null, + skipCount: entry.skipCount ?? 0, + createdAt: new Date(entry.createdAt), + updatedAt: new Date(entry.updatedAt), + deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + })), + }); + } + + // The builder filters the ledger to carried reminders, so a file this + // release writes never drops here. A file that does reference a reminder it + // does not carry cannot have that row invented, and a dangling id would + // fail the foreign key and roll the whole restore back over one ledger + // line. The row is dropped and reported instead. + const restoredReminders = new Set( + payload.measurementReminders.map((entry) => entry.id), + ); + const writableEvents: RestoredMeasurementReminderEvent[] = []; + const droppedReminderRefs: string[] = []; + for (const entry of payload.measurementReminderEvents) { + if (restoredReminders.has(entry.reminderId)) { + writableEvents.push(entry); + } else { + droppedReminderRefs.push(entry.reminderId); + } + } + if (writableEvents.length > 0) { + await tx.measurementReminderEvent.createMany({ + data: writableEvents.map((entry) => ({ + id: entry.id, + userId: ownerId, + reminderId: entry.reminderId, + kind: entry.kind, + occurredAt: new Date(entry.occurredAt), + onTime: entry.onTime, + source: entry.source, + createdAt: new Date(entry.createdAt), + })), + }); + } + recordUnknownKeys( + skips, + "reminderReference", + [...new Set(droppedReminderRefs)], + droppedReminderRefs, + ); + + return { + measurementReminders: clearedReminders.count, + measurementReminderEvents: clearedEvents.count, + }; +} diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 24fa35348..4336bf999 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -36,18 +36,26 @@ * mechanism because the cost and the honest response are identical: an edge is * lost, the record it hangs off survives, and the operator is told which one. * What it names is a row the file pointed at that the restore did not put - * back — a reminder, which is on the coverage-pending register and never - * travels, or a document, lab result or condition episode whose id a portable - * export does not carry. + * back — a reminder the file does not carry (a portable export omits + * tombstoned ones since v1.37.20; before that no reminder travelled at all), + * or a document, lab result or condition episode whose id a portable export + * does not carry. * * The sixth, `vaccinationReference`, is the same kind as `visitReference` and * is kept separate rather than folded into it so the report says which part of * the record lost an edge. It names a row an immunization entry pointed at - * that the restore did not put back — a booster reminder, which never travels, - * a practitioner or visit the file referenced without carrying, or a scanned - * page whose id a portable export does not include. + * that the restore did not put back — a booster reminder the file does not + * carry, a practitioner or visit the file referenced without carrying, or a + * scanned page whose id a portable export does not include. * - * The seventh, `checkupClosure`, is not about a restore at all, and it borrows + * The seventh, `reminderReference`, is the same kind as the two above, for + * the completion ledger (v1.37.20, #223 / iOS #68): a ledger row whose + * reminder the file does not carry. The builder filters the ledger to carried + * reminders at write time, so a file this release writes never trips it — + * it exists for the hand-edited or truncated file, where inventing the + * reminder and silently dropping the row are equally wrong. + * + * The eighth, `checkupClosure`, is not about a restore at all, and it borrows * this shape deliberately rather than growing a second reporting mechanism * beside it. The situation is the same one: something a write was asked to do * could not be done, the record itself survives, and the person is told which @@ -62,6 +70,7 @@ export type SkippedCatalogue = | "moodTag" | "visitReference" | "vaccinationReference" + | "reminderReference" | "checkupClosure"; /** One key this instance does not know, and the links it cost. */ diff --git a/src/lib/export/vaccinations-backup.ts b/src/lib/export/vaccinations-backup.ts index e4515747e..f88bcc151 100644 --- a/src/lib/export/vaccinations-backup.ts +++ b/src/lib/export/vaccinations-backup.ts @@ -23,9 +23,12 @@ * - `practitionerId` and `encounterId` point at models that restore in the * same run. They remap to the restored rows, and drop to NULL when the * file references a row it does not carry. - * - `reminderId` points at a `MeasurementReminder`, which is on the - * coverage-pending register and does not come back. It resolves to NULL, - * never to a dangling id, and the drop is reported. + * - `reminderId` points at a `MeasurementReminder`, which also restores in + * the same run (v1.37.20, #223 / iOS #68 — it was on the coverage-pending + * register before that and every reference was a known drop). It remaps + * the same way, and resolves to NULL, with the drop reported, only when + * the file genuinely lacks the reminder — a portable export omits + * tombstoned ones. * - `antigenSlug` is restored verbatim with no validation against the * current catalogue. A slug this release no longer resolves must still * come back; the renderer degrades to the free-text arm, which is the @@ -63,9 +66,10 @@ export interface VaccinationBackupEntry { practitionerId: string | null; encounterId: string | null; /** - * Carried so the loss is visible in the file rather than inferred from its - * absence. The restore resolves it to NULL and names the drop: the reminder - * it points at is not in the backup. + * The booster reminder this dose recorded satisfying, which restores in the + * same run since v1.37.20. The restore remaps it against the restored + * reminders and drops it to NULL, with the drop named, only when the file + * lacks the reminder. */ reminderId: string | null; /** Base64 ciphertext, carried verbatim — never decrypted into the file. */ @@ -253,11 +257,11 @@ function decodeCiphertext(encoded: string): Uint8Array { * section. The dose first, then the link, because a link addresses both of its * ends by id. * - * MUST be called after the practitioners, encounters and documents have been - * restored: the dose remaps two of those references against the database and - * the link is written only when the document actually came back. Calling it - * earlier would drop every reference and be counted as a successful restore of - * a thinner record. + * MUST be called after the practitioners, encounters, documents and + * measurement reminders have been restored: the dose remaps three of those + * references against the database and the link is written only when the + * document actually came back. Calling it earlier would drop every reference + * and be counted as a successful restore of a thinner record. */ export async function restoreVaccinationsData( tx: Prisma.TransactionClient, @@ -272,10 +276,10 @@ export async function restoreVaccinationsData( where: { userId: ownerId }, }); - // Both far sides are restored by other branches of the same transaction, so - // the check is against the database rather than against the payload — this - // is the only place that can see all three sections at once. - const [practitionerIds, encounterIds] = await Promise.all([ + // All three far sides are restored by other branches of the same + // transaction, so the check is against the database rather than against the + // payload — this is the only place that can see every section at once. + const [practitionerIds, encounterIds, reminderIds] = await Promise.all([ tx.practitioner.findMany({ where: { userId: ownerId }, select: { id: true }, @@ -284,18 +288,24 @@ export async function restoreVaccinationsData( where: { userId: ownerId }, select: { id: true }, }), + tx.measurementReminder.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), ]); const restoredPractitioners = new Set(practitionerIds.map((row) => row.id)); const restoredEncounters = new Set(encounterIds.map((row) => row.id)); + const restoredReminders = new Set(reminderIds.map((row) => row.id)); // A row the file references but the restore did not bring back cannot be // invented, and a dangling id would fail the foreign key and roll the whole // restore back over one dose. The reference drops to NULL instead — the - // dose, its date, its antigen and its batch code all survive. + // dose, its date, its antigen and its batch code all survive. That includes + // the reminder since v1.37.20: it restores in the same run, so a drop here + // now means the file genuinely lacked it (a portable export omits + // tombstoned reminders), not that reminders never travel. const droppedPractitioners: string[] = []; const droppedEncounters: string[] = []; - // The reminder is on the coverage-pending register: it is not in the file at - // all, so every reference to one is a known, reportable drop. const droppedReminders: string[] = []; if (payload.vaccinations.length > 0) { @@ -316,7 +326,13 @@ export async function restoreVaccinationsData( if (entry.encounterId && encounterId === null) { droppedEncounters.push(entry.encounterId); } - if (entry.reminderId) droppedReminders.push(entry.reminderId); + const reminderId = + entry.reminderId && restoredReminders.has(entry.reminderId) + ? entry.reminderId + : null; + if (entry.reminderId && reminderId === null) { + droppedReminders.push(entry.reminderId); + } return { id: entry.id, userId: ownerId, @@ -332,8 +348,7 @@ export async function restoreVaccinationsData( site: entry.site ?? null, practitionerId, encounterId, - // Never the id the file carried: the row it named is not restored. - reminderId: null, + reminderId, noteEncrypted: entry.noteEncrypted == null ? null diff --git a/src/lib/export/visits-backup.ts b/src/lib/export/visits-backup.ts index 4b5eae712..b34dde4ba 100644 --- a/src/lib/export/visits-backup.ts +++ b/src/lib/export/visits-backup.ts @@ -8,11 +8,11 @@ * * Five models ride: the practitioners, the encounters, and the three link * tables between an encounter and a document, a lab result or a condition - * episode. All five are carried rather than owed. Two neighbours on the debt - * register already say what the alternative costs — reminders restore silent, - * and the filing between documents and conditions is lost — and a restore that - * returned visits and documents with nothing between them would be the same - * loss one layer deeper. + * episode. All five are carried rather than owed. A neighbour on the debt + * register already says what the alternative costs — the filing between + * documents and conditions is lost — and a restore that returned visits and + * documents with nothing between them would be the same loss one layer + * deeper. * * Every read is scoped to the owner. Each model uses its own named select * constant and its own delegate call rather than riding a parent's `include`: @@ -20,12 +20,15 @@ * relation-shaped write here would be attributed to whichever of them a * matcher happened to find first. Delegate calls cannot be confused that way. * - * Two references deliberately do not survive a restore, and both are reported - * rather than dropped: + * Two references can fail to resolve on a restore, and both are reported + * rather than dropped in silence: * - * - `Encounter.reminderId` points at a `MeasurementReminder`, which is on the - * coverage-pending register and does not come back. The column resolves to - * NULL, never to a dangling id. + * - `Encounter.reminderId` points at a `MeasurementReminder`, which restores + * in the same run (v1.37.20, #223 / iOS #68 — it was on the + * coverage-pending register before that and every reference was a known + * drop). It remaps against the restored reminders and resolves to NULL, + * never to a dangling id, only when the file genuinely lacks the reminder + * — a portable export omits tombstoned ones. * - A link whose far side was not restored. A portable export carries no lab * result ids, so a lab link in one cannot resolve; a disaster-recovery * payload carries every id and they all do. @@ -72,9 +75,9 @@ export interface EncounterBackupEntry { reasonEncrypted: string | null; outcomeEncrypted: string | null; /** - * Carried so the loss is visible in the file rather than inferred from its - * absence. The restore resolves it to NULL and names the drop: the reminder - * it points at is not in the backup. + * The appointment's reminder row, which restores in the same run since + * v1.37.20. The restore remaps it against the restored reminders and drops + * it to NULL, with the drop named, only when the file lacks the reminder. */ reminderId: string | null; createdAt: string; @@ -351,10 +354,12 @@ function decodeCiphertext(encoded: string): Uint8Array { * section. Parents first — practitioners, then encounters, then the links — * because a link addresses both of its ends by id. * - * MUST be called after the documents, lab results and condition episodes have - * been restored: a link is written only when both of its ends exist, and the - * existence check is what decides. Calling it earlier would report every link - * as unresolvable and be counted as a successful restore of nothing. + * MUST be called after the documents, lab results, condition episodes AND the + * measurement reminders have been restored: a link is written only when both + * of its ends exist, the encounter's `reminderId` remaps against the restored + * reminders, and the existence check is what decides. Calling it earlier would + * report every one of those references as unresolvable and be counted as a + * successful restore of a thinner record. */ export async function restoreVisitsData( tx: Prisma.TransactionClient, @@ -406,8 +411,17 @@ export async function restoreVisitsData( ); const droppedPractitioners: string[] = []; - // The reminder is on the coverage-pending register: it is not in the file at - // all, so every reference to one is a known, reportable drop. + // The reminders are restored by another branch of the same transaction + // (v1.37.20 — they used to be on the coverage-pending register, when every + // reference was a known drop), so the check is against the database rather + // than against the payload. A reference the file genuinely lacks — a + // portable export omits tombstoned reminders — still drops to NULL and is + // still reported. + const reminderRows = await tx.measurementReminder.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }); + const restoredReminders = new Set(reminderRows.map((row) => row.id)); const droppedReminders: string[] = []; if (payload.encounters.length > 0) { @@ -421,7 +435,13 @@ export async function restoreVisitsData( if (entry.practitionerId && practitionerId === null) { droppedPractitioners.push(entry.practitionerId); } - if (entry.reminderId) droppedReminders.push(entry.reminderId); + const reminderId = + entry.reminderId && restoredReminders.has(entry.reminderId) + ? entry.reminderId + : null; + if (entry.reminderId && reminderId === null) { + droppedReminders.push(entry.reminderId); + } return { id: entry.id, userId: ownerId, @@ -437,8 +457,7 @@ export async function restoreVisitsData( entry.outcomeEncrypted == null ? null : decodeCiphertext(entry.outcomeEncrypted), - // Never the id the file carried: the row it named is not restored. - reminderId: null, + reminderId, createdAt: new Date(entry.createdAt), updatedAt: new Date(entry.updatedAt), deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 2f1e696e1..88c20d4c5 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -46,6 +46,7 @@ import { IntakeAttributionSource, IntakeSource, MeasurementAggregationProvenance, + MeasurementReminderEventKind, MeasurementSource, MeasurementType, MedicationCategory, @@ -54,6 +55,7 @@ import { MedicationSideEffectCategory, MedicationSideEffectEntry, OvulationTest, + ReminderOrigin, RhythmClassification, SecondarySymptom, SleepStage, @@ -70,6 +72,7 @@ import { emergencyBloodTypeSchema, organDonorStatusSchema, } from "@/lib/validations/emergency-profile"; +import { REMINDER_EVENT_SOURCES } from "@/lib/measurement-reminders/satisfy"; export const BACKUP_SCHEMA_VERSION = "2" as const; const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const; @@ -890,8 +893,8 @@ const encounterBackupSchema = z practitionerId: z.string().nullable().optional(), reasonEncrypted: base64BytesSchema.nullable().optional(), outcomeEncrypted: base64BytesSchema.nullable().optional(), - // Carried so the loss is visible in the file. The restore resolves it to - // NULL and names the drop: the reminder it points at does not travel. + // Remapped against the restored reminders (they travel since v1.37.20); + // dropped to NULL, with the drop named, only when the file lacks the row. reminderId: z.string().nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, @@ -927,8 +930,8 @@ const vaccinationBackupSchema = z site: z.enum(VaccinationSite).nullable().optional(), practitionerId: z.string().nullable().optional(), encounterId: z.string().nullable().optional(), - // Carried so the loss is visible in the file. The restore resolves it to - // NULL and names the drop: the reminder it points at does not travel. + // Remapped against the restored reminders (they travel since v1.37.20); + // dropped to NULL, with the drop named, only when the file lacks the row. reminderId: z.string().nullable().optional(), noteEncrypted: base64BytesSchema.nullable().optional(), createdAt: isoDateTime, @@ -945,6 +948,56 @@ const vaccinationLinkBackupSchema = z }) .passthrough(); +/** + * One Vorsorge reminder, and one row of its completion ledger (v1.37.20, + * #223 / iOS #68). + * + * `id` is required on both: an encounter, a vaccination record and every + * ledger row address a reminder by it, and a ledger row is addressed by its + * own id on re-insert. The enums are exactly the API's own — `origin` and + * `kind` are the Prisma enums, and the ledger `source` is validated against + * the engine's closed call-site set rather than accepted as free text, so a + * file cannot smuggle a value the engine itself would never write. + */ +const measurementReminderBackupSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + measurementType: z.enum(MeasurementType).nullable().optional(), + intervalDays: z.number().int().nullable().optional(), + rrule: z.string().nullable().optional(), + anchorDate: isoDateTime.nullable().optional(), + endsOn: isoDateTime.nullable().optional(), + origin: z.enum(ReminderOrigin).optional(), + notifyHour: z.number().int().min(0).max(23).optional(), + location: z.string().nullable().optional(), + // Server-computed and restored verbatim — recomputing on restore would + // move a due date the person had already been shown. + nextDueAt: isoDateTime.nullable().optional(), + lastSatisfiedAt: isoDateTime.nullable().optional(), + enabled: z.boolean().optional(), + vaccinationAntigen: z.string().nullable().optional(), + snoozedUntil: isoDateTime.nullable().optional(), + lastSkippedAt: isoDateTime.nullable().optional(), + skipCount: z.number().int().min(0).optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); + +const measurementReminderEventBackupSchema = z + .object({ + id: z.string().min(1), + reminderId: z.string().min(1), + kind: z.enum(MeasurementReminderEventKind), + occurredAt: isoDateTime, + onTime: z.boolean(), + source: z.enum(REMINDER_EVENT_SOURCES), + createdAt: isoDateTime, + }) + .passthrough(); + const allergyBackupSchema = z .object({ id: z.string().min(1), @@ -1107,6 +1160,14 @@ export const backupPayloadSchema = z // writes []. vaccinations: z.array(vaccinationBackupSchema).default([]), vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]), + // The Vorsorge reminders and their completion ledger (v1.37.20, #223 / + // iOS #68). Defaulted for the same reason as the sections above: a file + // written before the reminders travelled carries no key, and an account + // with none writes []. + measurementReminders: z.array(measurementReminderBackupSchema).default([]), + measurementReminderEvents: z + .array(measurementReminderEventBackupSchema) + .default([]), manifest: backupManifestSchema.nullable().default(null), // v1.37.19 (A6-9) — field paths a PORTABLE export could not decrypt // (fail-soft nulls). Disclosed in the file so a nulled field is @@ -1189,6 +1250,10 @@ export interface BackupSummary { vaccinations: number; /** v1.37.19 (A6-8) — vaccination↔document links. */ vaccinationLinks: number; + /** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */ + measurementReminders: number; + /** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */ + measurementReminderEvents: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -1240,6 +1305,11 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { payload.encounterConditionLinks.length, vaccinations: payload.vaccinations.length, vaccinationLinks: payload.vaccinationDocumentLinks.length, + // v1.37.20 (#223 / iOS #68) — counted from the release that carries them, + // so the admin's "what did I just restore" answer never under-counts a + // file with reminders the way it once did for visits. + measurementReminders: payload.measurementReminders.length, + measurementReminderEvents: payload.measurementReminderEvents.length, }; } diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 966919fd2..2923d738b 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -153,6 +153,10 @@ const COUNT_BACK: Record< p.vaccinationRecord.count({ where: { userId } }), VaccinationDocumentLink: (p, userId) => p.vaccinationDocumentLink.count({ where: { userId } }), + MeasurementReminder: (p, userId) => + p.measurementReminder.count({ where: { userId } }), + MeasurementReminderEvent: (p, userId) => + p.measurementReminderEvent.count({ where: { userId } }), }; /** @@ -438,6 +442,51 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { noteEncrypted: encryptToBytes("ring the upper bell"), }, }); + + // One Vorsorge reminder with the v1.37.20 skip/snooze columns all set, and + // two completion-ledger rows — one honest satisfy, one honest skip. Fat like + // the vaccination fixture below and for the same reason: the count-back + // proves the rows returned, the column-by-column assertion after the restore + // proves they returned WHOLE. The encounter and the dose below point their + // `reminderId` here, because both references now remap against the restored + // reminders and this fixture is what proves they survive rather than drop. + const reminder = await prisma.measurementReminder.create({ + data: { + userId: OWNER_ID, + label: "Blutdruck messen", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 7, + notifyHour: 8, + location: "Zuhause", + nextDueAt: AT("2026-07-08T06:00:00.000Z"), + lastSatisfiedAt: AT("2026-07-01T06:05:00.000Z"), + vaccinationAntigen: "tetanus", + snoozedUntil: AT("2026-07-08T06:00:00.000Z"), + lastSkippedAt: AT("2026-06-24T06:00:00.000Z"), + skipCount: 2, + }, + }); + await prisma.measurementReminderEvent.create({ + data: { + userId: OWNER_ID, + reminderId: reminder.id, + kind: "SATISFIED", + occurredAt: AT("2026-07-01T06:05:00.000Z"), + onTime: true, + source: "manual", + }, + }); + await prisma.measurementReminderEvent.create({ + data: { + userId: OWNER_ID, + reminderId: reminder.id, + kind: "SKIPPED", + occurredAt: AT("2026-06-24T06:00:00.000Z"), + onTime: false, + source: "skip", + }, + }); + const encounter = await prisma.encounter.create({ data: { userId: OWNER_ID, @@ -449,6 +498,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { outcomeEncrypted: encryptToBytes( "ferritin back in range, recheck in a year", ), + reminderId: reminder.id, }, }); await prisma.encounterDocumentLink.create({ @@ -477,13 +527,12 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { // transcribed from. Fat on purpose, against this file's own minimal-fixture // rule: a count-back proves the row returned, and the assertion after the // restore reads each column back so it also proves the row returned WHOLE. - // The practitioner and the encounter are the ones seeded above, because both - // are remapped on the way back and a reference to something the restore did - // not put back is a different case (covered by the skip test below). - // - // `reminderId` is deliberately NOT set here: it is the one reference that - // can never resolve, so setting it would make this test's "nothing was - // skipped" assertion false for a reason that is correct behaviour. + // The practitioner, the encounter and the reminder are the ones seeded + // above, because all three are remapped on the way back and a reference to + // something the restore did not put back is a different case (covered by + // the skip test below). `reminderId` used to be deliberately absent here — + // it was the one reference that could never resolve — and is deliberately + // present since v1.37.20, because resolving is now the behaviour under test. const vaccination = await prisma.vaccinationRecord.create({ data: { userId: OWNER_ID, @@ -496,6 +545,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { site: "LEFT_ARM", practitionerId: practitioner.id, encounterId: encounter.id, + reminderId: reminder.id, noteEncrypted: encryptToBytes("sore arm for a day, nothing else"), }, }); @@ -646,6 +696,7 @@ describe("every model the plan claims two-ended survives a real restore", () => include: { practitioner: { select: { name: true } }, encounter: { select: { occurredAt: true } }, + reminder: { select: { label: true } }, documentLinks: { select: { documentId: true } }, }, }); @@ -659,6 +710,10 @@ describe("every model the plan claims two-ended survives a real restore", () => site: dose.site, practitioner: dose.practitioner?.name ?? null, encounterAt: dose.encounter?.occurredAt.toISOString() ?? null, + // The booster reference, resolved through the relation: non-null proves + // the id survived the round trip instead of dropping to NULL the way it + // had to before the reminders travelled. + reminder: dose.reminder?.label ?? null, links: dose.documentLinks.length, note: dose.noteEncrypted ? decryptFromBytes(dose.noteEncrypted) : null, }).toEqual({ @@ -671,21 +726,119 @@ describe("every model the plan claims two-ended survives a real restore", () => site: "LEFT_ARM", practitioner: "Round-trip practice", encounterAt: "2026-06-30T08:00:00.000Z", + reminder: "Blutdruck messen", links: 1, note: "sore arm for a day, nothing else", }); + + // The reminder, read back column by column — including the three + // v1.37.20 columns (snooze cursor, last skip, skip counter), because a + // restore that defaulted them would hand back a cadence that looks never + // snoozed and never skipped, which is a history the account does not have. + const restoredReminder = await prisma.measurementReminder.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect({ + label: restoredReminder.label, + measurementType: restoredReminder.measurementType, + intervalDays: restoredReminder.intervalDays, + rrule: restoredReminder.rrule, + anchorDate: restoredReminder.anchorDate, + endsOn: restoredReminder.endsOn, + origin: restoredReminder.origin, + notifyHour: restoredReminder.notifyHour, + location: restoredReminder.location, + nextDueAt: restoredReminder.nextDueAt?.toISOString() ?? null, + lastSatisfiedAt: restoredReminder.lastSatisfiedAt?.toISOString() ?? null, + enabled: restoredReminder.enabled, + vaccinationAntigen: restoredReminder.vaccinationAntigen, + snoozedUntil: restoredReminder.snoozedUntil?.toISOString() ?? null, + lastSkippedAt: restoredReminder.lastSkippedAt?.toISOString() ?? null, + skipCount: restoredReminder.skipCount, + deletedAt: restoredReminder.deletedAt, + }).toEqual({ + label: "Blutdruck messen", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 7, + rrule: null, + anchorDate: null, + endsOn: null, + origin: "VORSORGE", + notifyHour: 8, + location: "Zuhause", + nextDueAt: "2026-07-08T06:00:00.000Z", + lastSatisfiedAt: "2026-07-01T06:05:00.000Z", + enabled: true, + vaccinationAntigen: "tetanus", + snoozedUntil: "2026-07-08T06:00:00.000Z", + lastSkippedAt: "2026-06-24T06:00:00.000Z", + skipCount: 2, + deletedAt: null, + }); + + // Both ledger rows, whole: the satisfy and the skip, with the resolved + // `onTime` verdicts. A restore cannot re-derive `onTime` — the pre-event + // `nextDueAt` is gone — so a defaulted value here would be an invented + // punctuality record. + const ledger = await prisma.measurementReminderEvent.findMany({ + where: { userId: OWNER_ID }, + orderBy: { occurredAt: "asc" }, + }); + expect( + ledger.map((event) => ({ + reminderId: event.reminderId, + kind: event.kind, + occurredAt: event.occurredAt.toISOString(), + onTime: event.onTime, + source: event.source, + })), + ).toEqual([ + { + reminderId: restoredReminder.id, + kind: "SKIPPED", + occurredAt: "2026-06-24T06:00:00.000Z", + onTime: false, + source: "skip", + }, + { + reminderId: restoredReminder.id, + kind: "SATISFIED", + occurredAt: "2026-07-01T06:05:00.000Z", + onTime: true, + source: "manual", + }, + ]); + + // The appointment's reminder reference survives too — same remap, other + // referrer. + const restoredEncounter = await prisma.encounter.findFirstOrThrow({ + where: { userId: OWNER_ID }, + select: { reminderId: true }, + }); + expect( + restoredEncounter.reminderId, + "the encounter's reminder reference must survive now that the reminder travels", + ).toBe(restoredReminder.id); }); /** - * The two references a dose can carry that the restore cannot honour, and - * the slug it must not judge. + * The reference a portable file genuinely lacks, and the slug the restore + * must not judge. * - * Separated from the round trip above because both are correct behaviour - * that shows up as a reported skip, and that file asserts nothing was + * The reminders travel since v1.37.20, so a reference to one is no longer a + * guaranteed drop — the round trip above proves it surviving. What can still + * genuinely be missing is a TOMBSTONED reminder in a portable export: the + * builder omits it (a restore must not resurrect a deletion) and omits its + * completion ledger with it, while a live dose that recorded satisfying it + * keeps its reference in the database. That reference then points at a row + * the file does not carry, which is exactly the case the restore reports. + * + * Separated from the round trip above because this is correct behaviour + * that shows up as a reported skip, and that test asserts nothing was * skipped. Reported is the whole point: a reference dropped in silence is * the same defect as a row dropped in silence, one field smaller. */ - it("reports the reminder it cannot restore and keeps a slug it cannot resolve", async () => { + it("reports the tombstoned reminder a portable file omits and keeps a slug it cannot resolve", async () => { const prisma = getPrismaClient(); await seedAdminSession(prisma); await createOwner(prisma); @@ -696,6 +849,20 @@ describe("every model the plan claims two-ended survives a real restore", () => label: "Tetanus booster", intervalDays: 3650, vaccinationAntigen: "tetanus", + deletedAt: AT("2026-07-01T00:00:00.000Z"), + }, + }); + // A ledger row on the tombstoned reminder. The builder must drop it from + // the file along with its reminder — a ledger row whose reminder is not + // carried could only ever restore as a reported loss. + await prisma.measurementReminderEvent.create({ + data: { + userId: OWNER_ID, + reminderId: reminder.id, + kind: "SATISFIED", + occurredAt: AT("2026-06-01T09:00:00.000Z"), + onTime: true, + source: "vaccination", }, }); await prisma.vaccinationRecord.create({ @@ -713,8 +880,14 @@ describe("every model the plan claims two-ended survives a real restore", () => }); const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { - purpose: "disaster-recovery", + purpose: "portable-export", }); + // The file must be silent about the tombstoned reminder AND its ledger — + // carrying the events of a reminder it refuses to restore would make + // every portable file this release writes restore with a reported loss. + expect(payload.measurementReminders).toEqual([]); + expect(payload.measurementReminderEvents).toEqual([]); + await prisma.user.delete({ where: { id: OWNER_ID } }); await createOwner(prisma);