diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index fd351fa8a..81962cb5c 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({ cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, cycleDayLog: { findMany: vi.fn() }, + cycleSymptom: { findMany: vi.fn() }, // v1.28 backup-completeness — the records section the full-backup // helper now also reads (`buildRecordsBackupSection`). labResult: { findMany: vi.fn() }, @@ -408,6 +409,7 @@ describe("GET /api/export/full-backup", () => { vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cycleSymptom.findMany).mockResolvedValue([] as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never); vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); @@ -476,6 +478,7 @@ describe("GET /api/export/full-backup", () => { vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cycleSymptom.findMany).mockResolvedValue([] as never); const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); vi.mocked(prisma.labResult.findMany).mockResolvedValue([ diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 6a409c51d..8aaf39708 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => ({ cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + cycleSymptom: { findMany: vi.fn().mockResolvedValue([]) }, // v1.28 backup-completeness — the records section the shared payload // builder now also reads (`buildRecordsBackupSection`). labResult: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts index 170d3c29a..7a0166fd1 100644 --- a/src/lib/cycle/__tests__/backup.test.ts +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -26,6 +26,19 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { updatedAt, }), }, + cycleSymptom: { + findMany: vi.fn().mockResolvedValue([ + { + id: "sym-custom", + key: "custom_ache", + labelKey: "cycle.symptom.custom_ache", + categoryId: "cat-1", + icon: "Activity", + sortOrder: 3, + isActive: true, + }, + ]), + }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([ { @@ -151,3 +164,68 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { }); }); }); + +describe("a symptom the account created survives the round trip", () => { + /** + * The defect this pins: the seeded symptom catalogue is reference data every + * instance already has, but a symptom the user made exists only in their + * account. It was never carried, so on restore its key resolved to nothing — + * and the link was silently filtered out. The day-log came back, one of its + * symptoms did not, and the restore reported success. + */ + function client(customSymptoms: unknown[]) { + return { + cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + cycleSymptom: { findMany: vi.fn().mockResolvedValue(customSymptoms) }, + } as never; + } + + it("carries the account's own symptom definitions", async () => { + const section = await buildCycleBackupSection( + client([ + { + id: "sym-1", + key: "custom_ache", + labelKey: "cycle.symptom.custom_ache", + categoryId: "cat-1", + icon: null, + sortOrder: 2, + isActive: true, + }, + ]), + "user-1", + { purpose: "disaster-recovery" }, + ); + + expect(section.customSymptoms).toHaveLength(1); + expect(section.customSymptoms[0]).toMatchObject({ + key: "custom_ache", + labelKey: "cycle.symptom.custom_ache", + categoryId: "cat-1", + sortOrder: 2, + isActive: true, + }); + }); + + it("asks only for the account's own rows, never the seeded catalogue", async () => { + // Carrying the seeded rows would let one instance's restore rewrite + // another instance's reference data. + const c = client([]); + await buildCycleBackupSection(c, "user-1", {}); + expect( + (c as unknown as { cycleSymptom: { findMany: ReturnType } }) + .cycleSymptom.findMany, + ).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "user-1" } }), + ); + }); + + it("writes an empty list rather than omitting the section", async () => { + // An absent key and an empty list read the same to a careless consumer. + // The restore distinguishes them, so the builder must be explicit. + const section = await buildCycleBackupSection(client([]), "user-1", {}); + expect(section.customSymptoms).toEqual([]); + }); +}); diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 6affd2607..1440d72c1 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -92,6 +92,23 @@ export interface CycleBackupSection { updatedAt?: string; symptomKeys: string[]; }>; + /** + * The account's OWN symptom definitions. + * + * The seeded catalogue is reference data every instance already has, but a + * symptom the user created exists only here. Without it the restore looks up + * a key that resolves to nothing and drops the link — the day-log comes back + * with one of its symptoms quietly missing. + */ + customSymptoms: Array<{ + id?: string; + key: string; + labelKey: string; + categoryId: string; + icon: string | null; + sortOrder: number; + isActive: boolean; + }>; } /** @@ -100,12 +117,15 @@ export interface CycleBackupSection { * client) so both writers share one read. */ export async function buildCycleBackupSection( - prisma: Pick, + prisma: Pick< + PrismaClient, + "cycleProfile" | "menstrualCycle" | "cycleDayLog" | "cycleSymptom" + >, userId: string, options: CycleBackupOptions = {}, ): Promise { const disasterRecovery = options.purpose === "disaster-recovery"; - const [profile, cycles, dayLogs] = await Promise.all([ + const [profile, cycles, dayLogs, customSymptoms] = await Promise.all([ prisma.cycleProfile.findUnique({ where: { userId } }), prisma.menstrualCycle.findMany({ where: disasterRecovery @@ -120,6 +140,12 @@ export async function buildCycleBackupSection( symptomLinks: { include: { symptom: { select: { key: true } } } }, }, }), + // `userId` set means the user made it. The NULL rows are the seeded + // catalogue, which the restoring instance already has. + prisma.cycleSymptom.findMany({ + where: { userId }, + orderBy: { key: "asc" }, + }), ]); return { @@ -196,6 +222,15 @@ export async function buildCycleBackupSection( tz: d.tz, symptomKeys: d.symptomLinks.map((l) => l.symptom.key), })), + customSymptoms: customSymptoms.map((sym) => ({ + ...(disasterRecovery ? { id: sym.id } : {}), + key: sym.key, + labelKey: sym.labelKey, + categoryId: sym.categoryId, + icon: sym.icon, + sortOrder: sym.sortOrder, + isActive: sym.isActive, + })), }; } @@ -353,7 +388,30 @@ export async function restoreCycleData( restoredCycleIds.add(created.id); } - // Resolve the seeded symptom catalogue once for the link re-creation. + // Re-create the account's own symptom definitions BEFORE resolving links. + // The seeded catalogue is already on this instance; a symptom the user made + // exists only in the file, and a link cannot resolve to a row that was never + // written back. + for (const sym of payload.customSymptoms ?? []) { + await tx.cycleSymptom.upsert({ + where: { key: sym.key }, + create: { + ...(sym.id ? { id: sym.id } : {}), + userId: ownerId, + key: sym.key, + labelKey: sym.labelKey, + categoryId: sym.categoryId, + icon: sym.icon ?? null, + sortOrder: sym.sortOrder, + isActive: sym.isActive, + }, + // `key` is globally unique, so a seeded key would collide. Leave the + // catalogue row alone: the account's links resolve against it either way. + update: {}, + }); + } + + // Resolve the symptom catalogue once for the link re-creation. const allKeys = Array.from( new Set(payload.cycleDayLogs.flatMap((d) => d.symptomKeys ?? [])), ); @@ -367,6 +425,20 @@ export async function restoreCycleData( select: { id: true, key: true }, }); 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. + 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.", + ); + } } // Recreate day-logs (with symptom links). The owning cycle is the latest @@ -384,9 +456,14 @@ export async function restoreCycleData( }; for (const d of payload.cycleDayLogs) { - const symptomIds = (d.symptomKeys ?? []) - .map((k) => symptomIdByKey.get(k)) - .filter((v): v is string => v !== undefined); + // 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 id = symptomIdByKey.get(k); + if (!id) throw new Error(`Unresolved cycle symptom key: ${k}`); + return id; + }); const cycleId = d.cycleId !== undefined ? d.cycleId === null diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 854b1a7be..b204181b3 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -314,6 +314,27 @@ const cycleProfileSchema = z }) .passthrough(); +/** + * A symptom the account created, as opposed to the seeded catalogue. + * + * Carried because a day-log's symptom link resolves by key: without the + * definition the key resolves to nothing on the restoring instance, and the + * link is lost. The seeded rows are deliberately NOT carried — every instance + * already has them, and shipping them would mean a restore could rewrite + * another instance's reference data. + */ +const customCycleSymptomSchema = z + .object({ + id: z.string().min(1).optional(), + key: z.string().min(1), + labelKey: z.string().min(1), + categoryId: z.string().min(1), + icon: z.string().nullable().optional(), + sortOrder: z.number().int().default(0), + isActive: z.boolean().default(true), + }) + .passthrough(); + const appSettingsBackupSchema = z .object({ id: z.string().min(1), @@ -561,6 +582,9 @@ export const backupPayloadSchema = z cycleProfile: cycleProfileSchema.nullable().default(null), cycles: z.array(cycleSpanSchema).default([]), cycleDayLogs: z.array(cycleDayLogSchema).default([]), + // Defaulted rather than required: files written before this field existed + // are still valid, and an account with no custom symptoms writes []. + customSymptoms: z.array(customCycleSymptomSchema).default([]), // Structured records default to empty arrays so older backups remain // parseable. Canonical DR writers add stable ids and encrypted document // fields; portable exports retain the metadata-only subset.