diff --git a/messages/de.json b/messages/de.json index 365f608ae..53ed601c2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7343,6 +7343,7 @@ "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens", "restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation", "restoreSkippedScheduleRevisionLink": "Abfolge der Schema-Zeiträume", + "restoreSkippedAccountSetting": "Kontoeinstellung", "restoreMissingTitle": "Wiederherstellung abgelehnt — die Datei ist unvollständig", "restoreMissingDescription": "Das Manifest der Datei sagt, sie enthalte den folgenden Abschnitt, und sie enthält ihn nicht. Es wurde nichts geändert: Eine Wiederherstellung ersetzt eine ganze Datenklasse, ein fehlender Abschnitt hätte sie also geleert.", "restoreMissingRemedy": "Nimm eine Notfall-Sicherung oder exportiere das Konto erneut und lade die neue Datei hoch. Ein Abschnitt, den das Manifest ausdrücklich als weggelassen ausweist, ist etwas anderes und wird normal wiederhergestellt.", diff --git a/messages/en.json b/messages/en.json index e04b37a24..60da33482 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7343,6 +7343,7 @@ "restoreSkippedEcgReference": "ECG strip provenance", "restoreSkippedMedicationTarget": "Medication target analyte", "restoreSkippedScheduleRevisionLink": "Schedule era succession", + "restoreSkippedAccountSetting": "Account setting", "restoreMissingTitle": "Restore refused — the file is incomplete", "restoreMissingDescription": "The file's own manifest says it carries the section below, and it does not. Nothing was changed: a restore replaces a whole class of records, so putting back a section that is not in the file would have emptied it.", "restoreMissingRemedy": "Use a disaster-recovery snapshot, or export the account again and upload the new file. A section the manifest declares as omitted is a different thing and restores normally.", diff --git a/messages/es.json b/messages/es.json index 97027af1f..d4fadc383 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7343,6 +7343,7 @@ "restoreSkippedEcgReference": "Procedencia de un trazado de ECG", "restoreSkippedMedicationTarget": "Analito objetivo de un medicamento", "restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta", + "restoreSkippedAccountSetting": "Ajuste de la cuenta", "restoreMissingTitle": "Restauración rechazada: el archivo está incompleto", "restoreMissingDescription": "El propio manifiesto del archivo dice que incluye la sección siguiente, y no la incluye. No se cambió nada: una restauración sustituye toda una clase de registros, así que una sección ausente la habría vaciado.", "restoreMissingRemedy": "Usa una copia de recuperación ante desastres, o vuelve a exportar la cuenta y sube el archivo nuevo. Una sección que el manifiesto declara omitida es otra cosa y se restaura con normalidad.", diff --git a/messages/fr.json b/messages/fr.json index ffb3f280a..0ba1803f6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7343,6 +7343,7 @@ "restoreSkippedEcgReference": "Provenance d'un tracé ECG", "restoreSkippedMedicationTarget": "Analyte cible d'un médicament", "restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma", + "restoreSkippedAccountSetting": "Réglage du compte", "restoreMissingTitle": "Restauration refusée — le fichier est incomplet", "restoreMissingDescription": "Le manifeste du fichier indique qu'il contient la section ci-dessous, et il ne la contient pas. Rien n'a été modifié : une restauration remplace toute une classe d'enregistrements, une section absente l'aurait donc vidée.", "restoreMissingRemedy": "Utilise une sauvegarde de reprise après sinistre, ou réexporte le compte et téléverse le nouveau fichier. Une section que le manifeste déclare omise est autre chose et se restaure normalement.", diff --git a/messages/it.json b/messages/it.json index 66e7ee9db..ff8d19586 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7343,6 +7343,7 @@ "restoreSkippedEcgReference": "Provenienza di un tracciato ECG", "restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco", "restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema", + "restoreSkippedAccountSetting": "Impostazione dell'account", "restoreMissingTitle": "Ripristino rifiutato — il file è incompleto", "restoreMissingDescription": "Il manifesto del file dichiara di contenere la sezione qui sotto, e non la contiene. Non è stato modificato nulla: un ripristino sostituisce un'intera classe di record, quindi una sezione assente l'avrebbe svuotata.", "restoreMissingRemedy": "Usa uno snapshot di disaster recovery, oppure esporta di nuovo l'account e carica il file nuovo. Una sezione che il manifesto dichiara omessa è un'altra cosa e si ripristina normalmente.", diff --git a/messages/ko.json b/messages/ko.json index fd7eb9f96..76581d070 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7322,6 +7322,7 @@ "restoreSkippedEcgReference": "ECG 파형 출처", "restoreSkippedMedicationTarget": "약 목표 분석물", "restoreSkippedScheduleRevisionLink": "일정 개정 연결", + "restoreSkippedAccountSetting": "계정 설정", "restoreMissingTitle": "복원을 중단했어요 — 파일이 불완전해요", "restoreMissingDescription": "파일의 매니페스트에는 아래 항목이 들어 있다고 적혀 있는데 실제로는 없어요. 아무것도 바꾸지 않았어요. 복원은 해당 기록 전체를 갈아엎기 때문에, 파일에 없는 항목을 되돌리면 그 항목이 통째로 비워졌을 거예요.", "restoreMissingRemedy": "재해 복구용 스냅샷을 쓰거나, 계정을 다시 내보내서 새 파일을 업로드해 주세요. 매니페스트가 처음부터 '빠졌다'고 표시한 항목은 다른 경우라 정상적으로 복원돼요.", diff --git a/messages/pl.json b/messages/pl.json index 122c19159..4bcb40e97 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7343,6 +7343,7 @@ "restoreSkippedEcgReference": "Pochodzenie zapisu EKG", "restoreSkippedMedicationTarget": "Analit docelowy leku", "restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu", + "restoreSkippedAccountSetting": "Ustawienie konta", "restoreMissingTitle": "Przywracanie odrzucone — plik jest niekompletny", "restoreMissingDescription": "Manifest pliku mówi, że zawiera on poniższą sekcję, a jej nie zawiera. Nic nie zostało zmienione: przywracanie zastępuje całą klasę rekordów, więc brakująca sekcja by ją opróżniła.", "restoreMissingRemedy": "Użyj kopii do odtwarzania po awarii albo wyeksportuj konto ponownie i wgraj nowy plik. Sekcja, którą manifest jawnie deklaruje jako pominiętą, to co innego i przywraca się normalnie.", diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 0f716e5d0..3df29eb17 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -27,6 +27,9 @@ vi.mock("@/lib/db", () => ({ intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, + // The account row's settings, read by `buildAccountSettingsBackupSection`. + // Absent here; the section has its own tests. + user: { findUnique: vi.fn().mockResolvedValue(null) }, // The visit tables. Empty here: this file asserts the sections it seeds, // and an account with no visits is the ordinary case the builder must // still answer for. diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 39f99924f..bcd8cca24 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -467,6 +467,9 @@ function catalogueLabel( if (catalogue === "scheduleRevisionLink") { return t("admin.section.backups.restoreSkippedScheduleRevisionLink"); } + if (catalogue === "accountSetting") { + return t("admin.section.backups.restoreSkippedAccountSetting"); + } // Not a fallback: the chain above is exhaustive and this line is what makes // the compiler say so. A catalogue added without a label here now stops the // build instead of shipping under a label that belongs to something else, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index e0dcf020b..c4a65b1ec 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -384,6 +384,9 @@ function makePrisma() { }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, + // The account row's settings, read by `buildAccountSettingsBackupSection`. + // Absent here; the section has its own tests. + user: { findUnique: vi.fn().mockResolvedValue(null) }, // The visit tables. Empty here: this file asserts the sections it seeds, // and an account with no visits is the ordinary case the builder must // still answer for. diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index 287a97a9f..a988ddfed 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -253,6 +253,9 @@ function sourceClient() { // ordinary case: the export has to answer for a record that never entered // the flow without inventing one. onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, + // The account row's settings, read by `buildAccountSettingsBackupSection`. + // Absent here; the section has its own tests. + user: { findUnique: vi.fn().mockResolvedValue(null) }, // The score as it was SHOWN on a local day. Not recomputable: today's // number always comes from today's rows, so once the readings behind this // day moved, nothing can reproduce what it said. diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index a1c9396c3..5f5e35fed 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -118,6 +118,10 @@ import { type OnboardingBackupCounts, type OnboardingBackupSection, } from "@/lib/export/onboarding-backup"; +import { + buildAccountSettingsBackupSection, + type AccountSettingsBackupSection, +} from "@/lib/export/account-settings-backup"; import { getMedicationCategories } from "@/lib/medication-category"; export interface FullBackupCounts @@ -707,6 +711,7 @@ export async function buildFullBackupPayload( onboardingRecord, ecg, nutrientDays, + accountSettings, ] = await Promise.all([ disasterRecovery ? prisma.appSettings.findUnique({ where: { id: "singleton" } }) @@ -903,6 +908,13 @@ export async function buildFullBackupPayload( }, orderBy: [{ day: "desc" }, { nutrient: "asc" }], }), + // The account's own settings: units, language, modules, thresholds, the + // AI choices without the keys. Which columns those are is decided in + // `USER_COLUMN_BACKUP_CLASS`; both ends live in + // `src/lib/export/account-settings-backup.ts`. + buildAccountSettingsBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), ]); // Pinned to the section interfaces so the spreads below cannot quietly widen @@ -927,6 +939,7 @@ export async function buildFullBackupPayload( const awardsSection: AwardsBackupSection = awards; const environmentSection: EnvironmentBackupSection = environment; const ecgSection: EcgBackupSection = ecg; + const accountSettingsSection: AccountSettingsBackupSection = accountSettings; // The three unbounded tables, either read into arrays or left as markers // the streaming writer will pull through. One place decides, so the payload @@ -985,6 +998,7 @@ export async function buildFullBackupPayload( documentQuotaBytes: appSettings.documentQuotaBytes.toString(), } : null, + ...accountSettingsSection, measurements: bulk.measurements, medications: medications.map((m) => ({ ...(disasterRecovery diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index da1e3baca..8b80622d1 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -89,6 +89,11 @@ import { restoreProfileData } from "@/lib/export/profile-backup"; import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; import { restoreOnboardingData } from "@/lib/export/onboarding-backup"; +import { + restoreAccountSettings, + type AccountSettingsRestoreResult, +} from "@/lib/export/account-settings-backup"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; import { restoreVisitsData } from "@/lib/export/visits-backup"; import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup"; import { restoreSensitiveData } from "@/lib/export/sensitive-backup"; @@ -675,6 +680,7 @@ export async function restoreBackup( expiredTombstonesSkipped: number; cleared: RestoreResponse["cleared"]; skipped: RestoreSkipSummary; + accountSettings: AccountSettingsRestoreResult; }; // The category side table is created lazily. Doing that here, before the // transaction, keeps its DDL (which takes a table lock) out of a @@ -1785,6 +1791,21 @@ export async function restoreBackup( payload, ); + // The account's own settings, onto the account row, column by column. + // Only what `USER_COLUMN_BACKUP_CLASS` calls a setting can be written; + // identity, credentials and this host's bookkeeping on the row stay as + // the receiving account has them. A value this host will not take is + // left out and named in the skip report. Both ends live in + // `src/lib/export/account-settings-backup.ts`. + const accountSettingsResult = await restoreAccountSettings( + tx, + ownerId, + payload, + ); + for (const column of accountSettingsResult.refused) { + skips.push({ catalogue: "accountSetting", key: column, links: 1 }); + } + reportSection("labs"); const biomarkerByName = new Map(); const restoredBiomarkerIds = new Set(); @@ -2455,6 +2476,7 @@ export async function restoreBackup( cleared, skipped: summarizeRestoreSkips(skips), expiredTombstonesSkipped, + accountSettings: accountSettingsResult, }; }, { @@ -2532,7 +2554,8 @@ export async function restoreBackup( }); } - const { cleared, skipped, expiredTombstonesSkipped } = outcome; + const { cleared, skipped, expiredTombstonesSkipped, accountSettings } = + outcome; const measurementsRestored = streamed.measurementCount - expiredTombstonesSkipped; annotate({ @@ -2646,6 +2669,9 @@ export async function restoreBackup( // the trail has to be able to answer it later. restoreInstanceSettings, cleared, + // How many of the account's own settings came back. The ones that did + // not are named in `skipped` under `accountSetting`. + accountSettingsApplied: accountSettings.applied, // The durable half of the report. The response reaches whoever was // looking at the screen; the audit row is still here next week when // someone asks why a day-log lost a symptom. @@ -2685,6 +2711,14 @@ export async function restoreBackup( // The complete transaction succeeded; only now evict owner-scoped caches. invalidateUserData(ownerId); + // A restored unit preference leaves the stored status notes and period + // narratives quoting the old unit, the same gap the settings routes close + // on a unit change, closed the same way. Fire-and-forget: the restore has + // committed and never fails over a re-warm. + if (accountSettings.unitsChanged) { + void refreshTextsAfterUnitChange(ownerId); + } + const response: RestoreResponse = { restored: true, summary, diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 11ffe61d7..5e98d1b4b 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -139,6 +139,13 @@ * one rather than left to assume it worked. It is filed when a delegate files * a visit against a preventive-care checkup their grant does not reach — the * visit saves, the checkup stays due, and the response says so. + * + * The fourteenth, `accountSetting`, names one of the account's own settings + * the file carried and this host would not write: an AI endpoint on a private + * address this host has not allowed, a language or unit this release does not + * know, a threshold out of range, an avatar the upload would refuse. Its key + * is the column (or `column.part` for one band or one site), never the value. + * The account keeps what it had for that setting; everything else comes back. */ export type SkippedCatalogue = | "cycleSymptom" @@ -157,7 +164,8 @@ export type SkippedCatalogue = | "ecgReference" | "medicationTarget" | "scheduleRevisionLink" - | "checkupClosure"; + | "checkupClosure" + | "accountSetting"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index e6412d98f..0613a290d 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1728,6 +1728,85 @@ const ecgRecordingBackupSchema = z }) .passthrough(); +/** + * The account's own settings: the `User` columns `USER_COLUMN_BACKUP_CLASS` + * calls `SETTING`, by column name. Never a credential, an identity column or + * the host's own bookkeeping on the row. + * + * Every field is optional: a column a file does not carry leaves the account + * as it is, so a file from a release with fewer setting columns restores + * what it has. Types are checked here; whether a value is one this host will + * write (a known language, an allowed AI endpoint, an image the upload would + * accept) is decided by the restore, which drops and names what it refuses + * rather than refusing the whole account over one preference. + * + * The insurance number arrives as `insuranceNumberEncrypted` (ciphertext) in + * a disaster-recovery file and as `insuranceNumber` (readable) in a portable + * one, the split every other sealed column uses. + */ +const accountSettingsBackupSchema = z + .object({ + heightCm: z.number().nullable().optional(), + dateOfBirth: isoDateTime.nullable().optional(), + gender: z.string().nullable().optional(), + hasDiabetes: z.boolean().optional(), + displayName: z.string().nullable().optional(), + fullName: z.string().nullable().optional(), + insurerName: z.string().nullable().optional(), + insuranceNumber: z.string().nullable().optional(), + insuranceNumberEncrypted: z.string().nullable().optional(), + insurerIkNumber: z.string().nullable().optional(), + lastReportPracticeName: z.string().nullable().optional(), + avatarBytes: base64BytesSchema.nullable().optional(), + avatarContentType: z.string().nullable().optional(), + avatarUpdatedAt: isoDateTime.nullable().optional(), + homeLat: z.number().nullable().optional(), + homeLon: z.number().nullable().optional(), + homeLabel: z.string().nullable().optional(), + homeTimezone: z.string().nullable().optional(), + homeSince: isoDateTime.nullable().optional(), + timezone: z.string().optional(), + locale: z.string().nullable().optional(), + unitPreference: z.string().nullable().optional(), + glucoseUnit: z.string().nullable().optional(), + timeFormat: z.string().optional(), + dateFormat: z.string().optional(), + modulePreferencesJson: z.unknown().optional(), + thresholdsJson: z.unknown().optional(), + healthScoreConfigJson: z.unknown().optional(), + sourcePriorityJson: z.unknown().optional(), + dashboardWidgetsJson: z.unknown().optional(), + insightsLayoutJson: z.unknown().optional(), + medicationListLayoutJson: z.unknown().optional(), + moodTagLayoutJson: z.unknown().optional(), + reportSelectionJson: z.unknown().optional(), + globalExcludedInjectionSites: z.array(z.string()).optional(), + healthKitConfigJson: z.unknown().optional(), + notificationPrefs: z.unknown().optional(), + moodReminderEnabled: z.boolean().optional(), + aiProvider: z.string().nullable().optional(), + aiModel: z.string().nullable().optional(), + aiBaseUrl: z.string().nullable().optional(), + aiCompatBaseUrl: z.string().nullable().optional(), + aiCompatModel: z.string().nullable().optional(), + aiProviderChain: z.unknown().optional(), + aiResponseTimeoutSeconds: z.number().int().nullable().optional(), + useCentralCodex: z.boolean().optional(), + insightsPrivacyMode: z.string().optional(), + insightsExcludeMetrics: z.array(z.string()).optional(), + disableCoach: z.boolean().optional(), + coachPrefsJson: z.unknown().optional(), + documentsAutoAiRead: z.boolean().optional(), + labsLocalOcrEnabled: z.boolean().optional(), + onboardingCompletedAt: isoDateTime.nullable().optional(), + onboardingTourCompleted: z.boolean().optional(), + onboardingTourProgressJson: z.unknown().optional(), + disclaimerAcknowledgedAt: isoDateTime.nullable().optional(), + disclaimerAcknowledgedVersion: z.string().nullable().optional(), + passkeyUpgradeNudgeDismissed: z.boolean().optional(), + }) + .passthrough(); + const backupManifestSchema = z .object({ documents: z @@ -1750,6 +1829,9 @@ export const backupPayloadSchema = z exportedAt: isoDateTime, userId: z.string().min(1), appSettings: appSettingsBackupSchema.nullable().default(null), + // The account's own settings. Null in every file written before the + // section existed, and a null restores by leaving the account row alone. + accountSettings: accountSettingsBackupSchema.nullable().default(null), measurements: z.array(measurementSchema).default([]), medications: z.array(medicationSchema).default([]), intakeEvents: z.array(intakeEventSchema).default([]),