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/__tests__/backups-restore-skip-report.test.tsx b/src/components/admin/__tests__/backups-restore-skip-report.test.tsx new file mode 100644 index 000000000..5a23df5af --- /dev/null +++ b/src/components/admin/__tests__/backups-restore-skip-report.test.tsx @@ -0,0 +1,81 @@ +/** + * The restore's skip report has to reach a human. + * + * The server names every catalogue key it could not resolve, in the response + * and in the audit row, and the integration suite proves that end. This is the + * other end: a report that a person never sees is the same silence the naming + * exists to end, so the panel gets its own coverage rather than riding on the + * fact that a state setter is wired up somewhere. + * + * Pinned here: + * 1. every skipped key is printed, not just a total, + * 2. each key carries its own link count and its catalogue, + * 3. an empty report renders nothing at all — a panel that appears after a + * clean restore trains the operator to ignore it, + * 4. the copy resolves in more than one locale, so a self-hoster running the + * German UI is told the same thing. + * + * Mutation check: total the links instead of listing them (drop the `
+ {t("admin.section.backups.restoreSkippedTitle", { + links: String(report.links), + })} +
++ {t("admin.section.backups.restoreSkippedDescription")} +
+