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 },