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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 12:18:00 +02:00
1 parent 2c1e0f5bca
commit 091e4b84cb
4 files changed
+124 -2

No files matched your search

@@ -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),
};
@@ -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,
});
});
});
+28
View File
@@ -117,6 +117,34 @@ export async function getOperatorModuleAvailability(): Promise<OperatorModuleAva
);
}
/**
* Bring an instance settings pair written before migration 0343 forward.
*
* Until 0343 the operator turned the Coach off in module availability
* (`{ coach: false }`); since then the Coach switch alone carries that answer
* and the availability key is ignored. The migration folded every live row; a
* restored backup from before it needs the same fold, or the Coach it had off
* comes back on and the dead key sits in the blob. Returns the pair to write.
*/
export function foldLegacyCoachAvailability(
moduleAvailabilityJson: unknown,
assistantCoachEnabled: boolean,
): { moduleAvailabilityJson: unknown; assistantCoachEnabled: boolean } {
if (
typeof moduleAvailabilityJson !== "object" ||
moduleAvailabilityJson === null ||
Array.isArray(moduleAvailabilityJson) ||
!("coach" in moduleAvailabilityJson)
) {
return { moduleAvailabilityJson, assistantCoachEnabled };
}
const { coach, ...rest } = moduleAvailabilityJson as Record<string, unknown>;
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);
@@ -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<typeof POST>[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;