Files
HealthLog/src/lib/cycle/backup.ts
T
Marc-André Bombeck 9c4235b66d fix(backup): a retired catalogue key must not cost the whole file
Three of the restore's lookups resolve a key against a seeded catalogue:
cycle symptoms, illness symptoms, mood factors. Each threw on a key it
could not resolve, inside the one transaction that wipes and rebuilds the
account. So a single renamed symptom rolled everything back and answered
500. No measurements, no doses, no notes, nothing. A year-old backup read
by a newer instance is the ordinary case for a backup, and the catalogue
drifts between those two dates by design.

The unresolvable link is dropped and named now. What it costs is one
association between a row and a symptom; the day-log, the mood entry, the
flow, the temperature and the encrypted note all come back. The count and
the exact keys ride the response, the audit row and the wide event, and
the admin surface shows them in a panel that stays put instead of a toast
that leaves. A quiet drop would be the older failure of this same seam
wearing a success message.

A dangling `cycleId` or a dose recorded against a medication the file
never carried still throws. Those name rows the file was supposed to hold,
so a missing one means the file contradicts itself. A catalogue key names
a row the instance owns, which is why it gets the other answer.

`IllnessSymptom` carries no owner column at all, so a key that will not
resolve there can only ever be drift; the file was never able to supply it.

The restore does not mint the missing catalogue row back. That would
preserve the link at the price of reference data colliding with whatever
the next migration seeds under that key, and a label nothing can translate.
2026-08-02 09:04:36 +02:00

600 lines
22 KiB
TypeScript

