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;