From cd30cb3a52b4d4db02b82bfeb09c67fc2538cdfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 12:41:45 +0200 Subject: [PATCH] fix(backup): carry the mood tags an account made, so its own file can be restored The restore resolves a mood entry's rated factors by key and asks only for the seeded catalogue. A tag the user created is not in it, so the lookup came up short and the whole restore threw 'Unknown mood factor keys'. The file was complete; it simply could not be read back. That is the same cause as the cycle symptoms two commits ago, with the opposite ending. There, an unresolvable key was filtered out and the loss was silent. Here it stopped everything. One shape of missing definition, two answers, neither of them the right one. The account's own tag definitions travel now, get written before the factors resolve, and the lookup accepts the seeded catalogue or the owner's own. The seeded rows stay out of the file for the same reason as before: a restore must not rewrite another instance's reference data. Four more test mocks needed the new delegate, which is again the compiler and the suite naming the call sites rather than me hunting for them. --- .../api/admin/backups/[id]/restore/route.ts | 25 ++++++++++++++++++- .../export/__tests__/per-type-routes.test.ts | 4 +++ .../__tests__/soft-delete-filter.test.ts | 1 + .../export/encrypted/__tests__/route.test.ts | 1 + .../__tests__/full-backup-payload.test.ts | 1 + src/lib/export/full-backup-payload.ts | 18 +++++++++++++ src/lib/validations/backup.ts | 21 ++++++++++++++++ 7 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 2f5660421..17d2ca600 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -605,6 +605,28 @@ const handler = apiHandler( ), ), ]; + // 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 + // "Unknown mood factor keys" — the file was complete and unusable. + for (const tag of payload.customMoodTags) { + await tx.moodTag.upsert({ + where: { key: tag.key }, + create: { + ...(tag.id ? { id: tag.id } : {}), + userId: ownerId, + key: tag.key, + labelKey: tag.labelKey, + categoryId: tag.categoryId, + kind: tag.kind, + isActive: tag.isActive, + }, + // `key` is globally unique, so a seeded key would collide. + // Leave the catalogue row alone; the links resolve either way. + update: {}, + }); + } + const factorRows = factorKeys.length === 0 ? [] @@ -612,7 +634,8 @@ const handler = apiHandler( where: { key: { in: factorKeys }, kind: "RATED", - userId: null, + // The seeded catalogue OR this account's own tags. + OR: [{ userId: null }, { userId: ownerId }], }, select: { id: true, key: true }, }); 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 81962cb5c..70e0f0fd6 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, + moodTag: { findMany: vi.fn() }, nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, @@ -361,6 +362,7 @@ describe("GET /api/export/mood", () => { remaining: 9, resetAt: Date.now() + 3600_000, }); + vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ { date: "2026-05-01", @@ -406,6 +408,7 @@ describe("GET /api/export/full-backup", () => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never); 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); @@ -475,6 +478,7 @@ describe("GET /api/export/full-backup", () => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never); 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); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 0f039db61..4544f74f9 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -24,6 +24,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() }, moodEntry: { findMany: vi.fn(), count: vi.fn() }, + moodTag: { findMany: vi.fn() }, nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, user: { findUnique: vi.fn() }, passkey: { findMany: vi.fn() }, diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 8aaf39708..9f7e39fca 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn().mockResolvedValue([]) }, medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + moodTag: { findMany: vi.fn().mockResolvedValue([]) }, nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index aac92b01f..b699ff685 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -159,6 +159,7 @@ function makePrisma() { }, ]), }, + moodTag: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 835dade03..5dade4a0d 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -92,6 +92,7 @@ export async function buildFullBackupPayload( medications, intakeEvents, moodEntries, + customMoodTags, cycle, records, nutrientDays, @@ -175,6 +176,15 @@ export async function buildFullBackupPayload( }, }, }), + // The account's OWN mood tags. The seeded catalogue is reference data every + // instance has; a tag the user made lives only here. Without it the restore + // cannot resolve the entry's factor keys — and unlike the cycle path, which + // used to drop them silently, the mood path throws, so an account with one + // custom rated tag could not be restored at all. + prisma.moodTag.findMany({ + where: { userId }, + orderBy: { key: "asc" }, + }), buildCycleBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), @@ -309,6 +319,14 @@ export async function buildFullBackupPayload( rating: link.rating!, })), })), + customMoodTags: customMoodTags.map((tag) => ({ + ...(disasterRecovery ? { id: tag.id } : {}), + key: tag.key, + labelKey: tag.labelKey, + categoryId: tag.categoryId, + kind: tag.kind, + isActive: tag.isActive, + })), cycleProfile: cycle.cycleProfile, cycles: cycle.cycles, cycleDayLogs: cycle.cycleDayLogs, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 0af8a3c5b..0387e2fdf 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -314,6 +314,26 @@ const cycleProfileSchema = z }) .passthrough(); +/** + * A mood tag the account created, as opposed to the seeded catalogue. + * + * Carried for the same reason as a custom cycle symptom, but the failure it + * prevents is louder: the restore resolves an entry's rated factors by key and + * throws on one it cannot find, so an account with a single custom rated tag + * could not be restored at all. + */ +const customMoodTagSchema = z + .object({ + id: z.string().min(1).optional(), + key: z.string().min(1), + labelKey: z.string().min(1), + categoryId: z.string().min(1), + // "BINARY" | "RATED", enforced in app code rather than a DB enum. + kind: z.string().min(1), + isActive: z.boolean().default(true), + }) + .passthrough(); + /** * A day's total for one nutrient, from one source. * @@ -605,6 +625,7 @@ export const backupPayloadSchema = z // 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([]), + customMoodTags: z.array(customMoodTagSchema).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.