Merge pull request #816 from MBombeck/feat/backup-mood-taxonomy

Restore an account that grouped its own mood factors
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-22 09:16:34 +02:00
commit e56b54cf24
10 files changed
+223 -5

No files matched your search

@@ -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([]) },
@@ -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
? []
@@ -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(() => {
@@ -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,
@@ -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([]) },
@@ -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([]) },
+11 -4
View File
@@ -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<Record<string, string>> = {
"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:
+40
View File
@@ -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,
+27
View File
@@ -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
+56 -1
View File
@@ -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<void> {
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<void> {
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 },