mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(backup): a retired catalogue key must not cost the whole file
Three of the restore's lookups resolve a key against a seeded catalogue: cycle symptoms, illness symptoms, mood factors. Each threw on a key it could not resolve, inside the one transaction that wipes and rebuilds the account. So a single renamed symptom rolled everything back and answered 500. No measurements, no doses, no notes, nothing. A year-old backup read by a newer instance is the ordinary case for a backup, and the catalogue drifts between those two dates by design. The unresolvable link is dropped and named now. What it costs is one association between a row and a symptom; the day-log, the mood entry, the flow, the temperature and the encrypted note all come back. The count and the exact keys ride the response, the audit row and the wide event, and the admin surface shows them in a panel that stays put instead of a toast that leaves. A quiet drop would be the older failure of this same seam wearing a success message. A dangling `cycleId` or a dose recorded against a medication the file never carried still throws. Those name rows the file was supposed to hold, so a missing one means the file contradicts itself. A catalogue key names a row the instance owns, which is why it gets the other answer. `IllnessSymptom` carries no owner column at all, so a key that will not resolve there can only ever be drift; the file was never able to supply it. The restore does not mint the missing catalogue row back. That would preserve the link at the price of reference data colliding with whatever the next migration seeds under that key, and a label nothing can translate.
This commit is contained in:
1 parent
1156839d73
commit
9c4235b66d
13 files changed
+824
-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);
|
||||
|
||||
@@ -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<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");
|
||||
}
|
||||
|
||||
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<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 +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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<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(skipped.links),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.restoreSkippedDescription")}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{skipped.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={() => setSkipped(null)}
|
||||
className="min-h-11"
|
||||
>
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : 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