Files
HealthLog/src/lib/export/app-settings-backup.ts
T
Marc-André Bombeck 322652a86e refactor(backup): build the export from per-domain slices
buildFullBackupPayload now asks every domain for its slice at once and
lays the slices out in the file's key order (backup-export-sections.ts),
merging the manifest where it first appears. The measurement, medication,
mood, instance-settings and nutrient readers and serialisers move out of
the payload builder into their domain modules; the records section keeps
its composition and manifest, while labs, illness, allergies, family
history, workouts and documents each export from their own module. The
measurement restore half moves to measurements-restore.ts so the export
graph does not pull in the restore's dependencies.

The file, its counts and their order are unchanged; the golden copies
still match. The measurement completeness guard now reads the domain
module and also checks that every carried column is written back.
2026-10-22 07:46:02 +02:00

121 lines
5.5 KiB
TypeScript

/**
* The instance settings, the one section of a backup file that is not the
* account's. Both backup ends in one file.
*
* A disaster-recovery payload carries the singleton `app_settings` row —
* registration, the MFA requirement, the default locale and timezone, module
* availability, the notification and AI configuration, the document size cap
* and quota. Writing it back as a side effect of restoring ONE account
* reconfigures the host for everybody on it, silently and hours or weeks out
* of date: an operator putting a single record back on Wednesday has no
* reason to expect Monday's registration switch and Monday's upload cap to
* come with it. Rebuilding a host from a snapshot is a real case, so the
* write stays available — as an answer the operator gives
* (`restoreInstanceSettings`), not one the account restore assumes.
*/
import { DEFAULT_REASONING_MAX_EFFORT } from "@/lib/ai/reasoning/levels";
import type {
BackupExportContext,
BackupExportPart,
} from "@/lib/export/export-context";
import type { RestoreContext } from "@/lib/export/restore-context";
import { foldLegacyCoachAvailability } from "@/lib/modules/operator-availability";
// ── Export ───────────────────────────────────────────────────────────────────
/**
* The instance settings slice: the singleton row, in a disaster-recovery
* payload only, with its timestamps and its quota made JSON-safe.
*/
export async function buildAppSettingsBackupPart(
ctx: BackupExportContext,
): Promise<BackupExportPart> {
const appSettings = ctx.disasterRecovery
? await ctx.prisma.appSettings.findUnique({ where: { id: "singleton" } })
: null;
return {
section: {
appSettings:
ctx.disasterRecovery && appSettings
? {
...appSettings,
adminCodexTokenExpiresAt:
appSettings.adminCodexTokenExpiresAt?.toISOString() ?? null,
adminCodexConnectedAt:
appSettings.adminCodexConnectedAt?.toISOString() ?? null,
documentQuotaBytes: appSettings.documentQuotaBytes.toString(),
}
: null,
},
counts: {},
};
}
// ── Restore ──────────────────────────────────────────────────────────────────
export async function restoreAppSettings(ctx: RestoreContext): Promise<void> {
const { tx, payload } = ctx;
if (!(payload.appSettings && ctx.restoreInstanceSettings)) return;
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,
defaultLocale: settings.defaultLocale,
telegramGlobal: settings.telegramGlobal,
ntfyGlobal: settings.ntfyGlobal,
webPushGlobal: settings.webPushGlobal,
webPushVapidPublicKey: settings.webPushVapidPublicKey,
webPushVapidPrivateKeyEncrypted: settings.webPushVapidPrivateKeyEncrypted,
webPushVapidSubject: settings.webPushVapidSubject,
apiGlobal: settings.apiGlobal,
umamiEnabled: settings.umamiEnabled,
umamiScriptUrl: settings.umamiScriptUrl,
umamiWebsiteId: settings.umamiWebsiteId,
glitchtipEnabled: settings.glitchtipEnabled,
glitchtipDsn: settings.glitchtipDsn,
glitchtipEnvironment: settings.glitchtipEnvironment,
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
adminAiKeyEncrypted: settings.adminAiKeyEncrypted,
adminAiModel: settings.adminAiModel,
adminAiBaseUrl: settings.adminAiBaseUrl,
adminCodexAccessTokenEncrypted: settings.adminCodexAccessTokenEncrypted,
adminCodexRefreshTokenEncrypted: settings.adminCodexRefreshTokenEncrypted,
adminCodexAccountIdEncrypted: settings.adminCodexAccountIdEncrypted,
adminCodexTokenExpiresAt: settings.adminCodexTokenExpiresAt
? new Date(settings.adminCodexTokenExpiresAt)
: null,
adminCodexConnectedAt: settings.adminCodexConnectedAt
? new Date(settings.adminCodexConnectedAt)
: null,
adminCodexConnectionStatus: settings.adminCodexConnectionStatus,
adminAiInsightsFeedbackSummary:
settings.adminAiInsightsFeedbackSummary as never,
defaultUserTimezone: settings.defaultUserTimezone,
assistantEnabled: settings.assistantEnabled,
assistantCoachEnabled: coachFold.assistantCoachEnabled,
assistantBriefingEnabled: settings.assistantBriefingEnabled,
assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled,
assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled ?? true,
// Absent on a file written before the controls existed, which restores
// them at their defaults: allowed, no cap.
aiReasoningEnabled: settings.aiReasoningEnabled ?? true,
aiReasoningMaxEffort:
settings.aiReasoningMaxEffort ?? DEFAULT_REASONING_MAX_EFFORT,
moduleAvailabilityJson: coachFold.moduleAvailabilityJson as never,
documentMaxFileBytes: settings.documentMaxFileBytes,
documentQuotaBytes: BigInt(settings.documentQuotaBytes),
};
await tx.appSettings.upsert({
where: { id: settings.id },
create: { id: settings.id, ...settingsData },
update: settingsData,
});
}