From 091e4b84cb679e70926dd44a78ea3c3995a84a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:18:00 +0200 Subject: [PATCH] fix(backup): fold a pre-0343 Coach availability key on instance restore Migration 0343 moved the operator's Coach answer from module availability into the Coach switch and folded every live row. Restoring the instance settings from an older file wrote the dead coach key back into module availability and left the Coach switch on, so a Coach the operator had off came back. The restore now applies the same fold: coach false in the file's module availability switches the Coach off, and the key is dropped either way. --- .../api/admin/backups/[id]/restore/route.ts | 11 +++- .../legacy-coach-availability.test.ts | 34 ++++++++++++ src/lib/modules/operator-availability.ts | 28 ++++++++++ .../integration/admin-backups-restore.test.ts | 53 +++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/lib/modules/__tests__/legacy-coach-availability.test.ts diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index cbcf966e9..7e45e49f9 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -71,6 +71,7 @@ import { restoreAwardsData } from "@/lib/export/awards-backup"; import { restoreEnvironmentData } from "@/lib/export/environment-backup"; import { restoreEcgData } from "@/lib/export/ecg-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; +import { foldLegacyCoachAvailability } from "@/lib/modules/operator-availability"; export const dynamic = "force-dynamic"; @@ -483,6 +484,12 @@ const handler = apiHandler( // the account restore assumes. if (payload.appSettings && restoreInstanceSettings) { const settings = payload.appSettings; + // A file from before 0343 carries the Coach's operator answer in + // module availability; carry it into the Coach switch. + const coachFold = foldLegacyCoachAvailability( + settings.moduleAvailabilityJson, + settings.assistantCoachEnabled, + ); const settingsData = { registrationEnabled: settings.registrationEnabled, mfaRequired: settings.mfaRequired, @@ -523,13 +530,13 @@ const handler = apiHandler( settings.adminAiInsightsFeedbackSummary as never, defaultUserTimezone: settings.defaultUserTimezone, assistantEnabled: settings.assistantEnabled, - assistantCoachEnabled: settings.assistantCoachEnabled, + assistantCoachEnabled: coachFold.assistantCoachEnabled, assistantBriefingEnabled: settings.assistantBriefingEnabled, assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled, assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled ?? true, - moduleAvailabilityJson: settings.moduleAvailabilityJson as never, + moduleAvailabilityJson: coachFold.moduleAvailabilityJson as never, documentMaxFileBytes: settings.documentMaxFileBytes, documentQuotaBytes: BigInt(settings.documentQuotaBytes), }; diff --git a/src/lib/modules/__tests__/legacy-coach-availability.test.ts b/src/lib/modules/__tests__/legacy-coach-availability.test.ts new file mode 100644 index 000000000..b72c54fc5 --- /dev/null +++ b/src/lib/modules/__tests__/legacy-coach-availability.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { foldLegacyCoachAvailability } from "../operator-availability"; + +describe("foldLegacyCoachAvailability", () => { + it("carries a pre-0343 `coach: false` into the Coach switch and drops the key", () => { + expect( + foldLegacyCoachAvailability({ coach: false, labs: true }, true), + ).toEqual({ + moduleAvailabilityJson: { labs: true }, + assistantCoachEnabled: false, + }); + }); + + it("drops a `coach: true` key without switching the Coach on", () => { + expect(foldLegacyCoachAvailability({ coach: true }, false)).toEqual({ + moduleAvailabilityJson: {}, + assistantCoachEnabled: false, + }); + }); + + it("leaves a current blob and a missing blob as they are", () => { + expect(foldLegacyCoachAvailability({ labs: false }, true)).toEqual({ + moduleAvailabilityJson: { labs: false }, + assistantCoachEnabled: true, + }); + expect(foldLegacyCoachAvailability(null, true)).toEqual({ + moduleAvailabilityJson: null, + assistantCoachEnabled: true, + }); + }); +}); diff --git a/src/lib/modules/operator-availability.ts b/src/lib/modules/operator-availability.ts index 90007c04e..d90b44a10 100644 --- a/src/lib/modules/operator-availability.ts +++ b/src/lib/modules/operator-availability.ts @@ -117,6 +117,34 @@ export async function getOperatorModuleAvailability(): Promise; + return { + moduleAvailabilityJson: rest, + assistantCoachEnabled: coach === false ? false : assistantCoachEnabled, + }; +} + /** A module key whose operator layer the blob owns (every key but the Coach). */ function isBlobOwnedKey(key: string): key is ModuleKey { return isModuleKey(key) && !SWITCH_OWNED_MODULE_KEYS.includes(key); diff --git a/tests/integration/admin-backups-restore.test.ts b/tests/integration/admin-backups-restore.test.ts index 3eecc7a2a..fed756f18 100644 --- a/tests/integration/admin-backups-restore.test.ts +++ b/tests/integration/admin-backups-restore.test.ts @@ -385,6 +385,59 @@ describe("POST /api/admin/backups/[id]/restore", () => { expect(rewritten.documentMaxFileBytes).toBe(4 * 1_048_576); }); + it("folds a pre-0343 Coach availability key into the Coach switch", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const live = await prisma.appSettings.create({ + data: { id: "singleton" }, + }); + + // A file written before the Coach's operator layer moved from module + // availability to the Coach switch: the operator had the Coach off there. + const { assistantDocumentAiEnabled: _dropped, ...beforeTheSwitchExisted } = + live; + void _dropped; + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "MANUAL_UPLOAD_SETTINGS_TEST", + data: encrypt( + JSON.stringify({ + schemaVersion: "1", + exportedAt: "2026-05-09T00:00:00.000Z", + userId: admin.id, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [], + appSettings: { + ...beforeTheSwitchExisted, + documentQuotaBytes: String(live.documentQuotaBytes), + assistantCoachEnabled: true, + moduleAvailabilityJson: { coach: false, labs: false }, + }, + }), + ), + }, + }); + + const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const res = await POST( + makeRequest(backup.id, { + confirm: "RESTORE", + restoreInstanceSettings: true, + }) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(res.status).toBe(200); + + const restored = await prisma.appSettings.findUniqueOrThrow({ + where: { id: "singleton" }, + }); + expect(restored.assistantCoachEnabled).toBe(false); + expect(restored.moduleAvailabilityJson).toEqual({ labs: false }); + }); + it("returns 404 when the backup id is unknown", async () => { const admin = await seedAdminSession(); void admin;