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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 12:41:45 +02:00
1 parent d9afb23067
commit cd30cb3a52
7 files changed
+70 -1

No files matched your search

@@ -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 },
});
@@ -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);
@@ -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() },
@@ -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([]) },
@@ -159,6 +159,7 @@ function makePrisma() {
},
]),
},
moodTag: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: {
findMany: vi.fn().mockResolvedValue([
{
+18
View File
@@ -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,
+21
View File
@@ -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.