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.