From 92da02f824ff147182a9b6dbf313478d43f79a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 07:37:48 +0200 Subject: [PATCH] fix(backup): carry how hard a symptom hit through export and restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cycle symptom can be recorded with a 1-4 intensity. The log-day sheet writes it, the day-log reads it back, and the backup never carried it: the file held the symptom keys and nothing else. Export a day recorded as "cramps, and they were a 4", restore it, and you get "cramps". The symptom is there, the number is gone, and nothing fails, which is why nobody noticed. The file now carries `symptomSeverities` alongside `symptomKeys` — one entry per symptom that was actually rated. `symptomKeys` still decides which symptoms a day had, so a release that only knows that field reads every symptom out of a new file unchanged, and the two lists can never disagree about presence. A link nobody rated is simply not listed: no entry means never rated, which is not the same as rated zero. A file written before this restores exactly as it did, with every intensity absent rather than invented. An intensity outside 1-4, from a hand-edited file or some later scale, costs that one number and leaves the symptom and the rest of the restore alone. --- src/lib/cycle/__tests__/backup.test.ts | 11 +- src/lib/cycle/backup.ts | 70 +++- src/lib/validations/backup.ts | 21 +- ...kup-cycle-symptom-severity-restore.test.ts | 317 ++++++++++++++++++ 4 files changed, 407 insertions(+), 12 deletions(-) create mode 100644 tests/integration/backup-cycle-symptom-severity-restore.test.ts diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts index 7a0166fd1..2bcc923ec 100644 --- a/src/lib/cycle/__tests__/backup.test.ts +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -87,7 +87,11 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { deletedAt, createdAt, updatedAt, - symptomLinks: [{ symptom: { key: "cramps" } }], + // One symptom the person put a number on, one they did not. + symptomLinks: [ + { severity: 4, symptom: { key: "cramps" } }, + { severity: null, symptom: { key: "fatigue" } }, + ], }, ]), }, @@ -160,7 +164,10 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { deletedAt: deletedAt.toISOString(), createdAt: createdAt.toISOString(), updatedAt: updatedAt.toISOString(), - symptomKeys: ["cramps"], + symptomKeys: ["cramps", "fatigue"], + // Only the rated link is listed. An unrated one is left out rather than + // written as a zero, so the file says "never rated" and not "rated none". + symptomSeverities: [{ key: "cramps", severity: 4 }], }); }); }); diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index ce511a02c..efbfbd895 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -90,7 +90,30 @@ export interface CycleBackupSection { deletedAt?: string | null; createdAt?: string; updatedAt?: string; + /** + * Which symptoms the day carries. The single presence list — one entry per + * link, nothing else decides whether a symptom was on the day. + */ symptomKeys: string[]; + /** + * How hard each of those symptoms hit, where the person said so. + * + * `CycleSymptomLink.severity` is a 1-4 Likert the log-day sheet writes and + * the day-log DTO reads back, and the backup carried only the keys — so a + * day recorded as "cramps, and they were a 4" came back as "cramps" and the + * 4 was gone. Nothing failed; the number was simply not in the file. + * + * Kept as a SPARSE annotation over `symptomKeys` rather than folded into + * it, for two reasons. A release that only knows `symptomKeys` still reads + * every symptom out of a file written here, instead of finding the key it + * looks for replaced by one it does not know and restoring a day with no + * symptoms at all. And presence stays decided in exactly one place, so the + * two lists cannot disagree about which symptoms a day had. + * + * A link with no severity is simply not listed. No entry means the person + * never rated it — which is not the same as rating it zero. + */ + symptomSeverities: Array<{ key: string; severity: number }>; }>; /** * The account's OWN symptom definitions. @@ -231,6 +254,9 @@ export async function buildCycleBackupSection( externalId: d.externalId, tz: d.tz, symptomKeys: d.symptomLinks.map((l) => l.symptom.key), + symptomSeverities: d.symptomLinks + .filter((l): l is typeof l & { severity: number } => l.severity != null) + .map((l) => ({ key: l.symptom.key, severity: l.severity })), })), customSymptoms: customSymptoms.map((sym) => ({ ...(disasterRecovery ? { id: sym.id } : {}), @@ -313,6 +339,25 @@ const CYCLE_GOALS = new Set([ "OFF", ]); +/** + * A symptom severity is the 1-4 Likert the log-day sheet writes, or nothing. + * + * Anything else — a value from a hand-edited file, a wider scale some later + * release might use, a null — reads as "not rated". It is dropped rather than + * clamped or rounded into range, because a severity nobody recorded is absent, + * and inventing a 1 or a 4 for it would be a guess printed next to the person's + * own numbers. It never fails the restore: refusing the whole file over one + * unfamiliar intensity would cost far more than the intensity does. + */ +function severityOrNull(value: number | null | undefined): number | null { + return typeof value === "number" && + Number.isInteger(value) && + value >= 1 && + value <= 4 + ? value + : null; +} + function enumOrNull( value: string | null | undefined, allow: ReadonlySet, @@ -326,7 +371,8 @@ function enumOrNull( * Restore the cycle tables for `ownerId` from a parsed backup payload, * inside an existing transaction. Delete-then-recreate, matching the * measurement/mood restore contract. Symptom links are re-resolved against - * the seeded catalogue by key (unknown keys dropped). `notesEncrypted` is + * the seeded catalogue by key, and each one gets back the 1-4 intensity the + * file recorded for it, or none if the file recorded none. `notesEncrypted` is * written back as the ciphertext envelope verbatim — never re-encrypted. * * Returns the wiped counts for the audit trail. A pre-v1.15 payload (empty @@ -468,13 +514,23 @@ export async function restoreCycleData( }; for (const d of payload.cycleDayLogs) { + // The day's rated intensities, keyed by symptom. A file written before + // severities were carried has no list at all and every link comes back + // unrated, which is what it was. An entry naming a symptom the day does not + // have is simply never read. + const severityByKey = new Map(); + for (const s of d.symptomSeverities ?? []) { + const severity = severityOrNull(s.severity); + 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 symptomIds = (d.symptomKeys ?? []).map((k) => { + const symptomLinks = (d.symptomKeys ?? []).map((k) => { const id = symptomIdByKey.get(k); if (!id) throw new Error(`Unresolved cycle symptom key: ${k}`); - return id; + return { symptomId: id, severity: severityByKey.get(k) ?? null }; }); const cycleId = d.cycleId !== undefined @@ -516,12 +572,8 @@ export async function restoreCycleData( deletedAt: d.deletedAt ? new Date(d.deletedAt) : null, ...(d.createdAt ? { createdAt: new Date(d.createdAt) } : {}), ...(d.updatedAt ? { updatedAt: new Date(d.updatedAt) } : {}), - ...(symptomIds.length > 0 - ? { - symptomLinks: { - create: symptomIds.map((symptomId) => ({ symptomId })), - }, - } + ...(symptomLinks.length > 0 + ? { symptomLinks: { create: symptomLinks } } : {}), }, }); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 8af09e801..e3c896d0b 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -267,8 +267,26 @@ const cycleSpanSchema = z * ciphertext envelope verbatim — the backup never decrypts it, so the * owner's free-text note round-trips encrypted (and a wrong-surface leak is * impossible). `symptomKeys` carries the seeded catalogue keys so the - * restore can re-link without exporting internal join ids. + * restore can re-link without exporting internal join ids, and + * `symptomSeverities` carries how hard each of them hit. */ +/** + * The intensity recorded against one of a day's symptoms. + * + * A sparse annotation over `symptomKeys`, not a replacement for it: the keys + * still say which symptoms the day had, this says how hard the ones that were + * rated hit. Only rated links appear, so a missing entry means the person never + * put a number on it. `severity` is left as a plain integer here on purpose — + * the 1-4 range is checked when the row is written, so a file carrying a value + * outside it loses that one intensity instead of failing to restore at all. + */ +const cycleSymptomSeveritySchema = z + .object({ + key: z.string().min(1), + severity: z.number().int().nullable().optional(), + }) + .passthrough(); + const cycleDayLogSchema = z .object({ id: z.string().min(1).optional(), @@ -298,6 +316,7 @@ const cycleDayLogSchema = z createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), symptomKeys: z.array(z.string()).default([]), + symptomSeverities: z.array(cycleSymptomSeveritySchema).default([]), }) .passthrough(); diff --git a/tests/integration/backup-cycle-symptom-severity-restore.test.ts b/tests/integration/backup-cycle-symptom-severity-restore.test.ts new file mode 100644 index 000000000..c55095e2b --- /dev/null +++ b/tests/integration/backup-cycle-symptom-severity-restore.test.ts @@ -0,0 +1,317 @@ +/** + * How hard a symptom hit, through the whole backup pipe. + * + * `CycleSymptomLink.severity` is the 1-4 intensity the log-day sheet writes and + * the day-log DTO reads back. The backup carried only `symptomKeys`, so a day + * recorded as "cramps, and they were a 4" exported as "cramps" and restored as + * "cramps". The symptom was there, the number was gone, and nothing failed — + * which is why it went unnoticed. + * + * These tests assert on ROWS, never on the payload object. A writer test and a + * reader test were both green through the last defect on this exact path while + * the assembly between them dropped the field, so the only thing worth proving + * is what ends up in `cycle_symptom_links` after a real export and a real + * restore: the REAL `buildFullBackupPayload` writes the file, the REAL + * `parseBackupPayload` reads it back, and the REAL admin restore route puts the + * rows down. + * + * The restore wipes day-logs and recreates them, so the links it writes are new + * rows. To make that impossible to mistake, the live severities are overwritten + * with a different number before the restore runs: whatever comes back came out + * of the file. + */ +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(), +})); + +const RATED_KEY = "custom:severity-rated"; +const UNRATED_KEY = "custom:severity-unrated"; + +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" }), + }); +} + +/** + * One account with a day-log carrying two symptoms: one rated 4, one never + * rated. Returns the ids the assertions read back through. + */ +async function seedAccount(username: string) { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username, + email: `${username}@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); + + const category = await prisma.cycleSymptomCategory.findFirstOrThrow(); + const rated = await prisma.cycleSymptom.create({ + data: { + userId: owner.id, + categoryId: category.id, + key: RATED_KEY, + labelKey: "cycle.symptom.custom", + labelEncrypted: encrypt("Cramps"), + sortOrder: 1, + }, + }); + const unrated = await prisma.cycleSymptom.create({ + data: { + userId: owner.id, + categoryId: category.id, + key: UNRATED_KEY, + labelKey: "cycle.symptom.custom", + labelEncrypted: encrypt("Tender skin"), + sortOrder: 2, + }, + }); + await prisma.cycleProfile.create({ + data: { + userId: owner.id, + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + typicalCycleLength: 29, + }, + }); + const cycle = await prisma.menstrualCycle.create({ + data: { + userId: owner.id, + startDate: "2026-07-01", + periodEndDate: "2026-07-05", + tz: "Europe/Berlin", + }, + }); + const dayLog = 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: rated.id, severity: 4 }, + // Never rated. NULL here is the whole point of the second case: it + // has to come back NULL, not 0 and not 1. + { symptomId: unrated.id }, + ], + }, + }, + }); + + return { prisma, owner, rated, unrated, dayLog }; +} + +/** + * Overwrite the live intensities so a severity that survives the restore cannot + * have come from a row that was simply left alone. + */ +async function poisonLiveSeverities(userId: string) { + const prisma = getPrismaClient(); + const links = await prisma.cycleSymptomLink.findMany({ + where: { dayLog: { userId } }, + select: { dayLogId: true, symptomId: true }, + }); + for (const link of links) { + await prisma.cycleSymptomLink.update({ + where: { + dayLogId_symptomId: { + dayLogId: link.dayLogId, + symptomId: link.symptomId, + }, + }, + data: { severity: 1 }, + }); + } +} + +async function storeBackup(userId: string, parsed: unknown, type: string) { + const prisma = getPrismaClient(); + return prisma.dataBackup.create({ + data: { userId, type, data: encrypt(JSON.stringify(parsed)) }, + }); +} + +async function severityByKey(userId: string) { + const prisma = getPrismaClient(); + const links = await prisma.cycleSymptomLink.findMany({ + where: { dayLog: { userId } }, + select: { severity: true, symptom: { select: { key: true } } }, + }); + return Object.fromEntries( + links.map((l) => [l.symptom.key, l.severity]), + ) as Record; +} + +describe("a symptom's recorded intensity across export and restore", () => { + it("comes back on the row it was recorded against", async () => { + const { prisma, owner, dayLog } = await seedAccount("cycle-severity-owner"); + + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(JSON.stringify(built)); + const backup = await storeBackup( + owner.id, + parsed, + "CYCLE_SYMPTOM_SEVERITY_ROUND_TRIP", + ); + + await poisonLiveSeverities(owner.id); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + // Rows, not payload keys. + const restoredDayLog = await prisma.cycleDayLog.findUniqueOrThrow({ + where: { id: dayLog.id }, + }); + expect(restoredDayLog.date).toBe("2026-07-03"); + expect(await severityByKey(owner.id)).toEqual({ + [RATED_KEY]: 4, + [UNRATED_KEY]: null, + }); + }); + + it("stays absent when the file was written before intensities were carried", async () => { + const { prisma, owner } = await seedAccount("cycle-severity-legacy"); + + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + + // A file from the release before this one: the key is not there at all. + // Not an empty list — absent, the way an older writer left it. + const legacy = JSON.parse(JSON.stringify(built)) as { + cycleDayLogs: Array>; + }; + for (const day of legacy.cycleDayLogs) delete day.symptomSeverities; + expect(legacy.cycleDayLogs.some((d) => "symptomSeverities" in d)).toBe( + false, + ); + expect(legacy.cycleDayLogs[0].symptomKeys).toEqual( + expect.arrayContaining([RATED_KEY, UNRATED_KEY]), + ); + + // The old file has to parse, not be refused for the field it never had. + const parsed = parseBackupPayload(JSON.stringify(legacy)); + const backup = await storeBackup( + owner.id, + parsed, + "CYCLE_SYMPTOM_SEVERITY_LEGACY_FILE", + ); + + await poisonLiveSeverities(owner.id); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + // Both symptoms survive; neither is given a number the file never held. + expect(await severityByKey(owner.id)).toEqual({ + [RATED_KEY]: null, + [UNRATED_KEY]: null, + }); + }); + + it("keeps the symptom and drops an intensity outside the scale", async () => { + const { prisma, owner } = await seedAccount("cycle-severity-offscale"); + + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + + // A hand-edited file, or one from some later release with a wider scale. + // Refusing the whole restore over it would cost the account everything to + // save one number. + const offScale = JSON.parse(JSON.stringify(built)) as { + cycleDayLogs: Array<{ + symptomSeverities: Array<{ key: string; severity: number }>; + }>; + }; + for (const day of offScale.cycleDayLogs) { + day.symptomSeverities = [ + { key: RATED_KEY, severity: 9 }, + // Names a symptom this day does not carry — nothing to attach it to. + { key: "custom:not-on-this-day", severity: 2 }, + ]; + } + + const parsed = parseBackupPayload(JSON.stringify(offScale)); + const backup = await storeBackup( + owner.id, + parsed, + "CYCLE_SYMPTOM_SEVERITY_OFF_SCALE", + ); + + await poisonLiveSeverities(owner.id); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + expect(await severityByKey(owner.id)).toEqual({ + [RATED_KEY]: null, + [UNRATED_KEY]: null, + }); + }); +});