/**
* Cycle-tracking backup serialisation — the one place the full-backup
* writers (the on-demand `GET /api/export/full-backup` route AND the
* pg-boss `data-backup` worker) read the cycle tables, so the two writers
* never drift. Mirrors the `backupPayloadSchema` cycle shapes.
*
* Reads are scoped to `userId` + `deletedAt: null` and exclude predicted
* (forecast) cycle rows — only observed history round-trips. `notesEncrypted`
* is carried as the ciphertext envelope VERBATIM: the backup never decrypts
* the day-log note, so a wrong-surface plaintext leak is impossible and the
* owner's note round-trips encrypted at rest.
*/
import type {
CervicalMucus,
CervixFirmness,
CervixOpening,
CervixPosition,
ContraceptiveKind,
CycleTrackingGoal,
FlowLevel,
HomeTestResult,
MeasurementSource,
OvulationTest,
Prisma,
PrismaClient,
SecondarySymptom,
} from "@/generated/prisma/client";
import type { BackupPayload } from "@/lib/validations/backup";
import {
recordUnknownKeys,
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
/** The cycle slice of a backup payload. */
export interface CycleBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
export interface CycleBackupSection {
cycleProfile: {
id?: string;
goal: string;
cycleTrackingEnabled: boolean | null;
typicalCycleLength: number | null;
typicalPeriodLength: number | null;
lutealPhaseLength: number | null;
secondarySymptom: string;
predictionEnabled: boolean;
rawChartMode: boolean;
discreetNotifications: boolean;
sensitiveCategoryEncryption: boolean;
createdAt?: string;
updatedAt?: string;
} | null;
cycles: Array<{
id?: string;
startDate: string;
endDate: string | null;
periodEndDate: string | null;
lengthDays: number | null;
ovulationDate: string | null;
ovulationConfirmed: boolean;
tz: string | null;
isPredicted?: boolean;
syncVersion?: number;
deletedAt?: string | null;
createdAt?: string;
updatedAt?: string;
}>;
cycleDayLogs: Array<{
id?: string;
cycleId?: string | null;
date: string;
flow: string | null;
intermenstrualBleeding: boolean;
basalBodyTempC: number | null;
temperatureExcluded: boolean;
ovulationTest: string | null;
cervicalMucus: string | null;
cervixPosition: string | null;
cervixFirmness: string | null;
cervixOpening: string | null;
sexualActivity: boolean;
protectedSex: boolean | null;
pregnancyTest: string | null;
progesteroneTest: string | null;
contraceptive: string | null;
sensitiveEncrypted: string | null;
notesEncrypted: string | null;
source: string;
externalId: string | null;
tz: string | null;
syncVersion?: number;
deletedAt?: string | null;
createdAt?: string;
updatedAt?: string;
/**
* Which symptoms the day carries. The single presence list — one entry per
* link, nothing else decides whether a symptom was on the day.
*/
symptomKeys: string[];
/**
* How hard each of those symptoms hit, where the person said so.
*
* `CycleSymptomLink.severity` is a 1-4 Likert the log-day sheet writes and
* the day-log DTO reads back, and the backup carried only the keys — so a
* day recorded as "cramps, and they were a 4" came back as "cramps" and the
* 4 was gone. Nothing failed; the number was simply not in the file.
*
* Kept as a SPARSE annotation over `symptomKeys` rather than folded into
* it, for two reasons. A release that only knows `symptomKeys` still reads
* every symptom out of a file written here, instead of finding the key it
* looks for replaced by one it does not know and restoring a day with no
* symptoms at all. And presence stays decided in exactly one place, so the
* two lists cannot disagree about which symptoms a day had.
*
* A link with no severity is simply not listed. No entry means the person
* never rated it — which is not the same as rating it zero.
*/
symptomSeverities: Array<{ key: string; severity: number }>;
}>;
/**
* The account's OWN symptom definitions.
*
* The seeded catalogue is reference data every instance already has, but a
* symptom the user created exists only here. Without it the restore looks up
* a key that resolves to nothing and drops the link — the day-log comes back
* with one of its symptoms quietly missing.
*/
customSymptoms: Array<{
id?: string;
key: string;
labelKey: string;
categoryId: string;
icon: string | null;
sortOrder: number;
isActive: boolean;
/**
* The user's own words for the symptom, carried as ciphertext verbatim.
*
* The builder has always emitted it and this interface did not declare it,
* which held while the payload was assembled key by key and stopped holding
* the moment the section is spread onto the wire: an undeclared field is a
* field the wire type says is not there. Declared, so the type describes
* the file.
*/
labelEncrypted: string | null;
}>;
}
/**
* Build the cycle section of a user's full backup. Accepts any client with
* the cycle delegates (the route's global client OR the worker's local
* client) so both writers share one read.
*/
export async function buildCycleBackupSection(
prisma: Pick<
PrismaClient,
"cycleProfile" | "menstrualCycle" | "cycleDayLog" | "cycleSymptom"
>,
userId: string,
options: CycleBackupOptions = {},
): Promise<CycleBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [profile, cycles, dayLogs, customSymptoms] = await Promise.all([
prisma.cycleProfile.findUnique({ where: { userId } }),
prisma.menstrualCycle.findMany({
where: disasterRecovery
? { userId }
: { userId, deletedAt: null, isPredicted: false },
orderBy: { startDate: "asc" },
}),
prisma.cycleDayLog.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { date: "asc" },
include: {
symptomLinks: { include: { symptom: { select: { key: true } } } },
},
}),
// `userId` set means the user made it. The NULL rows are the seeded
// catalogue, which the restoring instance already has.
prisma.cycleSymptom.findMany({
where: { userId },
orderBy: { key: "asc" },
}),
]);
return {
cycleProfile: profile
? {
...(disasterRecovery
? {
id: profile.id,
createdAt: profile.createdAt.toISOString(),
updatedAt: profile.updatedAt.toISOString(),
}
: {}),
goal: profile.goal,
cycleTrackingEnabled: profile.cycleTrackingEnabled,
typicalCycleLength: profile.typicalCycleLength,
typicalPeriodLength: profile.typicalPeriodLength,
lutealPhaseLength: profile.lutealPhaseLength,
secondarySymptom: profile.secondarySymptom,
predictionEnabled: profile.predictionEnabled,
rawChartMode: profile.rawChartMode,
discreetNotifications: profile.discreetNotifications,
sensitiveCategoryEncryption: profile.sensitiveCategoryEncryption,
}
: null,
cycles: cycles.map((c) => ({
...(disasterRecovery
? {
id: c.id,
isPredicted: c.isPredicted,
syncVersion: c.syncVersion,
deletedAt: c.deletedAt?.toISOString() ?? null,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
}
: {}),
startDate: c.startDate,
endDate: c.endDate,
periodEndDate: c.periodEndDate,
lengthDays: c.lengthDays,
ovulationDate: c.ovulationDate,
ovulationConfirmed: c.ovulationConfirmed,
tz: c.tz,
})),
cycleDayLogs: dayLogs.map((d) => ({
...(disasterRecovery
? {
id: d.id,
cycleId: d.cycleId,
syncVersion: d.syncVersion,
deletedAt: d.deletedAt?.toISOString() ?? null,
createdAt: d.createdAt.toISOString(),
updatedAt: d.updatedAt.toISOString(),
}
: {}),
date: d.date,
flow: d.flow,
intermenstrualBleeding: d.intermenstrualBleeding,
basalBodyTempC: d.basalBodyTempC,
temperatureExcluded: d.temperatureExcluded,
ovulationTest: d.ovulationTest,
cervicalMucus: d.cervicalMucus,
cervixPosition: d.cervixPosition,
cervixFirmness: d.cervixFirmness,
cervixOpening: d.cervixOpening,
sexualActivity: d.sexualActivity,
protectedSex: d.protectedSex,
pregnancyTest: d.pregnancyTest,
progesteroneTest: d.progesteroneTest,
contraceptive: d.contraceptive,
sensitiveEncrypted: d.sensitiveEncrypted,
notesEncrypted: d.notesEncrypted,
source: d.source,
externalId: d.externalId,
tz: d.tz,
symptomKeys: d.symptomLinks.map((l) => l.symptom.key),
symptomSeverities: d.symptomLinks
.filter((l): l is typeof l & { severity: number } => l.severity != null)
.map((l) => ({ key: l.symptom.key, severity: l.severity })),
})),
customSymptoms: customSymptoms.map((sym) => ({
...(disasterRecovery ? { id: sym.id } : {}),
key: sym.key,
labelKey: sym.labelKey,
categoryId: sym.categoryId,
icon: sym.icon,
sortOrder: sym.sortOrder,
isActive: sym.isActive,
labelEncrypted: sym.labelEncrypted,
})),
};
}
/* ── restore ───────────────────────────────────────────────────────── */
/** Counts the cycle restore wiped, for the audit trail. */
export interface CycleRestoreCleared {
cycles: number;
cycleDayLogs: number;
cycleProfile: number;
}
// Closed enum allow-lists. The backup schema is `.passthrough()`, so a
// malformed enum value would otherwise crash deep in `create()`. Guard the
// few enum columns up-front (mirrors the measurement-type guard in the
// restore route). Unknown values are coerced to null rather than failing
// the whole restore — a single drifted field shouldn't strand the rest.
const FLOW_LEVELS = new Set<FlowLevel>([
"NONE",
"SPOTTING",
"LIGHT",
"MEDIUM",
"HEAVY",
]);
const OVULATION_TESTS = new Set<OvulationTest>([
"NEGATIVE",
"POSITIVE_LH_SURGE",
"ESTROGEN_SURGE",
"INDETERMINATE",
]);
const CERVICAL_MUCUS = new Set<CervicalMucus>([
"DRY",
"STICKY",
"CREAMY",
"WATERY",
"EGG_WHITE",
]);
const SECONDARY_SYMPTOMS = new Set<SecondarySymptom>(["MUCUS", "CERVIX"]);
const CERVIX_POSITIONS = new Set<CervixPosition>(["LOW", "HIGH"]);
const CERVIX_FIRMNESSES = new Set<CervixFirmness>(["FIRM", "SOFT"]);
const CERVIX_OPENINGS = new Set<CervixOpening>(["CLOSED", "OPEN"]);
const HOME_TESTS = new Set<HomeTestResult>([
"NEGATIVE",
"POSITIVE",
"INDETERMINATE",
]);
const CONTRACEPTIVES = new Set<ContraceptiveKind>([
"NONE",
"UNSPECIFIED",
"IMPLANT",
"INJECTION",
"IUD",
"INTRAVAGINAL_RING",
"ORAL",
"PATCH",
"EMERGENCY",
]);
const CYCLE_SOURCES = new Set<MeasurementSource>([
"MANUAL",
"WITHINGS",
"IMPORT",
"APPLE_HEALTH",
]);
const CYCLE_GOALS = new Set<CycleTrackingGoal>([
"GENERAL_HEALTH",
"AVOID_PREGNANCY",
"TRYING_TO_CONCEIVE",
"PERIMENOPAUSE",
"OFF",
]);
/**
* A symptom severity is the 1-4 Likert the log-day sheet writes, or nothing.
*
* Anything else — a value from a hand-edited file, a wider scale some later
* release might use, a null — reads as "not rated". It is dropped rather than
* clamped or rounded into range, because a severity nobody recorded is absent,
* and inventing a 1 or a 4 for it would be a guess printed next to the person's
* own numbers. It never fails the restore: refusing the whole file over one
* unfamiliar intensity would cost far more than the intensity does.
*/
function severityOrNull(value: number | null | undefined): number | null {
return typeof value === "number" &&
Number.isInteger(value) &&
value >= 1 &&
value <= 4
? value
: null;
}
function enumOrNull<T extends string>(
value: string | null | undefined,
allow: ReadonlySet<T>,
): T | null {
return value != null && (allow as ReadonlySet<string>).has(value)
? (value as T)
: null;
}
/**
* Restore the cycle tables for `ownerId` from a parsed backup payload,
* inside an existing transaction. Delete-then-recreate, matching the
* measurement/mood restore contract. Symptom links are re-resolved against
* the seeded catalogue by key, and each one gets back the 1-4 intensity the
* file recorded for it, or none if the file recorded none. `notesEncrypted` is
* written back as the ciphertext envelope verbatim — never re-encrypted.
*
* A key that resolves against nothing is dropped from the day's links and
* recorded on `skips`. It is a required argument rather than an optional one:
* a caller that forgets to pass it would restore exactly as before and report
* nothing, which is the failure this reporting exists to end.
*
* Returns the wiped counts for the audit trail. A pre-v1.15 payload (empty
* cycle arrays + null profile) wipes nothing and recreates nothing.
*/
export async function restoreCycleData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: BackupPayload,
skips: RestoreSkipLog,
): Promise<CycleRestoreCleared> {
// Wipe (child links cascade off the day-log delete).
const dayLogs = await tx.cycleDayLog.deleteMany({
where: { userId: ownerId },
});
const cycles = await tx.menstrualCycle.deleteMany({
where: { userId: ownerId },
});
const profile = await tx.cycleProfile.deleteMany({
where: { userId: ownerId },
});
// Recreate the profile (one row / user).
if (payload.cycleProfile) {
const p = payload.cycleProfile;
await tx.cycleProfile.create({
data: {
...(p.id ? { id: p.id } : {}),
userId: ownerId,
goal: enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH",
cycleTrackingEnabled: p.cycleTrackingEnabled ?? null,
typicalCycleLength: p.typicalCycleLength ?? null,
typicalPeriodLength: p.typicalPeriodLength ?? null,
lutealPhaseLength: p.lutealPhaseLength ?? null,
secondarySymptom:
enumOrNull(p.secondarySymptom, SECONDARY_SYMPTOMS) ?? "MUCUS",
predictionEnabled: p.predictionEnabled ?? true,
rawChartMode: p.rawChartMode ?? false,
discreetNotifications: p.discreetNotifications ?? false,
sensitiveCategoryEncryption: p.sensitiveCategoryEncryption ?? true,
...(p.createdAt ? { createdAt: new Date(p.createdAt) } : {}),
...(p.updatedAt ? { updatedAt: new Date(p.updatedAt) } : {}),
},
});
}
// Recreate observed cycle spans; map startDate → id so day-logs can
// re-attach to their owning span.
const cycleIdByStart = new Map<string, string>();
const restoredCycleIds = new Set<string>();
for (const c of payload.cycles) {
const created = await tx.menstrualCycle.create({
data: {
...(c.id ? { id: c.id } : {}),
userId: ownerId,
startDate: c.startDate,
endDate: c.endDate ?? null,
periodEndDate: c.periodEndDate ?? null,
lengthDays: c.lengthDays ?? null,
ovulationDate: c.ovulationDate ?? null,
ovulationConfirmed: c.ovulationConfirmed ?? false,
tz: c.tz ?? null,
isPredicted: c.isPredicted ?? false,
syncVersion: c.syncVersion ?? 0,
deletedAt: c.deletedAt ? new Date(c.deletedAt) : null,
...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}),
...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}),
},
});
cycleIdByStart.set(c.startDate, created.id);
restoredCycleIds.add(created.id);
}
// Re-create the account's own symptom definitions BEFORE resolving links.
// The seeded catalogue is already on this instance; a symptom the user made
// exists only in the file, and a link cannot resolve to a row that was never
// written back.
for (const sym of payload.customSymptoms ?? []) {
await tx.cycleSymptom.upsert({
where: { key: sym.key },
create: {
...(sym.id ? { id: sym.id } : {}),
userId: ownerId,
key: sym.key,
labelKey: sym.labelKey,
categoryId: sym.categoryId,
icon: sym.icon ?? null,
sortOrder: sym.sortOrder,
isActive: sym.isActive,
labelEncrypted: sym.labelEncrypted ?? null,
},
// `key` is globally unique, so a seeded key would collide. Leave the
// catalogue row alone: the account's links resolve against it either way.
update: {},
});
}
// Resolve the symptom catalogue once for the link re-creation.
const referencedKeys = payload.cycleDayLogs.flatMap(
(d) => d.symptomKeys ?? [],
);
const allKeys = Array.from(new Set(referencedKeys));
const symptomIdByKey = new Map<string, string>();
if (allKeys.length > 0) {
const rows = await tx.cycleSymptom.findMany({
where: {
key: { in: allKeys },
OR: [{ userId: null }, { userId: ownerId }],
},
select: { id: true, key: true },
});
for (const r of rows) symptomIdByKey.set(r.key, r.id);
// A cycle reference below still throws, and that difference is the point.
// A `cycleId` names a row THIS FILE was supposed to carry, so a dangling
// one means the file contradicts itself and nothing good comes of writing
// it. A symptom key names a row the INSTANCE owns: the seeded catalogue is
// reference data that drifts across releases, so a key the file wrote a
// year ago and this instance no longer seeds is an ordinary, expected
// mismatch. Refusing the file over it threw away every measurement, every
// dose, and every note to protect a symptom chip. The link is dropped and
// named instead — see `restore-skips.ts` for why neither throwing nor
// filtering in silence was an acceptable answer.
const unresolved = allKeys.filter((k) => !symptomIdByKey.has(k));
recordUnknownKeys(skips, "cycleSymptom", unresolved, referencedKeys);
}
// Recreate day-logs (with symptom links). The owning cycle is the latest
// span whose start is on/before the day (the cycle-attribution rule).
const sortedStarts = payload.cycles
.map((c) => c.startDate)
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
const owningCycleId = (date: string): string | null => {
let id: string | null = null;
for (const start of sortedStarts) {
if (start <= date) id = cycleIdByStart.get(start) ?? id;
else break;
}
return id;
};
for (const d of payload.cycleDayLogs) {
// The day's rated intensities, keyed by symptom. A file written before
// severities were carried has no list at all and every link comes back
// unrated, which is what it was. An entry naming a symptom the day does not
// have is simply never read.
const severityByKey = new Map<string, number>();
for (const s of d.symptomSeverities ?? []) {
const severity = severityOrNull(s.severity);
if (severity !== null) severityByKey.set(s.key, severity);
}
// Every unresolvable key was counted into `skips` above, so dropping it
// here is a reported drop rather than a silent one. The rest of the day —
// flow, temperature, the encrypted note, every observation the person
// actually wrote — is unaffected by a symptom chip that will not resolve.
const symptomLinks = (d.symptomKeys ?? []).flatMap((k) => {
const id = symptomIdByKey.get(k);
return id
? [{ symptomId: id, severity: severityByKey.get(k) ?? null }]
: [];
});
const cycleId =
d.cycleId !== undefined
? d.cycleId === null
? null
: restoredCycleIds.has(d.cycleId)
? d.cycleId
: (() => {
throw new Error(`Unknown cycle reference: ${d.cycleId}`);
})()
: owningCycleId(d.date);
await tx.cycleDayLog.create({
data: {
...(d.id ? { id: d.id } : {}),
userId: ownerId,
date: d.date,
cycleId,
flow: enumOrNull(d.flow, FLOW_LEVELS),
intermenstrualBleeding: d.intermenstrualBleeding ?? false,
basalBodyTempC: d.basalBodyTempC ?? null,
temperatureExcluded: d.temperatureExcluded ?? false,
ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS),
cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS),
cervixPosition: enumOrNull(d.cervixPosition, CERVIX_POSITIONS),
cervixFirmness: enumOrNull(d.cervixFirmness, CERVIX_FIRMNESSES),
cervixOpening: enumOrNull(d.cervixOpening, CERVIX_OPENINGS),
sexualActivity: d.sexualActivity ?? false,
protectedSex: d.protectedSex ?? null,
pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS),
progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS),
contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES),
// Both ciphertext envelopes written back verbatim — never re-encrypted.
sensitiveEncrypted: d.sensitiveEncrypted ?? null,
notesEncrypted: d.notesEncrypted ?? null,
source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL",
externalId: d.externalId ?? null,
tz: d.tz ?? null,
syncVersion: d.syncVersion ?? 0,
deletedAt: d.deletedAt ? new Date(d.deletedAt) : null,
...(d.createdAt ? { createdAt: new Date(d.createdAt) } : {}),
...(d.updatedAt ? { updatedAt: new Date(d.updatedAt) } : {}),
...(symptomLinks.length > 0
? { symptomLinks: { create: symptomLinks } }
: {}),
},
});
}
return {
cycles: cycles.count,
cycleDayLogs: dayLogs.count,
cycleProfile: profile.count,
};
}