mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user