mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Carry the reminder schedule and its ledger through backup and restore
The reminder cadences were the oldest entry on the coverage-pending register: carried in name, restored never — an account came back silent until every reminder was rebuilt by hand. Both models are two-ended now, in the house shape (both ends in one file, reminders-backup.ts): every column travels, nextDueAt verbatim rather than recomputed, the completion ledger beside it, tombstones in disaster recovery only, and a portable export omits a tombstoned reminder together with its ledger rows. Encounter.reminderId and VaccinationRecord.reminderId stop being dropped on restore: both remap against the restored reminders and fall to NULL with a skip report only when the file genuinely lacks the row. The wipe plan names the ledger child-before-parent; the classification guard, completeness guards and the integration round trip all demand the new coverage (proven failing with the section removed). Refs #223
This commit is contained in:
1 parent
a1ca0dc911
commit
b1be67bfa7
15 files changed
+864
-86
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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([]) },
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Record<string, string>> = {
|
||||
"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:
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<RemindersBackupSection> {
|
||||
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<T> = { [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<RemindersRestoreCleared> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<ArrayBuffer> {
|
||||
* 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
|
||||
|
||||
@@ -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<ArrayBuffer> {
|
||||
* 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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user