diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index a6d393133..7a3956b06 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -774,15 +774,19 @@ const handler = apiHandler( date: entry.date, mood: entry.mood, score: entry.score, - // The five level-A values. `?? null` rather than a derivation: - // a file that carries none is a file from before they existed, - // and filling them in here would put a number the person never - // gave into a restored row and call it their answer. - moodA1: entry.a1 ?? null, - stressA2: entry.a2 ?? null, - energyA3: entry.a3 ?? null, - connectionA4: entry.a4 ?? null, - stabilityA5: entry.a5 ?? null, + // The five level-A values, carried the same way `syncVersion` + // is: absent in the file means the file predates the columns, + // and an upsert whose update arm stated them would wipe live + // answers off every matched row when an older backup is + // restored over a going concern. A file that has them writes + // them, including an explicit null; a file that does not says + // nothing. Never derived — filling one in here would put a + // number the person never gave into a restored row. + ...(entry.a1 === undefined ? {} : { moodA1: entry.a1 }), + ...(entry.a2 === undefined ? {} : { stressA2: entry.a2 }), + ...(entry.a3 === undefined ? {} : { energyA3: entry.a3 }), + ...(entry.a4 === undefined ? {} : { connectionA4: entry.a4 }), + ...(entry.a5 === undefined ? {} : { stabilityA5: entry.a5 }), tags: entry.tags ?? null, // The note comes back the way every other dual-column note // does: ciphertext verbatim when the file carries it, and a diff --git a/tests/integration/mood-backup-round-trip.test.ts b/tests/integration/mood-backup-round-trip.test.ts index 677b40f6d..71cbe1236 100644 --- a/tests/integration/mood-backup-round-trip.test.ts +++ b/tests/integration/mood-backup-round-trip.test.ts @@ -318,6 +318,146 @@ describe("mood backup round trip", () => { expect(byKey.get("rt_sleep_quality")!.rating).toBe(4); }); + it("wipes before it rebuilds, so a live row comes back exactly as the file describes it", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await seedOwner(prisma); + + // A row as it stands today, with answered sliders. + await prisma.moodEntry.create({ + data: { + id: "mood-rt-live", + userId: OWNER_ID, + date: "2026-07-19", + mood: "OKAY", + score: 3, + moodA1: 4, + stressA2: 9, + source: "MANUAL", + moodLoggedAt: LOGGED_AT, + }, + }); + + // Restoring a file written before these columns existed. The restore + // deletes the owner's mood partition and rebuilds it from the file, so + // the row that comes back is the file's row and carries nothing the file + // did not hold. This is the disaster-recovery contract and it is asserted + // here so nobody reads the omit-on-update below as protecting a live row: + // by the time that arm runs there are no live rows left. + const legacyPayload = { + schemaVersion: "2", + exportedAt: "2026-07-20T00:00:00.000Z", + userId: OWNER_ID, + appSettings: null, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: "mood-rt-live", + date: "2026-07-19", + mood: "OKAY", + score: 3, + tags: null, + source: "MANUAL", + loggedAt: LOGGED_AT.toISOString(), + externalId: null, + deletedAt: null, + factors: [], + }, + ], + customMoodTags: [], + nutrientDays: [], + }; + + const response = await restoreFromPayload( + prisma, + parseBackupPayload(legacyPayload), + "MOOD_LEVEL_A_WIPE", + ); + expect(response.status).toBe(200); + + const after = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-live" }, + }); + // Not 4 and not 9: the file said nothing about them and the file is what + // was restored. Nothing was derived to fill the gap either. + expect(after.moodA1).toBeNull(); + expect(after.stressA2).toBeNull(); + }); + + it("lets a second entry for the same row add without erasing", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await seedOwner(prisma); + + // Two entries in ONE file addressing the same row. The first creates it, + // the second lands on the upsert's update arm — the only way that arm is + // reachable, since the partition was wiped a moment earlier. A second + // entry that mentions nothing about a dimension must not blank what the + // first one set, and one that mentions it wins, including a null. + const payload = { + schemaVersion: "2", + exportedAt: "2026-08-08T00:00:00.000Z", + userId: OWNER_ID, + appSettings: null, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: "mood-rt-collide", + date: "2026-07-19", + mood: "OKAY", + score: 3, + tags: null, + source: "MANUAL", + loggedAt: LOGGED_AT.toISOString(), + externalId: null, + deletedAt: null, + a1: 4, + a2: 9, + a3: 6, + factors: [], + }, + { + id: "mood-rt-collide", + date: "2026-07-19", + mood: "GUT", + score: 4, + tags: null, + source: "MANUAL", + loggedAt: LOGGED_AT.toISOString(), + externalId: null, + deletedAt: null, + a1: 7, + a3: null, + factors: [], + }, + ], + customMoodTags: [], + nutrientDays: [], + }; + + const response = await restoreFromPayload( + prisma, + parseBackupPayload(payload), + "MOOD_LEVEL_A_COLLIDE", + ); + expect(response.status).toBe(200); + + const after = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-collide" }, + }); + expect(after.mood).toBe("GUT"); + // Stated by the second entry. + expect(after.moodA1).toBe(7); + // Not mentioned by the second entry: the first entry's answer stands. + expect(after.stressA2).toBe(9); + // Stated as null by the second entry: cleared. + expect(after.energyA3).toBeNull(); + }); + it("restores a file written in the shape that predates these fields", async () => { const prisma = getPrismaClient(); await seedAdminSession(prisma);