mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #685 from MBombeck/fix/restore-unknown-symptom-key
Let a restore survive a catalogue key that no longer exists
This commit is contained in:
14 files changed
+927
-70
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 `<ul>`) and
|
||||
* cases 1 and 2 go red; return the panel unconditionally and case 3 goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { RestoreSkipReport } from "@/components/admin/backups-section";
|
||||
import type { RestoreSkipSummary } from "@/lib/export/restore-skips";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/backups",
|
||||
}));
|
||||
|
||||
const REPORT: RestoreSkipSummary = {
|
||||
links: 4,
|
||||
catalogueKeys: [
|
||||
{ catalogue: "cycleSymptom", key: "cs.retired_symptom", links: 2 },
|
||||
{ catalogue: "illnessSymptom", key: "ill.retired_symptom", links: 1 },
|
||||
{ catalogue: "moodFactor", key: "mood.retired_factor", links: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
function render(report: RestoreSkipSummary, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<RestoreSkipReport report={report} onDismiss={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RestoreSkipReport", () => {
|
||||
it("names every skipped key rather than only the total", () => {
|
||||
const html = render(REPORT);
|
||||
expect(html).toContain('data-slot="restore-skip-report"');
|
||||
for (const entry of REPORT.catalogueKeys) {
|
||||
expect(html).toContain(entry.key);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives each key its own link count and catalogue", () => {
|
||||
const html = render(REPORT);
|
||||
// The cycle key cost two links and the other two cost one each. A panel
|
||||
// that printed the grand total against every row would read as six.
|
||||
expect(html).toContain("2 links");
|
||||
expect(html).toContain("1 links");
|
||||
expect(html).toContain("Cycle symptom");
|
||||
expect(html).toContain("Illness symptom");
|
||||
expect(html).toContain("Mood factor");
|
||||
expect(html).toContain("4 links were not restored");
|
||||
});
|
||||
|
||||
it("renders nothing when the restore dropped nothing", () => {
|
||||
expect(render({ links: 0, catalogueKeys: [] })).toBe("");
|
||||
});
|
||||
|
||||
it("speaks the operator's language", () => {
|
||||
const german = render(REPORT, "de");
|
||||
expect(german).toContain("4 Verknüpfungen wurden nicht wiederhergestellt");
|
||||
expect(german).toContain("Zyklussymptom");
|
||||
expect(german).toContain("cs.retired_symptom");
|
||||
});
|
||||
});
|
||||
@@ -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,98 @@ 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<typeof useTranslations>["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");
|
||||
}
|
||||
|
||||
/**
|
||||
* What the last restore could not put back.
|
||||
*
|
||||
* Its own component, and exported, so it can be rendered against a report in a
|
||||
* test instead of only through a state transition the SSR smoke tests here
|
||||
* cannot drive. A report that reaches the response and never reaches the screen
|
||||
* is the same silence the reporting exists to end, so the render is worth
|
||||
* proving on its own.
|
||||
*
|
||||
* It names every key rather than totalling them away: an operator who cannot
|
||||
* see WHICH symptom went missing cannot decide whether it mattered.
|
||||
*/
|
||||
export function RestoreSkipReport({
|
||||
report,
|
||||
onDismiss,
|
||||
}: {
|
||||
report: RestoreSkipSummary;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (report.links === 0) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-slot="restore-skip-report"
|
||||
className="border-warning/40 bg-warning/10 mt-4 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle
|
||||
className="mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("admin.section.backups.restoreSkippedTitle", {
|
||||
links: String(report.links),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.restoreSkippedDescription")}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{report.catalogueKeys.map((entry) => (
|
||||
<li key={`${entry.catalogue}:${entry.key}`} className="text-xs">
|
||||
<span className="font-mono">{entry.key}</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{catalogueLabel(entry.catalogue, t)}
|
||||
{" · "}
|
||||
{t("admin.section.backups.restoreSkippedLinks", {
|
||||
count: String(entry.links),
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDismiss}
|
||||
className="min-h-11"
|
||||
>
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackupsSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -301,6 +398,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<RestoreSkipSummary | null>(null);
|
||||
|
||||
// Restore: typed-confirmation dialog. The mutation is keyed by row id
|
||||
// and used inline by `<RestoreRowDialog>` below — keeping the
|
||||
// mutation here lets the parent invalidate the list query on success.
|
||||
@@ -310,14 +413,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 +584,13 @@ export function BackupsSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{skipped ? (
|
||||
<RestoreSkipReport
|
||||
report={skipped}
|
||||
onDismiss={() => setSkipped(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
+32
-20
@@ -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<T extends string>(
|
||||
* 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<CycleRestoreCleared> {
|
||||
// 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<string, string>();
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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<typeof POST>[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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user