diff --git a/messages/de.json b/messages/de.json index a5a24f4da..1d8f1da72 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6569,6 +6569,13 @@ "restoreInProgress": "Wiederherstellung läuft…", "restoreSuccess": "Wiederherstellung abgeschlossen", "restoreFailed": "Wiederherstellung fehlgeschlagen", + "restoreSkippedToast": "Wiederherstellung abgeschlossen, {links} Symptomverknüpfungen konnten nicht zurückgeschrieben werden", + "restoreSkippedTitle": "{links} Verknüpfungen wurden nicht wiederhergestellt", + "restoreSkippedDescription": "Die Datei verweist auf Einträge, die diese Installation nicht kennt. Alles andere wurde wiederhergestellt. Dieselbe Liste steht im Audit-Log.", + "restoreSkippedLinks": "{count} Verknüpfungen", + "restoreSkippedCycleSymptom": "Zyklussymptom", + "restoreSkippedIllnessSymptom": "Krankheitssymptom", + "restoreSkippedMoodFactor": "Stimmungsfaktor", "docsLink": "Dokumentation" }, "danger-zone": { diff --git a/messages/en.json b/messages/en.json index a63fa34f2..b44d7e40b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6569,6 +6569,13 @@ "restoreInProgress": "Restoring…", "restoreSuccess": "Restore complete", "restoreFailed": "Restore failed", + "restoreSkippedToast": "Restore complete, {links} symptom links could not be put back", + "restoreSkippedTitle": "{links} links were not restored", + "restoreSkippedDescription": "The file references entries this installation does not know. Everything else was restored. The same list is in the audit log.", + "restoreSkippedLinks": "{count} links", + "restoreSkippedCycleSymptom": "Cycle symptom", + "restoreSkippedIllnessSymptom": "Illness symptom", + "restoreSkippedMoodFactor": "Mood factor", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/es.json b/messages/es.json index 30e8f2fdf..b3f0f4338 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6569,6 +6569,13 @@ "restoreInProgress": "Restaurando...", "restoreSuccess": "Restauración completada", "restoreFailed": "La restauración falló", + "restoreSkippedToast": "Restauración completada, {links} vínculos de síntomas no se pudieron recuperar", + "restoreSkippedTitle": "{links} vínculos no se restauraron", + "restoreSkippedDescription": "El archivo hace referencia a entradas que esta instalación no conoce. Todo lo demás se restauró. La misma lista está en el registro de auditoría.", + "restoreSkippedLinks": "{count} vínculos", + "restoreSkippedCycleSymptom": "Síntoma del ciclo", + "restoreSkippedIllnessSymptom": "Síntoma de enfermedad", + "restoreSkippedMoodFactor": "Factor de ánimo", "docsLink": "Documentación" }, "danger-zone": { diff --git a/messages/fr.json b/messages/fr.json index fde24ba37..160b2706a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6569,6 +6569,13 @@ "restoreInProgress": "Restauration en cours...", "restoreSuccess": "Restauration terminée", "restoreFailed": "Échec de la restauration", + "restoreSkippedToast": "Restauration terminée, {links} liens de symptômes n'ont pas pu être rétablis", + "restoreSkippedTitle": "{links} liens n'ont pas été restaurés", + "restoreSkippedDescription": "Le fichier référence des entrées que cette installation ne connaît pas. Tout le reste a été restauré. La même liste figure dans le journal d'audit.", + "restoreSkippedLinks": "{count} liens", + "restoreSkippedCycleSymptom": "Symptôme de cycle", + "restoreSkippedIllnessSymptom": "Symptôme de maladie", + "restoreSkippedMoodFactor": "Facteur d'humeur", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/it.json b/messages/it.json index fc5f686ab..8713772d7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6569,6 +6569,13 @@ "restoreInProgress": "Ripristino in corso...", "restoreSuccess": "Ripristino completato", "restoreFailed": "Ripristino non riuscito", + "restoreSkippedToast": "Ripristino completato, {links} collegamenti ai sintomi non sono stati recuperati", + "restoreSkippedTitle": "{links} collegamenti non sono stati ripristinati", + "restoreSkippedDescription": "Il file fa riferimento a voci che questa installazione non conosce. Tutto il resto è stato ripristinato. Lo stesso elenco è nel registro di audit.", + "restoreSkippedLinks": "{count} collegamenti", + "restoreSkippedCycleSymptom": "Sintomo del ciclo", + "restoreSkippedIllnessSymptom": "Sintomo di malattia", + "restoreSkippedMoodFactor": "Fattore dell'umore", "docsLink": "Documentazione" }, "danger-zone": { diff --git a/messages/pl.json b/messages/pl.json index f34d27c7c..7c9cfb017 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6569,6 +6569,13 @@ "restoreInProgress": "Przywracanie...", "restoreSuccess": "Przywracanie zakończone", "restoreFailed": "Przywracanie nie powiodło się", + "restoreSkippedToast": "Przywracanie zakończone, {links} powiązań objawów nie udało się odtworzyć", + "restoreSkippedTitle": "{links} powiązań nie zostało przywróconych", + "restoreSkippedDescription": "Plik odwołuje się do wpisów, których ta instalacja nie zna. Wszystko pozostałe zostało przywrócone. Ta sama lista znajduje się w dzienniku audytu.", + "restoreSkippedLinks": "{count} powiązań", + "restoreSkippedCycleSymptom": "Objaw cyklu", + "restoreSkippedIllnessSymptom": "Objaw choroby", + "restoreSkippedMoodFactor": "Czynnik nastroju", "docsLink": "Dokumentacja" }, "danger-zone": { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index f83ef5a35..8afd9e765 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -38,6 +38,12 @@ import { } from "@/lib/rollups/medication-compliance-rollups"; import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; +import { + recordUnknownKeys, + summarizeRestoreSkips, + type RestoreSkipLog, + type RestoreSkipSummary, +} from "@/lib/export/restore-skips"; import { restoreProfileData } from "@/lib/export/profile-backup"; import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; @@ -48,6 +54,14 @@ export const dynamic = "force-dynamic"; interface RestoreResponse { restored: true; summary: BackupSummary; + /** + * What the file carried that this instance could not resolve, named. + * + * `links: 0` with an empty list is the normal answer and says so: nothing + * was dropped. Anything else has to reach the operator's screen, because a + * restore that quietly loses links looks exactly like one that did not. + */ + skipped: RestoreSkipSummary; cleared: { measurements: number; medications: number; @@ -276,10 +290,18 @@ const handler = apiHandler( ); } - let cleared: RestoreResponse["cleared"]; + let outcome: { + cleared: RestoreResponse["cleared"]; + skipped: RestoreSkipSummary; + }; try { - cleared = await prisma.$transaction( + outcome = await prisma.$transaction( async (tx) => { + // Declared INSIDE the transaction so a rollback takes the report with + // it. An accumulator that outlived a failed attempt would carry drops + // that never happened into the next one. + const skips: RestoreSkipLog = []; + // Delete every serialized owner-scoped partition before rebuilding it // in the same transaction. Child rows either go first for counts or // cascade from their serialized parent. @@ -631,13 +653,10 @@ const handler = apiHandler( } if (payload.moodEntries.length > 0) { - const factorKeys = [ - ...new Set( - payload.moodEntries.flatMap((entry) => - entry.factors.map((factor) => factor.key), - ), - ), - ]; + const referencedFactorKeys = payload.moodEntries.flatMap((entry) => + entry.factors.map((factor) => factor.key), + ); + const factorKeys = [...new Set(referencedFactorKeys)]; // Re-create the account's own tag definitions first. The lookup // below used to ask only for the seeded catalogue (`userId: null`), // so a single custom rated tag made the whole restore throw @@ -681,12 +700,18 @@ const handler = apiHandler( const factorByKey = new Map( factorRows.map((factor) => [factor.key, factor.id]), ); - if (factorByKey.size !== factorKeys.length) { - const missing = factorKeys.filter((key) => !factorByKey.has(key)); - throw new Error( - `Unknown mood factor keys: ${missing.join(", ")}`, - ); - } + // A rated tag this instance does not know costs the entry's link to + // it, not the entry. The mood, the score, the date, the free tags — + // everything the person logged — comes back; the one rating that + // has nowhere to attach is dropped and named. Refusing the file + // instead meant a renamed seeded tag made the whole account + // unrecoverable. + recordUnknownKeys( + skips, + "moodFactor", + factorKeys.filter((key) => !factorByKey.has(key)), + referencedFactorKeys, + ); for (const entry of payload.moodEntries) { const moodLoggedAt = new Date(entry.loggedAt); @@ -734,14 +759,20 @@ const handler = apiHandler( await tx.moodEntryTagLink.deleteMany({ where: { moodEntryId: restored.id }, }); - if (entry.factors.length > 0) { - await tx.moodEntryTagLink.createMany({ - data: entry.factors.map((factor) => ({ - moodEntryId: restored.id, - moodTagId: factorByKey.get(factor.key)!, - rating: factor.rating, - })), - }); + const factorLinks = entry.factors.flatMap((factor) => { + const moodTagId = factorByKey.get(factor.key); + return moodTagId + ? [ + { + moodEntryId: restored.id, + moodTagId, + rating: factor.rating, + }, + ] + : []; + }); + if (factorLinks.length > 0) { + await tx.moodEntryTagLink.createMany({ data: factorLinks }); } } } @@ -749,7 +780,12 @@ const handler = apiHandler( // v1.15.0 — cycle tables (profile + observed spans + day-logs + // symptom links). Delete-then-recreate, mirroring the contract // above. `notesEncrypted` is restored as ciphertext verbatim. - const cycleCleared = await restoreCycleData(tx, ownerId, payload); + const cycleCleared = await restoreCycleData( + tx, + ownerId, + payload, + skips, + ); // Durable self-context + user-defined metrics. Both ends of this pair // live in `src/lib/export/profile-backup.ts` beside its builder — a @@ -905,15 +941,13 @@ const handler = apiHandler( } } - const symptomKeys = [ - ...new Set( - payload.illnessEpisodes.flatMap((episode) => - episode.dayLogs.flatMap((dayLog) => - dayLog.symptoms.map((symptom) => symptom.key), - ), + const referencedSymptomKeys = payload.illnessEpisodes.flatMap( + (episode) => + episode.dayLogs.flatMap((dayLog) => + dayLog.symptoms.map((symptom) => symptom.key), ), - ), - ]; + ); + const symptomKeys = [...new Set(referencedSymptomKeys)]; const symptomRows = symptomKeys.length === 0 ? [] @@ -924,14 +958,25 @@ const handler = apiHandler( const symptomByKey = new Map( symptomRows.map((symptom) => [symptom.key, symptom.id]), ); - if (symptomByKey.size !== symptomKeys.length) { - const missing = symptomKeys.filter((key) => !symptomByKey.has(key)); - throw new Error( - `Unknown illness symptom keys: ${missing.join(", ")}`, - ); - } + // `IllnessSymptom` has no `userId` column at all — it is a purely + // seeded catalogue, so a key that will not resolve here can ONLY be + // catalogue drift and never a definition the file failed to carry. + // Which makes refusing the file over it the least defensible of the + // three: the file was never able to supply the missing row. + recordUnknownKeys( + skips, + "illnessSymptom", + symptomKeys.filter((key) => !symptomByKey.has(key)), + referencedSymptomKeys, + ); for (const episode of payload.illnessEpisodes) { for (const dayLog of episode.dayLogs) { + const symptomLinks = dayLog.symptoms.flatMap((symptom) => { + const symptomId = symptomByKey.get(symptom.key); + return symptomId + ? [{ symptomId, severity: symptom.severity ?? null }] + : []; + }); await tx.illnessDayLog.create({ data: { ...(dayLog.id ? { id: dayLog.id } : {}), @@ -958,12 +1003,9 @@ const handler = apiHandler( ...(dayLog.updatedAt ? { updatedAt: new Date(dayLog.updatedAt) } : {}), - symptomLinks: { - create: dayLog.symptoms.map((symptom) => ({ - symptomId: symptomByKey.get(symptom.key)!, - severity: symptom.severity ?? null, - })), - }, + ...(symptomLinks.length > 0 + ? { symptomLinks: { create: symptomLinks } } + : {}), }, }); } @@ -1113,7 +1155,7 @@ const handler = apiHandler( }); } - return { + const cleared = { measurements: measurements.count, medications: meds.count, intakeEvents: intake.count, @@ -1140,6 +1182,7 @@ const handler = apiHandler( intradayProfiles: intradayCleared.intradayProfiles, healthScoreRecords: healthScoreCleared.healthScoreRecords, }; + return { cleared, skipped: summarizeRestoreSkips(skips) }; }, { maxWait: 10_000, @@ -1168,6 +1211,19 @@ const handler = apiHandler( return apiError("Restore failed", 500); } + const { cleared, skipped } = outcome; + + // Pinned shape, not free text: a dashboard can alert on + // `restoreSkippedLinks > 0` and the key list says which catalogue drifted. + annotate({ + meta: { + restoreSkippedLinks: skipped.links, + restoreSkippedKeys: skipped.catalogueKeys + .map((entry) => `${entry.catalogue}:${entry.key}`) + .join(","), + }, + }); + // v1.4.39.1 — re-fold the persistent measurement rollup tier from // the just-restored measurements. Pre-fix the restore left the // rollup table empty for the owner, so the dashboard chart's @@ -1247,6 +1303,10 @@ const handler = apiHandler( ownerId, ownerUsername: owner.username, cleared, + // The durable half of the report. The response reaches whoever was + // looking at the screen; the audit row is still here next week when + // someone asks why a day-log lost a symptom. + skipped, restored: { measurements: summary.measurements, medications: summary.medications, @@ -1278,6 +1338,7 @@ const handler = apiHandler( const response: RestoreResponse = { restored: true, summary, + skipped, cleared, }; return apiSuccess(response); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 12b79babc..40fed1154 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -13,6 +13,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + AlertTriangle, BookOpen, Database, Download, @@ -45,6 +46,10 @@ import { ListRow } from "@/components/ui/list-row"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BackupRow, BackupsList } from "@/types/backups"; +import type { + RestoreSkipSummary, + SkippedCatalogue, +} from "@/lib/export/restore-skips"; import { getApiErrorMessage } from "./_shared"; import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch"; @@ -171,6 +176,27 @@ function formatBackupType( return type; } +/** + * Which catalogue a skipped key belonged to, as a label. + * + * Spelled out with literal `t()` calls rather than an interpolated key, so + * `i18n-call-site-coverage.test.ts` can see each one and prove the bundle has + * it. An interpolated key is invisible to that guard and shows up as raw dot + * notation in production the day someone renames a catalogue. + */ +function catalogueLabel( + catalogue: SkippedCatalogue, + t: ReturnType["t"], +) { + if (catalogue === "cycleSymptom") { + return t("admin.section.backups.restoreSkippedCycleSymptom"); + } + if (catalogue === "illnessSymptom") { + return t("admin.section.backups.restoreSkippedIllnessSymptom"); + } + return t("admin.section.backups.restoreSkippedMoodFactor"); +} + export function BackupsSection() { const { t } = useTranslations(); const fmt = useFormatters(); @@ -301,6 +327,12 @@ export function BackupsSection() { if (file) upload.mutate(file); } + // What the last restore could not put back. Held in state rather than left + // to the toast: a toast for "twelve symptom links are gone" disappears in + // four seconds and the operator has no way to get it back. This panel stays + // until they dismiss it. + const [skipped, setSkipped] = useState(null); + // Restore: typed-confirmation dialog. The mutation is keyed by row id // and used inline by `` below — keeping the // mutation here lets the parent invalidate the list query on success. @@ -310,14 +342,28 @@ export function BackupsSection() { // destructive transaction. Include the row id so two different // backups can both be restored independently in the same minute. const idempotencyKey = `restore-${row.id}-${crypto.randomUUID()}`; - return apiPost<{ restored: true }>( + return apiPost<{ restored: true; skipped?: RestoreSkipSummary }>( `/api/admin/backups/${row.id}/restore`, { confirm: "RESTORE" }, { headers: { "Idempotency-Key": idempotencyKey } }, ); }, - onSuccess: () => { - toast.success(t("admin.section.backups.restoreSuccess")); + onSuccess: (data) => { + // A restore that dropped links is not a plain success and must not read + // as one. The count and the exact keys go on screen; the same report is + // in the audit row for anyone asking later. + const report = data.skipped; + if (report && report.links > 0) { + setSkipped(report); + toast.warning( + t("admin.section.backups.restoreSkippedToast", { + links: String(report.links), + }), + ); + } else { + setSkipped(null); + toast.success(t("admin.section.backups.restoreSuccess")); + } queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); // Restore touches every personal-data table; nuke the broader // cache so dashboards / lists rebuild against the new state. @@ -467,6 +513,62 @@ export function BackupsSection() { + {/* The restore's honesty panel. Rendered only after a restore that + actually dropped something, and it names every key rather than + totalling them away — an operator who cannot see WHICH symptom went + missing cannot decide whether it mattered. */} + {skipped && skipped.links > 0 ? ( +
+
+
+
+ +
+
+ ) : null} + {isLoading ? (
diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index efbfbd895..048e52f8c 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -26,6 +26,10 @@ import type { 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 { @@ -375,6 +379,11 @@ function enumOrNull( * 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. */ @@ -382,6 +391,7 @@ export async function restoreCycleData( tx: Prisma.TransactionClient, ownerId: string, payload: BackupPayload, + skips: RestoreSkipLog, ): Promise { // Wipe (child links cascade off the day-log delete). const dayLogs = await tx.cycleDayLog.deleteMany({ @@ -470,9 +480,10 @@ export async function restoreCycleData( } // Resolve the symptom catalogue once for the link re-creation. - const allKeys = Array.from( - new Set(payload.cycleDayLogs.flatMap((d) => d.symptomKeys ?? [])), + const referencedKeys = payload.cycleDayLogs.flatMap( + (d) => d.symptomKeys ?? [], ); + const allKeys = Array.from(new Set(referencedKeys)); const symptomIdByKey = new Map(); if (allKeys.length > 0) { const rows = await tx.cycleSymptom.findMany({ @@ -484,19 +495,18 @@ export async function restoreCycleData( }); for (const r of rows) symptomIdByKey.set(r.key, r.id); - // Three lines below, an unknown CYCLE reference throws. An unknown symptom - // used to be filtered out instead: the day-log came back with one of its - // symptoms quietly missing, and the restore reported success. Same - // function, same class of missing reference, two different answers — so - // this one is loud now too. + // 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)); - if (unresolved.length > 0) { - throw new Error( - `Unknown cycle symptom keys: ${unresolved.join(", ")}. ` + - "The backup references symptoms that are neither in this instance's " + - "seeded catalogue nor carried in the file.", - ); - } + recordUnknownKeys(skips, "cycleSymptom", unresolved, referencedKeys); } // Recreate day-logs (with symptom links). The owning cycle is the latest @@ -524,13 +534,15 @@ export async function restoreCycleData( if (severity !== null) severityByKey.set(s.key, severity); } - // Every key was proven resolvable above, so this maps rather than filters: - // a `.filter(Boolean)` here would silently re-open the hole that check - // just closed. - const symptomLinks = (d.symptomKeys ?? []).map((k) => { + // 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); - if (!id) throw new Error(`Unresolved cycle symptom key: ${k}`); - return { symptomId: id, severity: severityByKey.get(k) ?? null }; + return id + ? [{ symptomId: id, severity: severityByKey.get(k) ?? null }] + : []; }); const cycleId = d.cycleId !== undefined diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts new file mode 100644 index 000000000..e57756edd --- /dev/null +++ b/src/lib/export/restore-skips.ts @@ -0,0 +1,89 @@ +/** + * What a restore could not put back, and how much of it there was. + * + * Three of the restore's lookups resolve a key against a SEEDED CATALOGUE — + * cycle symptoms, illness symptoms, mood factors. The catalogue is reference + * data the instance owns, not data the file carries, so it legitimately drifts + * between the day a backup is written and the day it is read: a key gets + * renamed, a symptom is retired, the file is a year old, the instance is three + * releases newer. That is the ordinary case for a backup, not a corrupt file. + * + * Each of those lookups used to throw, which rolled the whole transaction back + * and answered 500 — one renamed symptom and the operator got none of the + * account back. Before that they filtered the unresolvable key away and + * reported success, which lost the same link with no trace at all. Neither is + * honest: the first destroys a recoverable restore, the second hides a real + * loss. + * + * So the unresolvable link is dropped and NAMED. This module is the naming. + * Every drop lands here, and the accumulated report rides the restore response, + * the audit row, and the wide event, so the count and the exact keys reach the + * person who ran the restore instead of a log line nobody reads. + * + * What is dropped is deliberately narrow: one (row, symptom) association. The + * day-log, the mood entry, the note, the flow, the temperature — everything the + * row itself holds — comes back. A link table is the only place this applies, + * because it is the only place where a value that will not resolve costs an + * edge rather than a record. + */ + +/** Which seeded catalogue a key failed to resolve against. */ +export type SkippedCatalogue = "cycleSymptom" | "illnessSymptom" | "moodFactor"; + +/** One key this instance does not know, and the links it cost. */ +export interface SkippedCatalogueKey { + catalogue: SkippedCatalogue; + /** The key exactly as the file wrote it. Reported verbatim so an operator + * can grep the file for it and see which days it was on. */ + key: string; + /** How many links referenced it — twelve day-logs is twelve, not one. */ + links: number; +} + +/** Mutable accumulator threaded through one restore transaction. */ +export type RestoreSkipLog = SkippedCatalogueKey[]; + +/** The report shape carried by the response, the audit row, and the UI. */ +export interface RestoreSkipSummary { + /** Distinct unknown keys, ordered by catalogue then key. */ + catalogueKeys: SkippedCatalogueKey[]; + /** Total links dropped across every catalogue. Zero means nothing was lost. */ + links: number; +} + +/** + * Record the keys a catalogue lookup could not resolve. + * + * `referenced` is the FLAT list of keys the file used, one entry per link, not + * the deduplicated set the lookup queried with. A key that appears on twelve + * day-logs cost twelve links, and reporting it as one would understate the loss + * by a factor of twelve — which is the quiet-drop failure again, wearing a + * number. + */ +export function recordUnknownKeys( + log: RestoreSkipLog, + catalogue: SkippedCatalogue, + unresolved: readonly string[], + referenced: readonly string[], +): void { + for (const key of unresolved) { + log.push({ + catalogue, + key, + links: referenced.filter((candidate) => candidate === key).length, + }); + } +} + +/** Fold the accumulator into the report the callers surface. */ +export function summarizeRestoreSkips(log: RestoreSkipLog): RestoreSkipSummary { + const catalogueKeys = [...log].sort((a, b) => + a.catalogue === b.catalogue + ? a.key.localeCompare(b.key) + : a.catalogue.localeCompare(b.catalogue), + ); + return { + catalogueKeys, + links: catalogueKeys.reduce((total, entry) => total + entry.links, 0), + }; +} diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index 56d6f9687..50c4509c2 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -1074,6 +1074,20 @@ describe("canonical disaster-recovery backup round-trip", () => { expect(invalidateUserData).not.toHaveBeenCalled(); }); + /** + * The contract here is the rollback, not the trigger. The trigger used to be + * a mood factor key the instance did not know, and that is no longer a + * failure at all: a seeded catalogue legitimately drifts between the day a + * backup is written and the day it is read, so an unresolvable catalogue key + * now drops one link and names it in the response rather than costing the + * whole file. + * + * An intake event naming a medication the file does not carry is a different + * animal and still throws. That reference points at a row THIS FILE was + * supposed to contain, so a dangling one means the file contradicts itself, + * and writing a dose history against a medication that does not exist is not + * a recoverable restore. It is the honest trigger for the rollback contract. + */ it("rolls back a failed full restore before invalidating caches", async () => { const prisma = getPrismaClient(); const admin = await seedAdminSession(); @@ -1092,6 +1106,15 @@ describe("canonical disaster-recovery backup round-trip", () => { exportedAt: "2026-07-04T08:00:00.000Z", userId: admin.id, measurements: [], + medications: [], + intakeEvents: [ + { + id: "failed-restore-intake", + medication: "a medication this file never carried", + scheduledFor: "2026-07-04T08:00:00.000Z", + takenAt: "2026-07-04T08:05:00.000Z", + }, + ], moodEntries: [ { id: "failed-restore-mood", @@ -1100,7 +1123,7 @@ describe("canonical disaster-recovery backup round-trip", () => { score: 3, tags: null, loggedAt: "2026-07-04T08:00:00.000Z", - factors: [{ key: "missing-backup-factor", rating: 4 }], + factors: [], }, ], }); diff --git a/tests/integration/backup-unknown-catalogue-key-restore.test.ts b/tests/integration/backup-unknown-catalogue-key-restore.test.ts new file mode 100644 index 000000000..1a1082a28 --- /dev/null +++ b/tests/integration/backup-unknown-catalogue-key-restore.test.ts @@ -0,0 +1,419 @@ +/** + * A catalogue key the instance no longer knows must not cost the file. + * + * Three of the restore's lookups resolve a key against a seeded catalogue — + * cycle symptoms, illness symptoms, mood factors. Each of them used to throw on + * a key it could not resolve, and the throw was inside the one transaction that + * wipes and rebuilds the account. So one retired symptom rolled everything back: + * no measurements, no doses, no notes, no cycle history, HTTP 500. A backup a + * year old, restored onto an instance three releases newer, is the ordinary + * case for a backup, not a corrupt file. + * + * The link is dropped and reported now. This test proves both halves against + * the REAL admin restore route and against ROWS, never against a helper's + * return value: + * + * - everything else in the file lands (the measurement is the load-bearing + * assertion — it is the data the old behaviour destroyed to protect a + * symptom chip), + * - the surviving symptom keeps its link, + * - the retired key's link is gone AND named in the response, with the count, + * - the retired key is not resurrected in the catalogue: a restore does not + * get to invent reference data on the instance's behalf. + * + * Every owner row is deleted before the restore runs. That is the vacuousness + * guard, not tidiness: leave the fixture in place and a restore that did + * nothing at all would still find the rows sitting there and pass. + * + * Mutation check: put the `throw new Error("Unknown cycle symptom keys: …")` + * back in `restoreCycleData` and this test fails on `expected 500 to be 200`. + * Excise the whole `prisma.$transaction` body and it fails on the measurement + * that never came back. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { invalidateUserData } from "@/lib/cache/invalidate"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +/** + * Catalogue rows the fixture owns, so the test never mutates the migration + * seed other tests in the same container depend on. `userId: null` on the two + * cycle rows is what makes them CATALOGUE rows: the payload builder carries + * only `where: { userId }` symptoms in `customSymptoms`, so a NULL-owner key + * the instance drops afterwards has nothing in the file that could put it back. + * That is exactly the drift being modelled. + */ +const KEPT_CYCLE_KEY = "test:cycle-symptom-that-stays"; +const RETIRED_CYCLE_KEY = "test:cycle-symptom-retired-later"; +const KEPT_ILLNESS_KEY = "test:illness-symptom-that-stays"; +const RETIRED_ILLNESS_KEY = "test:illness-symptom-retired-later"; +const KEPT_MOOD_KEY = "test:mood-factor-that-stays"; +const RETIRED_MOOD_KEY = "test:mood-factor-retired-later"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + vi.mocked(invalidateUserData).mockClear(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +describe("a backup naming a catalogue key this instance retired", () => { + it("restores the file, drops only the unresolvable links, and names them", async () => { + const prisma = getPrismaClient(); + + // Owner and operator are the same account so the fixture stays about the + // catalogue rather than about the ownership guard. + const owner = await prisma.user.create({ + data: { + username: "catalogue-drift-owner", + email: "catalogue-drift-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + /* ── the catalogues, before the drift ──────────────────────────────── */ + + const cycleCategory = await prisma.cycleSymptomCategory.findFirstOrThrow(); + const keptCycleSymptom = await prisma.cycleSymptom.create({ + data: { + categoryId: cycleCategory.id, + key: KEPT_CYCLE_KEY, + labelKey: "cycle.symptom.kept", + sortOrder: 1, + }, + }); + const retiredCycleSymptom = await prisma.cycleSymptom.create({ + data: { + categoryId: cycleCategory.id, + key: RETIRED_CYCLE_KEY, + labelKey: "cycle.symptom.retired", + sortOrder: 2, + }, + }); + + const keptIllnessSymptom = await prisma.illnessSymptom.create({ + data: { key: KEPT_ILLNESS_KEY, labelKey: "illness.symptom.kept" }, + }); + const retiredIllnessSymptom = await prisma.illnessSymptom.create({ + data: { key: RETIRED_ILLNESS_KEY, labelKey: "illness.symptom.retired" }, + }); + + const moodCategory = await prisma.moodTagCategory.findFirstOrThrow({ + where: { userId: null }, + }); + const keptMoodTag = await prisma.moodTag.create({ + data: { + categoryId: moodCategory.id, + key: KEPT_MOOD_KEY, + labelKey: "mood.factor.kept", + kind: "RATED", + scaleMin: 1, + scaleMax: 5, + }, + }); + const retiredMoodTag = await prisma.moodTag.create({ + data: { + categoryId: moodCategory.id, + key: RETIRED_MOOD_KEY, + labelKey: "mood.factor.retired", + kind: "RATED", + scaleMin: 1, + scaleMax: 5, + }, + }); + + /* ── the account's data ────────────────────────────────────────────── */ + + // The measurement carries no catalogue reference at all, which is the + // point: it is what the whole-file refusal was destroying. + await prisma.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 81.4, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-07-03T06:30:00.000Z"), + }, + }); + + const cycle = await prisma.menstrualCycle.create({ + data: { + userId: owner.id, + startDate: "2026-07-01", + periodEndDate: "2026-07-05", + tz: "Europe/Berlin", + }, + }); + await prisma.cycleDayLog.create({ + data: { + userId: owner.id, + cycleId: cycle.id, + date: "2026-07-03", + flow: "MEDIUM", + source: "MANUAL", + tz: "Europe/Berlin", + symptomLinks: { + create: [ + { symptomId: keptCycleSymptom.id, severity: 2 }, + { symptomId: retiredCycleSymptom.id, severity: 4 }, + ], + }, + }, + }); + + // A SECOND day carrying the retired symptom. The report has to count links, + // not distinct keys: one key across two days is two lost links, and + // reporting it as one understates the loss by half. + await prisma.cycleDayLog.create({ + data: { + userId: owner.id, + cycleId: cycle.id, + date: "2026-07-04", + flow: "LIGHT", + source: "MANUAL", + tz: "Europe/Berlin", + symptomLinks: { create: [{ symptomId: retiredCycleSymptom.id }] }, + }, + }); + + const episode = await prisma.illnessEpisode.create({ + data: { + userId: owner.id, + label: "Summer cold", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: new Date("2026-07-02T00:00:00.000Z"), + dayLogs: { + create: { + userId: owner.id, + date: "2026-07-03", + functionalImpact: 2, + symptomLinks: { + create: [ + { symptomId: keptIllnessSymptom.id, severity: 1 }, + { symptomId: retiredIllnessSymptom.id, severity: 3 }, + ], + }, + }, + }, + }, + }); + + const moodEntry = await prisma.moodEntry.create({ + data: { + userId: owner.id, + date: "2026-07-03", + mood: "GOOD", + score: 4, + source: "MOODLOG", + moodLoggedAt: new Date("2026-07-03T19:00:00.000Z"), + tagLinks: { + create: [ + { moodTagId: keptMoodTag.id, rating: 3 }, + { moodTagId: retiredMoodTag.id, rating: 5 }, + ], + }, + }, + }); + + /* ── the real writer, the real wire format, the real reader ────────── */ + + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(JSON.stringify(built)); + const backup = await prisma.dataBackup.create({ + data: { + userId: owner.id, + type: "CATALOGUE_DRIFT_ROUND_TRIP", + data: encrypt(JSON.stringify(parsed)), + }, + }); + + /* ── the drift: three keys the instance stops carrying ─────────────── */ + + await prisma.cycleSymptom.delete({ where: { id: retiredCycleSymptom.id } }); + await prisma.illnessSymptom.delete({ + where: { id: retiredIllnessSymptom.id }, + }); + await prisma.moodTag.delete({ where: { id: retiredMoodTag.id } }); + + // Every owner row goes before the restore runs, so nothing below can pass + // on a row the fixture left behind. A restore that did nothing at all has + // to fail this test. + await prisma.measurement.deleteMany({ where: { userId: owner.id } }); + await prisma.cycleDayLog.deleteMany({ where: { userId: owner.id } }); + await prisma.menstrualCycle.deleteMany({ where: { userId: owner.id } }); + await prisma.illnessEpisode.deleteMany({ where: { userId: owner.id } }); + await prisma.moodEntry.deleteMany({ where: { userId: owner.id } }); + expect( + await prisma.measurement.count({ where: { userId: owner.id } }), + ).toBe(0); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + // The whole file came back. Pre-fix this was 500 and the account was empty. + expect(response.status).toBe(200); + + /* ── what actually landed in the database ──────────────────────────── */ + + // The data the old behaviour threw away to protect a symptom chip. + const measurements = await prisma.measurement.findMany({ + where: { userId: owner.id }, + select: { type: true, value: true, unit: true }, + }); + expect(measurements).toEqual([{ type: "WEIGHT", value: 81.4, unit: "kg" }]); + + const restoredDayLogs = await prisma.cycleDayLog.findMany({ + where: { userId: owner.id }, + orderBy: { date: "asc" }, + include: { symptomLinks: { include: { symptom: true } } }, + }); + // Both days come back, including the one whose ONLY symptom was retired. + // A day-log stripped of its last link is still the day the person logged: + // the flow, the timezone, the note are all still theirs. + expect(restoredDayLogs.map((d) => d.flow)).toEqual(["MEDIUM", "LIGHT"]); + // The surviving symptom keeps its link AND its intensity; the retired one + // is simply not there. + expect( + restoredDayLogs[0].symptomLinks.map((link) => ({ + key: link.symptom.key, + severity: link.severity, + })), + ).toEqual([{ key: KEPT_CYCLE_KEY, severity: 2 }]); + expect(restoredDayLogs[1].symptomLinks).toEqual([]); + + const restoredIllnessDayLog = await prisma.illnessDayLog.findFirstOrThrow({ + where: { userId: owner.id }, + include: { symptomLinks: { include: { symptom: true } } }, + }); + expect(restoredIllnessDayLog.episodeId).toBe(episode.id); + expect(restoredIllnessDayLog.functionalImpact).toBe(2); + expect( + restoredIllnessDayLog.symptomLinks.map((link) => ({ + key: link.symptom.key, + severity: link.severity, + })), + ).toEqual([{ key: KEPT_ILLNESS_KEY, severity: 1 }]); + + const restoredMood = await prisma.moodEntry.findFirstOrThrow({ + where: { userId: owner.id }, + include: { tagLinks: { include: { moodTag: true } } }, + }); + expect(restoredMood.id).toBe(moodEntry.id); + expect(restoredMood.score).toBe(4); + expect( + restoredMood.tagLinks.map((link) => ({ + key: link.moodTag.key, + rating: link.rating, + })), + ).toEqual([{ key: KEPT_MOOD_KEY, rating: 3 }]); + + /* ── what the operator is told ─────────────────────────────────────── */ + + const body = (await response.json()) as { + data: { + skipped: { + links: number; + catalogueKeys: Array<{ + catalogue: string; + key: string; + links: number; + }>; + }; + }; + }; + // Four links across three keys — the cycle symptom was on two days. + expect(body.data.skipped.links).toBe(4); + expect(body.data.skipped.catalogueKeys).toEqual([ + { catalogue: "cycleSymptom", key: RETIRED_CYCLE_KEY, links: 2 }, + { catalogue: "illnessSymptom", key: RETIRED_ILLNESS_KEY, links: 1 }, + { catalogue: "moodFactor", key: RETIRED_MOOD_KEY, links: 1 }, + ]); + + // The durable half. A screen gets closed; the audit row is still here. + const auditRow = await prisma.auditLog.findFirstOrThrow({ + where: { action: "admin.backups.restore" }, + orderBy: { createdAt: "desc" }, + }); + // `AuditLog.details` is a JSON string column, not a Json column. + const auditDetails = JSON.parse(auditRow.details ?? "{}") as { + skipped?: { + links?: number; + catalogueKeys?: Array<{ key: string }>; + }; + }; + expect(auditDetails.skipped?.links).toBe(4); + expect(auditDetails.skipped?.catalogueKeys?.map((e) => e.key)).toEqual([ + RETIRED_CYCLE_KEY, + RETIRED_ILLNESS_KEY, + RETIRED_MOOD_KEY, + ]); + + // A restore reads reference data; it does not get to write it. Minting the + // retired keys back into the catalogue would preserve the links at the cost + // of a row that collides with whatever the next migration seeds under that + // key, and of a label nothing in the app can translate. + expect( + await prisma.cycleSymptom.count({ where: { key: RETIRED_CYCLE_KEY } }), + ).toBe(0); + expect( + await prisma.illnessSymptom.count({ + where: { key: RETIRED_ILLNESS_KEY }, + }), + ).toBe(0); + expect( + await prisma.moodTag.count({ where: { key: RETIRED_MOOD_KEY } }), + ).toBe(0); + }); +}); diff --git a/tests/integration/cycle-import-export.test.ts b/tests/integration/cycle-import-export.test.ts index b0060a518..73eed32e2 100644 --- a/tests/integration/cycle-import-export.test.ts +++ b/tests/integration/cycle-import-export.test.ts @@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export"; import { buildCycleBackupSection, restoreCycleData } from "@/lib/cycle/backup"; +import type { RestoreSkipLog } from "@/lib/export/restore-skips"; import { parseBackupPayload } from "@/lib/validations/backup"; process.env.ENCRYPTION_KEY ??= @@ -219,9 +220,14 @@ describe("cycle backup — round-trips through build + restore", () => { }); // Restore into a clean transaction (wipes then recreates). + const skips: RestoreSkipLog = []; await prisma.$transaction(async (tx) => { - await restoreCycleData(tx, USER_ID, payload); + await restoreCycleData(tx, USER_ID, payload, skips); }); + // A file whose every symptom resolves reports nothing skipped. Absence + // reading as absence cuts both ways: the report has to stay empty when + // nothing was lost, or an operator learns to ignore it. + expect(skips).toEqual([]); const restored = await prisma.cycleDayLog.findMany({ where: { userId: USER_ID },