mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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
395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|