From d596f8c66d852dedb76929ff8c30b76650bdfcad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 01:12:22 +0200 Subject: [PATCH] Restore an account that grouped its own mood factors An account that ever created its own mood category could not be restored at all. Not partially, not with a gap: the restore violated mood_tags_category_id_fkey and rolled the whole file back, so the operator got a 500 naming a constraint and an empty account. The coverage register described this as custom tags landing in a grouping that no longer exists, which reads like a cosmetic loss. Measured, it is the same shape the retired-catalogue-key work already removed once: one row nobody thought about taking down everything else with it. MoodTag.categoryId is a real foreign key. Seeded categories belong to the instance and are deliberately still not carried, but a category the person made is theirs, cascades away with them, and has to travel. It now does, and it is written before the tags that point at it. The hidden set travels by key rather than id, because what people hide is almost always a seeded tag whose id differs on every instance. A key this instance no longer knows costs one hidden row and is reported: a silent drop would un-hide a factor somebody had deliberately taken out of their mood editor, and they would have no way to know why it came back. Found by experiment rather than by reading: seeding a user-owned category into the round trip turned it red with the constraint name, and the same test is green now. --- .../[id]/restore/__tests__/round-trip.test.ts | 2 + .../api/admin/backups/[id]/restore/route.ts | 74 +++++++++++++++++++ .../export/__tests__/per-type-routes.test.ts | 4 + .../__tests__/soft-delete-filter.test.ts | 5 ++ .../export/encrypted/__tests__/route.test.ts | 2 + .../__tests__/full-backup-payload.test.ts | 2 + src/lib/export/backup-plan.ts | 15 +++- src/lib/export/full-backup-payload.ts | 40 ++++++++++ src/lib/validations/backup.ts | 27 +++++++ tests/integration/backup-round-trip.test.ts | 57 +++++++++++++- 10 files changed, 223 insertions(+), 5 deletions(-) diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 640535f51..0629ad6cd 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -297,6 +297,8 @@ function sourceClient() { measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, + moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) }, + moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) }, coachFact: { findMany: vi.fn().mockResolvedValue([]) }, coachPlan: { findMany: vi.fn().mockResolvedValue([]) }, coachReminder: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index f913a7a75..cb50cf30d 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -794,6 +794,33 @@ const handler = apiHandler( (entry) => entry.structuredTags, ); const tagKeys = [...new Set(referencedTagKeys)]; + // The account's own CATEGORIES go in before its tags. A custom + // tag's `categoryId` is a real foreign key, so writing the tag + // first threw `mood_tags_category_id_fkey` and rolled the entire + // restore back — an account that had grouped its own mood factors + // could not be restored at all, and the failure named a constraint + // rather than the thing that was missing. + // + // Upserted by key for the same reason the tags below are: `key` is + // globally unique, so a seeded category that later claims the key + // must be left alone rather than overwritten by a restore. + for (const category of payload.customMoodTagCategories) { + await tx.moodTagCategory.upsert({ + where: { key: category.key }, + create: { + id: category.id, + userId: ownerId, + key: category.key, + labelKey: category.labelKey, + icon: category.icon ?? null, + sortOrder: category.sortOrder ?? 0, + isActive: category.isActive ?? true, + labelEncrypted: category.labelEncrypted ?? null, + }, + update: {}, + }); + } + // 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 @@ -822,6 +849,53 @@ const handler = apiHandler( }); } + // Which tags the account hid. Delete-then-recreate like every + // other section, and resolved by KEY: what people hide is almost + // always a seeded tag, whose id differs on every instance. + // + // A key this instance no longer knows costs one hidden row and is + // REPORTED. Dropping it silently would un-hide a factor the person + // had deliberately taken out of their mood editor, and they would + // have no way to know why it came back. + await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } }); + if (payload.hiddenMoodTags.length > 0) { + const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key); + const hiddenRows = await tx.moodTag.findMany({ + where: { + key: { in: [...new Set(hiddenKeys)] }, + OR: [{ userId: null }, { userId: ownerId }], + }, + select: { id: true, key: true }, + }); + const hiddenByKey = new Map( + hiddenRows.map((row) => [row.key, row.id]), + ); + const unresolvedHidden: string[] = []; + const writableHidden = payload.hiddenMoodTags.filter((entry) => { + if (hiddenByKey.has(entry.key)) return true; + unresolvedHidden.push(entry.key); + return false; + }); + if (writableHidden.length > 0) { + await tx.moodTagHidden.createMany({ + data: writableHidden.map((entry) => ({ + userId: ownerId, + moodTagId: hiddenByKey.get(entry.key)!, + ...(entry.createdAt + ? { createdAt: new Date(entry.createdAt) } + : {}), + })), + skipDuplicates: true, + }); + } + recordUnknownKeys( + skips, + "moodTag", + [...new Set(unresolvedHidden)], + unresolvedHidden, + ); + } + const factorRows = factorKeys.length === 0 ? [] 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 b63623c77..4d31fc6ab 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -43,6 +43,8 @@ vi.mock("@/lib/db", () => ({ measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, + moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) }, + moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) }, coachFact: { findMany: vi.fn().mockResolvedValue([]) }, coachPlan: { findMany: vi.fn().mockResolvedValue([]) }, coachReminder: { findMany: vi.fn().mockResolvedValue([]) }, @@ -137,6 +139,8 @@ beforeEach(() => { vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never); vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never); vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never); }); afterEach(() => { 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 b7b77d1f1..6f103cf6a 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -25,6 +25,8 @@ vi.mock("@/lib/db", () => ({ medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() }, moodEntry: { findMany: vi.fn(), count: vi.fn() }, moodTag: { findMany: vi.fn() }, + moodTagCategory: { findMany: vi.fn() }, + moodTagHidden: { findMany: vi.fn() }, nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, user: { findUnique: vi.fn() }, passkey: { findMany: vi.fn() }, @@ -134,6 +136,9 @@ beforeEach(() => { ); vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodEntry.count).mockResolvedValue(0); vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: null, diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 101d8bb9f..90a94977b 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -41,6 +41,8 @@ vi.mock("@/lib/db", () => ({ measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, + moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) }, + moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) }, coachFact: { findMany: vi.fn().mockResolvedValue([]) }, coachPlan: { findMany: vi.fn().mockResolvedValue([]) }, coachReminder: { 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 27f4436ae..8c7f090c8 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -345,6 +345,8 @@ function makePrisma() { // future `include` that forgets the relation fails here loudly instead of // exporting an account whose Coach never spoke. coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, + moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) }, + moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) }, coachFact: { findMany: vi.fn().mockResolvedValue([]) }, coachPlan: { findMany: vi.fn().mockResolvedValue([]) }, coachReminder: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index bd37f4bba..efa0d596c 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -354,6 +354,17 @@ export const TWO_ENDED_MODELS = [ // that recalculated would disagree with the account's own history. "MedicationInventoryItem", "MedicationInventoryEvent", + // The mood taxonomy the account shaped for itself. The category is not a + // nicety: `MoodTag.categoryId` is a real foreign key, so a category the + // person created and the file did not carry made the restore violate a + // constraint and roll the WHOLE account back. The register described this as + // custom tags landing in a grouping that no longer exists; measured, it was + // a 500 and an empty account. + // + // The hidden set travels by KEY rather than id, because what people hide is + // almost always a seeded tag whose id differs on every instance. + "MoodTagCategory", + "MoodTagHidden", ] as const; /** One model claimed to travel both ways. */ @@ -394,10 +405,6 @@ export const COVERAGE_PENDING: Readonly> = { "The history of how a schedule changed. Without it a restored medication keeps today's schedule and loses the record of when the dose or timing moved, which is the part a doctor asks about.", MedicationEfficacyTarget: "What a medication was supposed to move, and by how much. The drug comes back with no statement of what it was for.", - MoodTagCategory: - "Categories a person created to group their own mood factors. Custom tags restore into a grouping that no longer exists.", - MoodTagHidden: - "Which seeded mood tags the account chose to hide. A restore un-hides every one, so the mood editor comes back cluttered with tags the person removed on purpose.", MentalHealthAssessment: "Completed WHO-5, PHQ and GAD instruments with their scores and dates. Clinically meaningful history that no integration can re-sync, and the single largest gap on this list.", EcgRecording: diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 07c6f1d63..9169729c9 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -92,6 +92,8 @@ export interface FullBackupCounts medicationInventoryItems: number; medicationInventoryEvents: number; moodEntries: number; + customMoodTagCategories: number; + hiddenMoodTags: number; cycles: number; cycleDayLogs: number; nutrientDays: number; @@ -293,6 +295,8 @@ export async function buildFullBackupPayload( intakeEvents, moodEntries, customMoodTags, + customMoodTagCategories, + hiddenMoodTags, cycle, records, profile, @@ -409,6 +413,26 @@ export async function buildFullBackupPayload( where: { userId }, orderBy: { key: "asc" }, }), + // The account's OWN categories. Seeded ones (`userId: null`) belong to the + // instance and are deliberately not carried — but a custom tag's + // `categoryId` is a real foreign key, so a category the account created + // itself has to travel or the tag above cannot be written at all. Before + // this section existed, that was not a gap in the restore: it was a + // constraint violation that took the WHOLE file down with it, so an + // account that had ever grouped its own mood factors could not be + // restored. + prisma.moodTagCategory.findMany({ + where: { userId }, + orderBy: { sortOrder: "asc" }, + }), + // Which tags the account chose to hide. Carried by KEY rather than id: + // what people hide is almost always a SEEDED tag, whose id differs on + // every instance, so an id would resolve to nothing on the way back. + prisma.moodTagHidden.findMany({ + where: { userId }, + select: { moodTag: { select: { key: true } }, createdAt: true }, + orderBy: { createdAt: "asc" }, + }), buildCycleBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), @@ -773,6 +797,20 @@ export async function buildFullBackupPayload( : null, }; }), + customMoodTagCategories: customMoodTagCategories.map((category) => ({ + id: category.id, + key: category.key, + labelKey: category.labelKey, + icon: category.icon, + sortOrder: category.sortOrder, + isActive: category.isActive, + // Ciphertext verbatim, like the tag label beside it. + labelEncrypted: category.labelEncrypted, + })), + hiddenMoodTags: hiddenMoodTags.map((hidden) => ({ + key: hidden.moodTag.key, + createdAt: hidden.createdAt.toISOString(), + })), customMoodTags: customMoodTags.map((tag) => ({ ...(disasterRecovery ? { id: tag.id } : {}), key: tag.key, @@ -851,6 +889,8 @@ export async function buildFullBackupPayload( 0, ), moodEntries: moodEntries.length, + customMoodTagCategories: customMoodTagCategories.length, + hiddenMoodTags: hiddenMoodTags.length, cycles: cycle.cycles.length, cycleDayLogs: cycle.cycleDayLogs.length, nutrientDays: nutrientDays.length, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 3f074d48a..7d768e730 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1192,6 +1192,31 @@ const coachReminderBackupSchema = z }) .passthrough(); +/** + * A grouping the account created for its own mood factors. `id` is REQUIRED, + * unlike everywhere else in this file, because a custom tag addresses its + * category by that id and nothing else can resolve the pairing. + */ +const customMoodTagCategorySchema = z + .object({ + id: z.string().min(1), + key: z.string().min(1), + labelKey: z.string().min(1), + icon: z.string().nullable().optional(), + sortOrder: z.number().int().optional(), + isActive: z.boolean().optional(), + labelEncrypted: z.string().nullable().optional(), + }) + .passthrough(); + +/** A tag the account hid, named by key so a seeded tag resolves anywhere. */ +const hiddenMoodTagSchema = z + .object({ + key: z.string().min(1), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + const allergyBackupSchema = z .object({ id: z.string().min(1), @@ -1313,6 +1338,8 @@ 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([]), + customMoodTagCategories: z.array(customMoodTagCategorySchema).default([]), + hiddenMoodTags: z.array(hiddenMoodTagSchema).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 diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 3ec2f67da..6f1ec739d 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -188,6 +188,9 @@ const COUNT_BACK: Record< // No own `userId` column — reached through the drug, like the schedules. MedicationInventoryEvent: (p, userId) => p.medicationInventoryEvent.count({ where: { medication: { userId } } }), + MoodTagCategory: (p, userId) => + p.moodTagCategory.count({ where: { userId } }), + MoodTagHidden: (p, userId) => p.moodTagHidden.count({ where: { userId } }), }; /** @@ -350,10 +353,22 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { const moodCategory = await prisma.moodTagCategory.findFirstOrThrow({ where: { userId: null }, }); + // A category the account created itself, not a seeded one. It cascades away + // with the user, and `MoodTag.categoryId` is a real foreign key — so before + // the category travelled, this one row made the restore violate + // `mood_tags_category_id_fkey` and answer 500 with the account left empty. + const ownCategory = await prisma.moodTagCategory.create({ + data: { + userId: OWNER_ID, + key: "round_trip_own_category", + labelKey: "mood.category.roundTripOwn", + sortOrder: 90, + }, + }); const moodTag = await prisma.moodTag.create({ data: { userId: OWNER_ID, - categoryId: moodCategory.id, + categoryId: ownCategory.id, key: "round_trip_factor", labelKey: "mood.tag.roundTripFactor", kind: "RATED", @@ -361,6 +376,15 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { scaleMax: 5, }, }); + // A SEEDED tag the account chose to hide. The id differs per instance, so + // this is the row that proves the hidden set travels by key. + const seededTagToHide = await prisma.moodTag.findFirstOrThrow({ + where: { userId: null }, + }); + await prisma.moodTagHidden.create({ + data: { userId: OWNER_ID, moodTagId: seededTagToHide.id }, + }); + await prisma.moodEntry.create({ data: { userId: OWNER_ID, @@ -1068,6 +1092,37 @@ describe("every model the plan claims two-ended survives a real restore", () => status: "active", }); + // The account's own grouping came back, and the hidden tag is hidden + // again — resolved by key, so it points at whatever id this instance uses. + const ownCategories = await prisma.moodTagCategory.findMany({ + where: { userId: OWNER_ID }, + }); + expect( + ownCategories.map((c) => ({ key: c.key, labelKey: c.labelKey })), + ).toEqual([ + { + key: "round_trip_own_category", + labelKey: "mood.category.roundTripOwn", + }, + ]); + const restoredTag = await prisma.moodTag.findFirstOrThrow({ + where: { key: "round_trip_factor" }, + }); + expect( + restoredTag.categoryId, + "the custom tag is grouped under the account's own category", + ).toBe(ownCategories[0].id); + + const hidden = await prisma.moodTagHidden.findMany({ + where: { userId: OWNER_ID }, + include: { moodTag: { select: { key: true, userId: true } } }, + }); + // Exactly one, and it points at a SEEDED tag — which is only possible if + // the key was resolved against this instance's catalogue rather than an id + // carried in the file. + expect(hidden).toHaveLength(1); + expect(hidden[0].moodTag.userId).toBeNull(); + // The shelf, and the count that must not be recomputed. const packs = await prisma.medicationInventoryItem.findMany({ where: { userId: OWNER_ID },