mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Every finding behind this work is a two-ended change whose ends shipped in different releases with nothing holding them together, and every gate the repo runs proves one end: typecheck proves the export exists, knip proves it is imported, the OpenAPI check proves the schema matches. None of them notice when the second end never arrives. Two questions here can be answered exactly, so they become tests. The account endpoint rides every app boot and carries the user's preferences. Its field names are read out of the response literal and each one must be read by a client module — property access or destructuring, comments stripped, because a doc comment claiming a consumer is what the remembered practice name had for its whole dead life. That field would have failed this on the day it shipped. One field is allowlisted today with its reason written out: the insurer institution number has no web surface at all, which the entry keeps visible rather than papering over. The measurement backup select is now a named constant, compared field by field against the model in the schema. Both sides are literals, so the comparison is exact — no name heuristics, nothing that can drift towards accepting. A column added to the model and forgotten by the backup fails at the commit that adds it, which is how the aggregation provenance went missing. Scoped to the one model; extend it as each further model becomes backup-relevant.
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
/**
|
|
* v1.23 — shared builder for the user-scoped full-backup payload.
|
|
*
|
|
* Extracted so both the plaintext `GET /api/export/full-backup` route and the
|
|
* passphrase-encrypted `POST /api/export/encrypted` route emit the byte-for-byte
|
|
* same shape — the one that the pg-boss `data-backup` worker writes and that
|
|
* `parseBackupPayload()` round-trips on admin restore. Keep this writer in sync
|
|
* with the `data-backup` worker (`src/lib/jobs/reminder-worker.ts`).
|
|
*
|
|
* The decrypted notes are surfaced here (the backup is the human-readable
|
|
* artefact); an admin restore re-encrypts them on re-insert.
|
|
*/
|
|
import { Buffer } from "node:buffer";
|
|
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
|
import { readNote } from "@/lib/crypto/note-cipher";
|
|
import { iterateMeasurementPages } from "@/lib/export/paged-measurements";
|
|
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
|
|
import { buildCycleBackupSection } from "@/lib/cycle/backup";
|
|
import {
|
|
buildRecordsBackupSection,
|
|
countRecordsBackupSection,
|
|
type RecordsBackupCounts,
|
|
} from "@/lib/export/records-backup";
|
|
|
|
export interface FullBackupCounts extends RecordsBackupCounts {
|
|
measurements: number;
|
|
medications: number;
|
|
intakeEvents: number;
|
|
moodEntries: number;
|
|
cycles: number;
|
|
cycleDayLogs: number;
|
|
nutrientDays: number;
|
|
}
|
|
|
|
export interface FullBackupResult {
|
|
payload: Record<string, unknown>;
|
|
counts: FullBackupCounts;
|
|
}
|
|
|
|
export interface FullBackupOptions {
|
|
purpose?: "portable-export" | "disaster-recovery";
|
|
exportedAt?: Date;
|
|
}
|
|
/**
|
|
* Every restorable `Measurement` scalar. `userId` is deliberately absent — the
|
|
* backup is user-scoped, so the owner is implicit in the query and restored
|
|
* from the target account rather than the payload.
|
|
*
|
|
* Named rather than inlined so `measurement-backup-completeness.test.ts` can
|
|
* compare it field-by-field against the model in `prisma/schema.prisma`. A
|
|
* column added to the model and forgotten here is how the aggregation
|
|
* provenance went missing from disaster-recovery backups for several releases.
|
|
*/
|
|
const MEASUREMENT_BACKUP_SELECT = {
|
|
id: true,
|
|
type: true,
|
|
value: true,
|
|
valueMin: true,
|
|
valueMax: true,
|
|
unit: true,
|
|
measuredAt: true,
|
|
source: true,
|
|
notes: true,
|
|
notesEncrypted: true,
|
|
externalId: true,
|
|
externalSourceVersion: true,
|
|
aggregationProvenance: true,
|
|
glucoseContext: true,
|
|
sleepStage: true,
|
|
rhythmClassification: true,
|
|
deviceType: true,
|
|
syncVersion: true,
|
|
deletedAt: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
} as const satisfies Prisma.MeasurementSelect;
|
|
|
|
/**
|
|
* Build the canonical full-backup payload for `userId`. Portable exports omit
|
|
* tombstones and document ciphertext; disaster-recovery payloads preserve
|
|
* both so weekly and off-host snapshots share one restorable wire format.
|
|
*/
|
|
export async function buildFullBackupPayload(
|
|
prisma: PrismaClient,
|
|
userId: string,
|
|
options: FullBackupOptions = {},
|
|
): Promise<FullBackupResult> {
|
|
const disasterRecovery = options.purpose === "disaster-recovery";
|
|
const [
|
|
appSettings,
|
|
measurements,
|
|
medications,
|
|
intakeEvents,
|
|
moodEntries,
|
|
cycle,
|
|
records,
|
|
nutrientDays,
|
|
] = await Promise.all([
|
|
disasterRecovery
|
|
? prisma.appSettings.findUnique({ where: { id: "singleton" } })
|
|
: Promise.resolve(null),
|
|
(async () => {
|
|
const rows: Array<Record<string, unknown>> = [];
|
|
const pages = iterateMeasurementPages(
|
|
prisma,
|
|
disasterRecovery ? { userId } : { userId, deletedAt: null },
|
|
MEASUREMENT_BACKUP_SELECT,
|
|
);
|
|
for await (const page of pages) {
|
|
for (const measurement of page) {
|
|
rows.push(
|
|
disasterRecovery
|
|
? {
|
|
id: measurement.id,
|
|
type: measurement.type,
|
|
value: measurement.value,
|
|
valueMin: measurement.valueMin,
|
|
valueMax: measurement.valueMax,
|
|
unit: measurement.unit,
|
|
measuredAt: measurement.measuredAt.toISOString(),
|
|
source: measurement.source,
|
|
notes: measurement.notes,
|
|
notesEncrypted: measurement.notesEncrypted
|
|
? Buffer.from(measurement.notesEncrypted).toString("base64")
|
|
: null,
|
|
externalId: measurement.externalId,
|
|
externalSourceVersion: measurement.externalSourceVersion,
|
|
aggregationProvenance: measurement.aggregationProvenance,
|
|
glucoseContext: measurement.glucoseContext,
|
|
sleepStage: measurement.sleepStage,
|
|
rhythmClassification: measurement.rhythmClassification,
|
|
deviceType: measurement.deviceType,
|
|
syncVersion: measurement.syncVersion,
|
|
deletedAt: measurement.deletedAt?.toISOString() ?? null,
|
|
createdAt: measurement.createdAt.toISOString(),
|
|
updatedAt: measurement.updatedAt.toISOString(),
|
|
}
|
|
: {
|
|
id: measurement.id,
|
|
type: measurement.type,
|
|
value: measurement.value,
|
|
unit: measurement.unit,
|
|
measuredAt: measurement.measuredAt.toISOString(),
|
|
source: measurement.source,
|
|
notes: readNote(
|
|
measurement.notesEncrypted,
|
|
measurement.notes,
|
|
),
|
|
deletedAt: measurement.deletedAt?.toISOString() ?? null,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
return rows;
|
|
})(),
|
|
prisma.medication.findMany({
|
|
where: { userId },
|
|
include: { schedules: true },
|
|
}),
|
|
prisma.medicationIntakeEvent.findMany({
|
|
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
|
|
include: { medication: { select: { name: true } } },
|
|
orderBy: { scheduledFor: "desc" },
|
|
}),
|
|
prisma.moodEntry.findMany({
|
|
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
|
|
orderBy: { moodLoggedAt: "desc" },
|
|
include: {
|
|
tagLinks: {
|
|
where: { rating: { not: null } },
|
|
select: {
|
|
rating: true,
|
|
moodTag: { select: { key: true } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
buildCycleBackupSection(prisma, userId, {
|
|
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
|
}),
|
|
buildRecordsBackupSection(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").
|
|
// `source` is part of the composite PK, so it has to ride along or a
|
|
// restore cannot tell a manual water entry from a synced day total. The
|
|
// table carries no tombstone, so both purposes read the same shape.
|
|
prisma.nutrientIntakeDay.findMany({
|
|
where: { userId },
|
|
select: {
|
|
day: true,
|
|
nutrient: true,
|
|
amount: true,
|
|
unit: true,
|
|
source: true,
|
|
},
|
|
orderBy: [{ day: "desc" }, { nutrient: "asc" }],
|
|
}),
|
|
]);
|
|
|
|
const payload = {
|
|
schemaVersion: BACKUP_SCHEMA_VERSION,
|
|
exportedAt: (options.exportedAt ?? new Date()).toISOString(),
|
|
userId,
|
|
appSettings:
|
|
disasterRecovery && appSettings
|
|
? {
|
|
...appSettings,
|
|
adminCodexTokenExpiresAt:
|
|
appSettings.adminCodexTokenExpiresAt?.toISOString() ?? null,
|
|
adminCodexConnectedAt:
|
|
appSettings.adminCodexConnectedAt?.toISOString() ?? null,
|
|
documentQuotaBytes: appSettings.documentQuotaBytes.toString(),
|
|
}
|
|
: null,
|
|
measurements,
|
|
medications: medications.map((m) => ({
|
|
...(disasterRecovery
|
|
? {
|
|
id: m.id,
|
|
treatmentClass: m.treatmentClass,
|
|
dosesPerUnit: m.dosesPerUnit,
|
|
unitsPerDose: m.unitsPerDose.toString(),
|
|
notificationsEnabled: m.notificationsEnabled,
|
|
pausedAt: m.pausedAt?.toISOString() ?? null,
|
|
snoozedUntil: m.snoozedUntil?.toISOString() ?? null,
|
|
startsOn: m.startsOn?.toISOString() ?? null,
|
|
endsOn: m.endsOn?.toISOString() ?? null,
|
|
oneShot: m.oneShot,
|
|
asNeeded: m.asNeeded,
|
|
deliveryForm: m.deliveryForm,
|
|
trackInjectionSites: m.trackInjectionSites,
|
|
allowedInjectionSites: m.allowedInjectionSites,
|
|
liveActivityEnabled: m.liveActivityEnabled,
|
|
criticalAlarmEnabled: m.criticalAlarmEnabled,
|
|
atcCode: m.atcCode,
|
|
rxNormCode: m.rxNormCode,
|
|
lowStockNotifiedAt: m.lowStockNotifiedAt?.toISOString() ?? null,
|
|
lowStockNotifiedThresholdDays: m.lowStockNotifiedThresholdDays,
|
|
reorderLeadDays: m.reorderLeadDays,
|
|
externalSource: m.externalSource,
|
|
externalId: m.externalId,
|
|
createdAt: m.createdAt.toISOString(),
|
|
updatedAt: m.updatedAt.toISOString(),
|
|
}
|
|
: {}),
|
|
name: m.name,
|
|
dose: m.dose,
|
|
active: m.active,
|
|
schedules: m.schedules.map((s) => ({
|
|
...(disasterRecovery
|
|
? {
|
|
id: s.id,
|
|
daysOfWeek: s.daysOfWeek,
|
|
timesOfDay: s.timesOfDay,
|
|
reminderGraceMinutes: s.reminderGraceMinutes,
|
|
rrule: s.rrule,
|
|
rollingIntervalDays: s.rollingIntervalDays,
|
|
scheduleType: s.scheduleType,
|
|
cyclicOnWeeks: s.cyclicOnWeeks,
|
|
cyclicOffWeeks: s.cyclicOffWeeks,
|
|
doseWindows: s.doseWindows,
|
|
}
|
|
: {}),
|
|
windowStart: s.windowStart,
|
|
windowEnd: s.windowEnd,
|
|
label: s.label,
|
|
dose: s.dose,
|
|
})),
|
|
})),
|
|
intakeEvents: intakeEvents.map((e) => ({
|
|
...(disasterRecovery
|
|
? {
|
|
id: e.id,
|
|
medicationId: e.medicationId,
|
|
autoMissed: e.autoMissed,
|
|
attributionSource: e.attributionSource,
|
|
idempotencyKey: e.idempotencyKey,
|
|
createdAt: e.createdAt.toISOString(),
|
|
injectionSite: e.injectionSite,
|
|
doseTaken: e.doseTaken,
|
|
inventoryConsumption: e.inventoryConsumption,
|
|
externalId: e.externalId,
|
|
updatedAt: e.updatedAt.toISOString(),
|
|
syncVersion: e.syncVersion,
|
|
deletedAt: e.deletedAt?.toISOString() ?? null,
|
|
}
|
|
: {}),
|
|
medication: e.medication.name,
|
|
scheduledFor: e.scheduledFor.toISOString(),
|
|
takenAt: e.takenAt?.toISOString() ?? null,
|
|
skipped: e.skipped,
|
|
source: e.source,
|
|
})),
|
|
moodEntries: moodEntries.map((e) => ({
|
|
id: e.id,
|
|
date: e.date,
|
|
mood: e.mood,
|
|
score: e.score,
|
|
tags: e.tags,
|
|
source: e.source,
|
|
loggedAt: e.moodLoggedAt.toISOString(),
|
|
externalId: e.externalId,
|
|
deletedAt: e.deletedAt?.toISOString() ?? null,
|
|
factors: e.tagLinks.map((link) => ({
|
|
key: link.moodTag.key,
|
|
rating: link.rating!,
|
|
})),
|
|
})),
|
|
cycleProfile: cycle.cycleProfile,
|
|
cycles: cycle.cycles,
|
|
cycleDayLogs: cycle.cycleDayLogs,
|
|
// v1.28 backup-completeness — the domains the pre-existing shape never
|
|
// covered. `manifest` discloses the two deliberate exclusions (document
|
|
// binaries, workout GPS/sample time series) inline in the file itself,
|
|
// not just in the export UI copy.
|
|
labResults: records.labResults,
|
|
biomarkers: records.biomarkers,
|
|
illnessEpisodes: records.illnessEpisodes,
|
|
allergies: records.allergies,
|
|
familyHistory: records.familyHistory,
|
|
workouts: records.workouts,
|
|
documents: records.documents,
|
|
nutrientDays: nutrientDays.map((n) => ({
|
|
day: n.day,
|
|
nutrient: n.nutrient,
|
|
amount: n.amount,
|
|
unit: n.unit,
|
|
source: n.source,
|
|
})),
|
|
manifest: records.manifest,
|
|
};
|
|
|
|
return {
|
|
payload,
|
|
counts: {
|
|
measurements: measurements.length,
|
|
medications: medications.length,
|
|
intakeEvents: intakeEvents.length,
|
|
moodEntries: moodEntries.length,
|
|
cycles: cycle.cycles.length,
|
|
cycleDayLogs: cycle.cycleDayLogs.length,
|
|
nutrientDays: nutrientDays.length,
|
|
...countRecordsBackupSection(records),
|
|
},
|
|
};
|
|
}
|