mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #1076 from MBombeck/fix/v1397-account-backup
Back up account settings and the module selection
This commit is contained in:
21 files changed
+1694
-3
No files matched your search
@@ -34033,6 +34033,187 @@ components:
|
||||
- documentQuotaBytes
|
||||
additionalProperties: {}
|
||||
- type: "null"
|
||||
accountSettings:
|
||||
default: null
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
heightCm:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
dateOfBirth:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
gender:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
hasDiabetes:
|
||||
type: boolean
|
||||
displayName:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
fullName:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
insurerName:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
insuranceNumber:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
insuranceNumberEncrypted:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
insurerIkNumber:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
lastReportPracticeName:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
avatarBytes:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
avatarContentType:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
avatarUpdatedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
homeLat:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
homeLon:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
homeLabel:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
homeTimezone:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
homeSince:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
timezone:
|
||||
type: string
|
||||
locale:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
unitPreference:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
glucoseUnit:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
timeFormat:
|
||||
type: string
|
||||
dateFormat:
|
||||
type: string
|
||||
modulePreferencesJson: {}
|
||||
thresholdsJson: {}
|
||||
healthScoreConfigJson: {}
|
||||
sourcePriorityJson: {}
|
||||
dashboardWidgetsJson: {}
|
||||
insightsLayoutJson: {}
|
||||
medicationListLayoutJson: {}
|
||||
moodTagLayoutJson: {}
|
||||
reportSelectionJson: {}
|
||||
globalExcludedInjectionSites:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
healthKitConfigJson: {}
|
||||
notificationPrefs: {}
|
||||
moodReminderEnabled:
|
||||
type: boolean
|
||||
aiProvider:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
aiModel:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
aiBaseUrl:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
aiCompatBaseUrl:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
aiCompatModel:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
aiProviderChain: {}
|
||||
aiResponseTimeoutSeconds:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
useCentralCodex:
|
||||
type: boolean
|
||||
insightsPrivacyMode:
|
||||
type: string
|
||||
insightsExcludeMetrics:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
disableCoach:
|
||||
type: boolean
|
||||
coachPrefsJson: {}
|
||||
documentsAutoAiRead:
|
||||
type: boolean
|
||||
labsLocalOcrEnabled:
|
||||
type: boolean
|
||||
onboardingCompletedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
onboardingTourCompleted:
|
||||
type: boolean
|
||||
onboardingTourProgressJson: {}
|
||||
disclaimerAcknowledgedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
disclaimerAcknowledgedVersion:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
passkeyUpgradeNudgeDismissed:
|
||||
type: boolean
|
||||
additionalProperties: {}
|
||||
- type: "null"
|
||||
measurements:
|
||||
default: []
|
||||
type: array
|
||||
@@ -38434,6 +38615,7 @@ components:
|
||||
- exportedAt
|
||||
- userId
|
||||
- appSettings
|
||||
- accountSettings
|
||||
- measurements
|
||||
- medications
|
||||
- intakeEvents
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -7322,6 +7322,7 @@
|
||||
"restoreSkippedEcgReference": "ECG 파형 출처",
|
||||
"restoreSkippedMedicationTarget": "약 목표 분석물",
|
||||
"restoreSkippedScheduleRevisionLink": "일정 개정 연결",
|
||||
"restoreSkippedAccountSetting": "계정 설정",
|
||||
"restoreMissingTitle": "복원을 중단했어요 — 파일이 불완전해요",
|
||||
"restoreMissingDescription": "파일의 매니페스트에는 아래 항목이 들어 있다고 적혀 있는데 실제로는 없어요. 아무것도 바꾸지 않았어요. 복원은 해당 기록 전체를 갈아엎기 때문에, 파일에 없는 항목을 되돌리면 그 항목이 통째로 비워졌을 거예요.",
|
||||
"restoreMissingRemedy": "재해 복구용 스냅샷을 쓰거나, 계정을 다시 내보내서 새 파일을 업로드해 주세요. 매니페스트가 처음부터 '빠졌다'고 표시한 항목은 다른 경우라 정상적으로 복원돼요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Every `User` column has a backup verdict, read from the schema rather than
|
||||
* from a list somebody keeps.
|
||||
*
|
||||
* The account row mixes the person's choices with sign-in credentials,
|
||||
* provider keys, the operator's policy for the account and the host's own
|
||||
* cursors, so it cannot have one verdict the way a model does. A column added
|
||||
* tomorrow has to land in `USER_COLUMN_BACKUP_CLASS` (`src/lib/export/
|
||||
* backup-plan.ts`) or this fails naming it: a new preference that silently
|
||||
* stays behind on a restore, or a new secret that silently rides along in
|
||||
* every backup file, are the two failures this exists to stop.
|
||||
*
|
||||
* Mutation check: delete any key from the table and the first case goes red
|
||||
* naming the column; add a `User` column to the schema without classifying it
|
||||
* and the same case goes red naming that one.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseWipeSchema } from "@/__tests__/helpers/wipe-schema-shape";
|
||||
import { USER_COLUMN_BACKUP_CLASS } from "@/lib/export/backup-plan";
|
||||
import { ACCOUNT_SETTING_COLUMNS } from "@/lib/export/account-settings-backup";
|
||||
|
||||
const columns = parseWipeSchema().get("User")?.scalars ?? [];
|
||||
const classified = USER_COLUMN_BACKUP_CLASS as Readonly<Record<string, string>>;
|
||||
|
||||
describe("User column backup classification", () => {
|
||||
it("classifies every User column", () => {
|
||||
// A floor, so a parser that stops matching cannot pass on an empty list.
|
||||
expect(columns.length).toBeGreaterThan(100);
|
||||
const unclassified = columns.filter((c) => !Object.hasOwn(classified, c));
|
||||
expect(
|
||||
unclassified,
|
||||
"these User columns have no backup verdict. Add each to " +
|
||||
"USER_COLUMN_BACKUP_CLASS as SETTING, CREDENTIAL, IDENTITY or " +
|
||||
"OPERATIONAL, and if it is a SETTING give it a codec in " +
|
||||
"account-settings-backup.ts",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("names no column the schema no longer has", () => {
|
||||
const known = new Set(columns);
|
||||
expect(Object.keys(classified).filter((c) => !known.has(c))).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries exactly the columns classified as settings", () => {
|
||||
expect([...ACCOUNT_SETTING_COLUMNS].sort()).toEqual(
|
||||
Object.entries(classified)
|
||||
.filter(([, verdict]) => verdict === "SETTING")
|
||||
.map(([column]) => column)
|
||||
.sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("carries no credential-shaped column as a setting", () => {
|
||||
// The insurance number is the one sealed setting: it is the person's own
|
||||
// data, sealed at rest for privacy, not a secret that grants anything.
|
||||
const sealedSettings = ACCOUNT_SETTING_COLUMNS.filter((column) =>
|
||||
/Encrypted$|Token|Secret|password|totp|oidc/i.test(column),
|
||||
);
|
||||
expect(sealedSettings).toEqual(["insuranceNumberEncrypted"]);
|
||||
});
|
||||
|
||||
it("never classifies a sealed column other than the insurance number as anything but a credential", () => {
|
||||
const sealed = columns.filter(
|
||||
(c) => /Encrypted$/.test(c) && c !== "insuranceNumberEncrypted",
|
||||
);
|
||||
expect(sealed.length).toBeGreaterThan(20);
|
||||
expect(sealed.filter((c) => classified[c] !== "CREDENTIAL")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* The account-settings section at both ends, without a database.
|
||||
*
|
||||
* The integration round trip proves every setting column comes back; this
|
||||
* file pins what the restore refuses and why, and what a portable file shows
|
||||
* a person instead of ciphertext. Every refusal here leaves the account's
|
||||
* current value in place and names the column in the restore's skip report.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
ACCOUNT_SETTING_COLUMNS,
|
||||
admitAccountSettings,
|
||||
buildAccountSettingsBackupSection,
|
||||
} from "@/lib/export/account-settings-backup";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
|
||||
/** A 1x1 PNG, the smallest image the avatar upload accepts. */
|
||||
const PNG_1X1 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
||||
|
||||
const ctx = { operatorTrusted: false };
|
||||
|
||||
function rowWith(overrides: Record<string, unknown> = {}) {
|
||||
const row: Record<string, unknown> = Object.fromEntries(
|
||||
ACCOUNT_SETTING_COLUMNS.map((column) => [column, null]),
|
||||
);
|
||||
return { ...row, ...overrides };
|
||||
}
|
||||
|
||||
function prismaReturning(row: Record<string, unknown> | null) {
|
||||
return { user: { findUnique: vi.fn().mockResolvedValue(row) } } as never;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("buildAccountSettingsBackupSection", () => {
|
||||
it("writes only setting columns, plus the readable insurance number alias", async () => {
|
||||
const { accountSettings } = await buildAccountSettingsBackupSection(
|
||||
prismaReturning(rowWith()),
|
||||
"u1",
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
const allowed = new Set<string>([
|
||||
...ACCOUNT_SETTING_COLUMNS,
|
||||
"insuranceNumber",
|
||||
]);
|
||||
expect(
|
||||
Object.keys(accountSettings!).filter((key) => !allowed.has(key)),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries the insurance number sealed in a disaster-recovery file", async () => {
|
||||
const sealed = encrypt("A123456789");
|
||||
const { accountSettings } = await buildAccountSettingsBackupSection(
|
||||
prismaReturning(rowWith({ insuranceNumberEncrypted: sealed })),
|
||||
"u1",
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
expect(accountSettings).toMatchObject({
|
||||
insuranceNumber: null,
|
||||
insuranceNumberEncrypted: sealed,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries it readable in a portable file, and never the ciphertext", async () => {
|
||||
const { accountSettings } = await buildAccountSettingsBackupSection(
|
||||
prismaReturning(
|
||||
rowWith({ insuranceNumberEncrypted: encrypt("A123456789") }),
|
||||
),
|
||||
"u1",
|
||||
{ purpose: "portable-export" },
|
||||
);
|
||||
expect(accountSettings!.insuranceNumber).toBe("A123456789");
|
||||
expect(accountSettings).not.toHaveProperty("insuranceNumberEncrypted");
|
||||
});
|
||||
|
||||
it("writes the marker for an insurance number this host cannot open", async () => {
|
||||
const { accountSettings } = await buildAccountSettingsBackupSection(
|
||||
prismaReturning(rowWith({ insuranceNumberEncrypted: "zz.notreal" })),
|
||||
"u1",
|
||||
{ purpose: "portable-export" },
|
||||
);
|
||||
expect(accountSettings!.insuranceNumber).toBe(UNREADABLE_EXPORT_MARKER);
|
||||
});
|
||||
|
||||
it("answers null for an account that is not there", async () => {
|
||||
expect(
|
||||
await buildAccountSettingsBackupSection(prismaReturning(null), "u1"),
|
||||
).toEqual({ accountSettings: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("admitAccountSettings", () => {
|
||||
it("leaves out what the file does not carry", () => {
|
||||
expect(admitAccountSettings({}, ctx)).toEqual({ data: {}, refused: [] });
|
||||
});
|
||||
|
||||
it("refuses a language, unit or time zone this release does not know", () => {
|
||||
const { data, refused } = admitAccountSettings(
|
||||
{
|
||||
locale: "xx",
|
||||
unitPreference: "furlongs",
|
||||
glucoseUnit: "mmol/L",
|
||||
timezone: "Mars/Olympus_Mons",
|
||||
homeTimezone: "Europe/Lisbon",
|
||||
timeFormat: "H36",
|
||||
dateFormat: "YMD",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
expect(refused.sort()).toEqual([
|
||||
"locale",
|
||||
"timeFormat",
|
||||
"timezone",
|
||||
"unitPreference",
|
||||
]);
|
||||
expect(data).toEqual({
|
||||
glucoseUnit: "mmol/L",
|
||||
homeTimezone: "Europe/Lisbon",
|
||||
dateFormat: "YMD",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an AI endpoint on a private address this host has not allowed", () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
const { data, refused } = admitAccountSettings(
|
||||
{
|
||||
aiProvider: "LOCAL",
|
||||
aiBaseUrl: "http://192.168.1.20:11434",
|
||||
aiCompatBaseUrl: "http://169.254.169.254/latest",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
expect(refused.sort()).toEqual(["aiBaseUrl", "aiCompatBaseUrl"]);
|
||||
expect(data).toEqual({ aiProvider: "LOCAL" });
|
||||
});
|
||||
|
||||
it("keeps a private AI endpoint the operator allowed here", () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://ollama.lan:11434");
|
||||
const { data, refused } = admitAccountSettings(
|
||||
{ aiProvider: "LOCAL", aiBaseUrl: "http://ollama.lan:11434" },
|
||||
ctx,
|
||||
);
|
||||
expect(refused).toEqual([]);
|
||||
expect(data.aiBaseUrl).toBe("http://ollama.lan:11434");
|
||||
});
|
||||
|
||||
it("does not attach a base URL to a provider other than Local", () => {
|
||||
const { data, refused } = admitAccountSettings(
|
||||
{ aiProvider: "OPENAI", aiBaseUrl: "https://llm.example.com/v1" },
|
||||
ctx,
|
||||
);
|
||||
expect(refused).toEqual(["aiBaseUrl"]);
|
||||
expect(data).toEqual({ aiProvider: "OPENAI" });
|
||||
});
|
||||
|
||||
it("reads the provider the account already has when the file names none", () => {
|
||||
expect(
|
||||
admitAccountSettings(
|
||||
{ aiBaseUrl: "https://llm.example.com/v1" },
|
||||
ctx,
|
||||
"LOCAL",
|
||||
).data,
|
||||
).toEqual({ aiBaseUrl: "https://llm.example.com/v1" });
|
||||
});
|
||||
|
||||
it("keeps the valid threshold bands and names the ones it drops", () => {
|
||||
const { data, refused } = admitAccountSettings(
|
||||
{
|
||||
thresholdsJson: {
|
||||
WEIGHT: { min: 60, max: 80 },
|
||||
PULSE: { min: 100, max: 50 },
|
||||
RETIRED_METRIC: { min: 1, max: 2 },
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
expect(data.thresholdsJson).toEqual({ WEIGHT: { min: 60, max: 80 } });
|
||||
expect(refused.sort()).toEqual([
|
||||
"thresholdsJson.PULSE",
|
||||
"thresholdsJson.RETIRED_METRIC",
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes a database null for a JSON setting the file clears", () => {
|
||||
expect(
|
||||
admitAccountSettings({ dashboardWidgetsJson: null }, ctx).data
|
||||
.dashboardWidgetsJson,
|
||||
).toBe(Prisma.DbNull);
|
||||
});
|
||||
|
||||
it("drops an injection site this release does not know and keeps the rest", () => {
|
||||
const { data, refused } = admitAccountSettings(
|
||||
{ globalExcludedInjectionSites: ["THIGH_LEFT", "EARLOBE"] },
|
||||
ctx,
|
||||
);
|
||||
expect(data.globalExcludedInjectionSites).toEqual(["THIGH_LEFT"]);
|
||||
expect(refused).toEqual(["globalExcludedInjectionSites.EARLOBE"]);
|
||||
});
|
||||
|
||||
it("takes the avatar's type from its bytes, not from the file", () => {
|
||||
const { data, refused } = admitAccountSettings(
|
||||
{
|
||||
avatarBytes: PNG_1X1,
|
||||
avatarContentType: "text/html",
|
||||
avatarUpdatedAt: "2026-06-30T12:00:00.000Z",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
expect(refused).toEqual([]);
|
||||
expect(data.avatarContentType).toBe("image/png");
|
||||
expect(data.avatarUpdatedAt).toEqual(new Date("2026-06-30T12:00:00.000Z"));
|
||||
expect(data.avatarBytes).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("refuses an avatar that is not an image the upload accepts", () => {
|
||||
const { data, refused } = admitAccountSettings(
|
||||
{
|
||||
avatarBytes: Buffer.from("<svg onload=alert(1)>....").toString(
|
||||
"base64",
|
||||
),
|
||||
avatarContentType: "image/svg+xml",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
expect(refused).toEqual(["avatarBytes"]);
|
||||
expect(data).toEqual({});
|
||||
});
|
||||
|
||||
it("seals a readable insurance number under this host's key", () => {
|
||||
const { data } = admitAccountSettings({ insuranceNumber: "A123" }, ctx);
|
||||
expect(decrypt(data.insuranceNumberEncrypted as string)).toBe("A123");
|
||||
});
|
||||
|
||||
it("refuses the unreadable marker as an insurance number", () => {
|
||||
const { data, refused } = admitAccountSettings(
|
||||
{ insuranceNumber: UNREADABLE_EXPORT_MARKER },
|
||||
ctx,
|
||||
);
|
||||
expect(refused).toEqual(["insuranceNumberEncrypted"]);
|
||||
expect(data).toEqual({});
|
||||
});
|
||||
|
||||
it("never admits a column that is not a setting, whatever the file says", () => {
|
||||
const { data } = admitAccountSettings(
|
||||
{
|
||||
passwordHash: "x",
|
||||
role: "ADMIN",
|
||||
email: "someone@example.test",
|
||||
aiOpenaiKeyEncrypted: encrypt("sk-test"),
|
||||
managedProfileAt: "2026-01-01T00:00:00.000Z",
|
||||
syncResetAt: "2026-01-01T00:00:00.000Z",
|
||||
heightCm: 180,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
expect(data).toEqual({ heightCm: 180 });
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* The account's own settings, with both backup ends in one file.
|
||||
*
|
||||
* Which `User` columns these are is decided in `USER_COLUMN_BACKUP_CLASS`
|
||||
* (`src/lib/export/backup-plan.ts`); this file is how each one travels. The
|
||||
* table below is keyed by that type, so a column classified as a setting
|
||||
* without a way to carry it does not compile, and neither does a way to carry
|
||||
* a column that is not one.
|
||||
*
|
||||
* ── What the restore does with them ────────────────────────────────────────
|
||||
*
|
||||
* Writes them to the account being restored, column by column, and nothing
|
||||
* else on the row. The value from the file is not trusted just because this
|
||||
* host once wrote it: the file may come from another host, from an older
|
||||
* release, or from an operator's text editor, so each column is checked the
|
||||
* way the settings route that writes it checks it. A value that fails is not
|
||||
* written, the account keeps what it had, and the column is named in the
|
||||
* restore's skip report, so a setting that did not come back is never
|
||||
* reported as one that did. The checks that can actually fail on a real file:
|
||||
*
|
||||
* - an AI endpoint on a private address the receiving host has not allowed
|
||||
* (`AI_PRIVATE_ORIGINS`), or one the account's role no longer covers;
|
||||
* - a language, unit or time zone this release does not know;
|
||||
* - a threshold for a metric this release does not range, or out of range;
|
||||
* - an avatar that is not one of the image types the upload accepts.
|
||||
*
|
||||
* A setting naming something the receiving host does not have (a provider
|
||||
* with no key, a source that is not connected, a module the operator has
|
||||
* turned off) is kept: the app already reads that state as "not available"
|
||||
* everywhere, because it is exactly the state after a key is removed or an
|
||||
* integration disconnected.
|
||||
*
|
||||
* ── Encryption ─────────────────────────────────────────────────────────────
|
||||
*
|
||||
* One column is sealed, the insurance number, and it follows the split every
|
||||
* other section uses: a disaster-recovery file carries the ciphertext
|
||||
* verbatim (so the restore's key check sees it), a portable file carries the
|
||||
* readable value and the restore seals it under this host's key.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
DateFormatPreference,
|
||||
InjectionSite,
|
||||
TimeFormatPreference,
|
||||
} from "@/generated/prisma/enums";
|
||||
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
|
||||
import {
|
||||
AVATAR_MAX_BYTES,
|
||||
detectAvatarMimeType,
|
||||
readAvatarDimensions,
|
||||
} from "@/lib/avatar";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
USER_COLUMN_BACKUP_CLASS,
|
||||
type AccountSettingColumn,
|
||||
} from "@/lib/export/backup-plan";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { thresholdsUpdateSchema } from "@/lib/validations/thresholds";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import {
|
||||
glucoseUnitPatchSchema,
|
||||
unitPreferencePatchSchema,
|
||||
} from "@/lib/validations/user-prefs";
|
||||
|
||||
export interface AccountSettingsBackupOptions {
|
||||
purpose?: "portable-export" | "disaster-recovery";
|
||||
}
|
||||
|
||||
/** The section as it rides the wire: setting columns by name, JSON-safe. */
|
||||
export type AccountSettingsBackupEntry = Record<string, unknown>;
|
||||
|
||||
export interface AccountSettingsBackupSection {
|
||||
accountSettings: AccountSettingsBackupEntry | null;
|
||||
}
|
||||
|
||||
/** Every setting column, in the order the table below declares them. */
|
||||
export const ACCOUNT_SETTING_COLUMNS = Object.entries(USER_COLUMN_BACKUP_CLASS)
|
||||
.filter(([, verdict]) => verdict === "SETTING")
|
||||
.map(([column]) => column as AccountSettingColumn);
|
||||
|
||||
type SettingsRow = Record<AccountSettingColumn, unknown>;
|
||||
|
||||
/** What the restore knows about the account it writes into. */
|
||||
export interface AccountSettingsRestoreContext {
|
||||
/** Whether the account is an admin, which widens the AI endpoint policy. */
|
||||
operatorTrusted: boolean;
|
||||
}
|
||||
|
||||
/** One column's two ends. */
|
||||
interface SettingCodec {
|
||||
/** The wire fragment for this column, read from the database row. */
|
||||
write(row: SettingsRow, disasterRecovery: boolean): Record<string, unknown>;
|
||||
/**
|
||||
* The database value for this column from the file, `undefined` when the
|
||||
* file does not carry it (the account keeps what it has), or a refusal.
|
||||
*/
|
||||
read(
|
||||
entry: AccountSettingsBackupEntry,
|
||||
ctx: AccountSettingsRestoreContext,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
/** A value from the file that this host will not write. */
|
||||
class Refused {
|
||||
constructor(readonly key: string) {}
|
||||
}
|
||||
|
||||
/** A value that came back in part, with the parts that did not named. */
|
||||
class PartlyRefused {
|
||||
constructor(
|
||||
readonly value: unknown,
|
||||
readonly refused: string[],
|
||||
) {}
|
||||
}
|
||||
|
||||
function has(entry: AccountSettingsBackupEntry, key: string): boolean {
|
||||
return Object.hasOwn(entry, key) && entry[key] !== undefined;
|
||||
}
|
||||
|
||||
/** Carried as it is: a string, number, boolean or list, or null. */
|
||||
function plain(column: AccountSettingColumn): SettingCodec {
|
||||
return {
|
||||
write: (row) => ({ [column]: row[column] }),
|
||||
read: (entry) => (has(entry, column) ? entry[column] : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** A timestamp, carried as ISO-8601. */
|
||||
function instant(column: AccountSettingColumn): SettingCodec {
|
||||
return {
|
||||
write: (row) => ({
|
||||
[column]:
|
||||
row[column] instanceof Date
|
||||
? (row[column] as Date).toISOString()
|
||||
: null,
|
||||
}),
|
||||
read: (entry) => {
|
||||
if (!has(entry, column)) return undefined;
|
||||
const value = entry[column];
|
||||
return value === null ? null : new Date(value as string);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON column, carried as it is stored. Every reader of these parses
|
||||
* fail-soft (a key it does not know is ignored, a malformed blob reads as the
|
||||
* default), so a value written by another release restores rather than being
|
||||
* refused here on a stricter opinion than the app's own.
|
||||
*/
|
||||
function json(column: AccountSettingColumn): SettingCodec {
|
||||
return {
|
||||
write: (row) => ({ [column]: row[column] ?? null }),
|
||||
read: (entry) => {
|
||||
if (!has(entry, column)) return undefined;
|
||||
const value = entry[column];
|
||||
return value === null ? Prisma.DbNull : value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A closed vocabulary. A value outside it is refused, not coerced. */
|
||||
function oneOf(
|
||||
column: AccountSettingColumn,
|
||||
values: readonly string[],
|
||||
nullable: boolean,
|
||||
): SettingCodec {
|
||||
return {
|
||||
write: (row) => ({ [column]: row[column] }),
|
||||
read: (entry) => {
|
||||
if (!has(entry, column)) return undefined;
|
||||
const value = entry[column];
|
||||
if (value === null && nullable) return null;
|
||||
if (typeof value === "string" && values.includes(value)) return value;
|
||||
return new Refused(column);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isTimeZone(value: unknown): value is string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 64) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: value });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** An IANA zone this runtime knows, the check the settings routes apply. */
|
||||
function timeZone(
|
||||
column: AccountSettingColumn,
|
||||
nullable: boolean,
|
||||
): SettingCodec {
|
||||
return {
|
||||
write: (row) => ({ [column]: row[column] }),
|
||||
read: (entry) => {
|
||||
if (!has(entry, column)) return undefined;
|
||||
const value = entry[column];
|
||||
if (value === null && nullable) return null;
|
||||
return isTimeZone(value) ? value : new Refused(column);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An AI endpoint, held to the rule `PATCH /api/user/ai-provider` applies when
|
||||
* it is saved: a public address, or a private one the operator of THIS host
|
||||
* allowed for this account. The address arrived from a file, so it has never
|
||||
* been through that check here.
|
||||
*/
|
||||
function aiEndpoint(column: "aiBaseUrl" | "aiCompatBaseUrl"): SettingCodec {
|
||||
return {
|
||||
write: (row) => ({ [column]: row[column] }),
|
||||
read: (entry, ctx) => {
|
||||
if (!has(entry, column)) return undefined;
|
||||
const value = entry[column];
|
||||
if (value === null) return null;
|
||||
if (typeof value !== "string") return new Refused(column);
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return null;
|
||||
const allowed =
|
||||
isLocalAiHostAllowed(trimmed, {
|
||||
operatorTrusted: ctx.operatorTrusted,
|
||||
}) || isPublicUrl(trimmed);
|
||||
return allowed ? trimmed : new Refused(column);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The threshold overrides, metric by metric. The readers that apply a band
|
||||
* cast the column rather than parse it, so a band this release does not
|
||||
* range, or one outside the metric's bounds, is dropped here and named,
|
||||
* while the valid bands beside it still come back.
|
||||
*/
|
||||
const thresholds: SettingCodec = {
|
||||
write: (row) => ({ thresholdsJson: row.thresholdsJson ?? null }),
|
||||
read: (entry) => {
|
||||
if (!has(entry, "thresholdsJson")) return undefined;
|
||||
const value = entry.thresholdsJson;
|
||||
if (value === null) return Prisma.DbNull;
|
||||
if (typeof value !== "object" || Array.isArray(value)) {
|
||||
return new Refused("thresholdsJson");
|
||||
}
|
||||
const kept: Record<string, unknown> = {};
|
||||
const refused: string[] = [];
|
||||
for (const [metric, band] of Object.entries(value)) {
|
||||
if (thresholdsUpdateSchema.safeParse({ [metric]: band }).success) {
|
||||
kept[metric] = band;
|
||||
} else {
|
||||
refused.push(`thresholdsJson.${metric}`);
|
||||
}
|
||||
}
|
||||
return new PartlyRefused(
|
||||
Object.keys(kept).length === 0 ? Prisma.DbNull : kept,
|
||||
refused,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const injectionSites = Object.values(InjectionSite) as string[];
|
||||
|
||||
/** The injection sites to avoid everywhere; an unknown site is dropped. */
|
||||
const excludedSites: SettingCodec = {
|
||||
write: (row) => ({
|
||||
globalExcludedInjectionSites: row.globalExcludedInjectionSites,
|
||||
}),
|
||||
read: (entry) => {
|
||||
if (!has(entry, "globalExcludedInjectionSites")) return undefined;
|
||||
const value = entry.globalExcludedInjectionSites;
|
||||
if (!Array.isArray(value))
|
||||
return new Refused("globalExcludedInjectionSites");
|
||||
const kept = value.filter(
|
||||
(site): site is string =>
|
||||
typeof site === "string" && injectionSites.includes(site),
|
||||
);
|
||||
const refused = value
|
||||
.filter((site) => !kept.includes(site as string))
|
||||
.map((site) => `globalExcludedInjectionSites.${String(site)}`);
|
||||
return new PartlyRefused(kept, refused);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The picture, re-checked the way the upload route checks one: sniffed from
|
||||
* its bytes, within the size and dimension limits. The stored content type
|
||||
* is the sniffed one, never the file's, because the avatar is served with it.
|
||||
* The three avatar columns travel as one: the bytes decide, the type and the
|
||||
* stamp follow them.
|
||||
*/
|
||||
const avatar: SettingCodec = {
|
||||
write: (row) => ({
|
||||
avatarBytes:
|
||||
row.avatarBytes instanceof Uint8Array
|
||||
? Buffer.from(row.avatarBytes).toString("base64")
|
||||
: null,
|
||||
avatarContentType: row.avatarContentType,
|
||||
avatarUpdatedAt:
|
||||
row.avatarUpdatedAt instanceof Date
|
||||
? row.avatarUpdatedAt.toISOString()
|
||||
: null,
|
||||
}),
|
||||
read: (entry) => {
|
||||
if (!has(entry, "avatarBytes")) return undefined;
|
||||
const value = entry.avatarBytes;
|
||||
if (value === null) {
|
||||
return {
|
||||
avatarBytes: null,
|
||||
avatarContentType: null,
|
||||
avatarUpdatedAt: null,
|
||||
};
|
||||
}
|
||||
if (typeof value !== "string") return new Refused("avatarBytes");
|
||||
const buffer = Buffer.from(value, "base64");
|
||||
if (buffer.byteLength === 0 || buffer.byteLength > AVATAR_MAX_BYTES) {
|
||||
return new Refused("avatarBytes");
|
||||
}
|
||||
const mime = detectAvatarMimeType(buffer);
|
||||
const dimensions = mime ? readAvatarDimensions(buffer, mime) : null;
|
||||
if (!mime || !dimensions) return new Refused("avatarBytes");
|
||||
if (dimensions.width > 2048 || dimensions.height > 2048) {
|
||||
return new Refused("avatarBytes");
|
||||
}
|
||||
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
|
||||
bytes.set(buffer);
|
||||
const stamp = entry.avatarUpdatedAt;
|
||||
return {
|
||||
avatarBytes: bytes,
|
||||
avatarContentType: mime,
|
||||
avatarUpdatedAt: typeof stamp === "string" ? new Date(stamp) : new Date(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Carried by the avatar codec above. */
|
||||
const ridesWithAvatar: SettingCodec = {
|
||||
write: () => ({}),
|
||||
read: () => undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* The insurance number. Sealed at rest; see the file comment for the split.
|
||||
* A portable file written while this host could not open the value carries
|
||||
* the unreadable marker, and the marker is not an insurance number, so it is
|
||||
* refused rather than sealed as one.
|
||||
*/
|
||||
const insuranceNumber: SettingCodec = {
|
||||
write: (row, disasterRecovery) => {
|
||||
const sealed = row.insuranceNumberEncrypted as string | null;
|
||||
if (disasterRecovery) {
|
||||
return { insuranceNumber: null, insuranceNumberEncrypted: sealed };
|
||||
}
|
||||
if (sealed === null) return { insuranceNumber: null };
|
||||
try {
|
||||
return { insuranceNumber: decrypt(sealed) };
|
||||
} catch {
|
||||
return { insuranceNumber: UNREADABLE_EXPORT_MARKER };
|
||||
}
|
||||
},
|
||||
read: (entry) => {
|
||||
if (has(entry, "insuranceNumberEncrypted")) {
|
||||
const sealed = entry.insuranceNumberEncrypted;
|
||||
if (sealed === null) return null;
|
||||
return typeof sealed === "string"
|
||||
? sealed
|
||||
: new Refused("insuranceNumberEncrypted");
|
||||
}
|
||||
if (!has(entry, "insuranceNumber")) return undefined;
|
||||
const readable = entry.insuranceNumber;
|
||||
if (readable === null || readable === "") return null;
|
||||
if (typeof readable !== "string" || readable === UNREADABLE_EXPORT_MARKER) {
|
||||
return new Refused("insuranceNumberEncrypted");
|
||||
}
|
||||
return encrypt(readable);
|
||||
},
|
||||
};
|
||||
|
||||
const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
Record<AccountSettingColumn, SettingCodec>
|
||||
> = {
|
||||
heightCm: plain("heightCm"),
|
||||
dateOfBirth: instant("dateOfBirth"),
|
||||
gender: oneOf("gender", ["MALE", "FEMALE", "OTHER"], true),
|
||||
hasDiabetes: plain("hasDiabetes"),
|
||||
displayName: plain("displayName"),
|
||||
fullName: plain("fullName"),
|
||||
insurerName: plain("insurerName"),
|
||||
insuranceNumberEncrypted: insuranceNumber,
|
||||
insurerIkNumber: plain("insurerIkNumber"),
|
||||
lastReportPracticeName: plain("lastReportPracticeName"),
|
||||
avatarBytes: avatar,
|
||||
avatarContentType: ridesWithAvatar,
|
||||
avatarUpdatedAt: ridesWithAvatar,
|
||||
homeLat: plain("homeLat"),
|
||||
homeLon: plain("homeLon"),
|
||||
homeLabel: plain("homeLabel"),
|
||||
homeTimezone: timeZone("homeTimezone", true),
|
||||
homeSince: instant("homeSince"),
|
||||
timezone: timeZone("timezone", false),
|
||||
locale: oneOf("locale", locales, true),
|
||||
// The vocabularies the two preference routes accept, read from their own
|
||||
// schemas so the restore cannot drift from them.
|
||||
unitPreference: oneOf(
|
||||
"unitPreference",
|
||||
unitPreferencePatchSchema.shape.unitPreference.options,
|
||||
true,
|
||||
),
|
||||
glucoseUnit: oneOf(
|
||||
"glucoseUnit",
|
||||
glucoseUnitPatchSchema.shape.glucoseUnit.options,
|
||||
true,
|
||||
),
|
||||
timeFormat: oneOf("timeFormat", Object.values(TimeFormatPreference), false),
|
||||
dateFormat: oneOf("dateFormat", Object.values(DateFormatPreference), false),
|
||||
modulePreferencesJson: json("modulePreferencesJson"),
|
||||
thresholdsJson: thresholds,
|
||||
healthScoreConfigJson: json("healthScoreConfigJson"),
|
||||
sourcePriorityJson: json("sourcePriorityJson"),
|
||||
dashboardWidgetsJson: json("dashboardWidgetsJson"),
|
||||
insightsLayoutJson: json("insightsLayoutJson"),
|
||||
medicationListLayoutJson: json("medicationListLayoutJson"),
|
||||
moodTagLayoutJson: json("moodTagLayoutJson"),
|
||||
reportSelectionJson: json("reportSelectionJson"),
|
||||
globalExcludedInjectionSites: excludedSites,
|
||||
healthKitConfigJson: json("healthKitConfigJson"),
|
||||
notificationPrefs: json("notificationPrefs"),
|
||||
moodReminderEnabled: plain("moodReminderEnabled"),
|
||||
aiProvider: plain("aiProvider"),
|
||||
aiModel: plain("aiModel"),
|
||||
aiBaseUrl: aiEndpoint("aiBaseUrl"),
|
||||
aiCompatBaseUrl: aiEndpoint("aiCompatBaseUrl"),
|
||||
aiCompatModel: plain("aiCompatModel"),
|
||||
aiProviderChain: json("aiProviderChain"),
|
||||
aiResponseTimeoutSeconds: plain("aiResponseTimeoutSeconds"),
|
||||
useCentralCodex: plain("useCentralCodex"),
|
||||
insightsPrivacyMode: oneOf(
|
||||
"insightsPrivacyMode",
|
||||
["aggregated", "raw"],
|
||||
false,
|
||||
),
|
||||
insightsExcludeMetrics: plain("insightsExcludeMetrics"),
|
||||
disableCoach: plain("disableCoach"),
|
||||
coachPrefsJson: json("coachPrefsJson"),
|
||||
documentsAutoAiRead: plain("documentsAutoAiRead"),
|
||||
labsLocalOcrEnabled: plain("labsLocalOcrEnabled"),
|
||||
onboardingCompletedAt: instant("onboardingCompletedAt"),
|
||||
onboardingTourCompleted: plain("onboardingTourCompleted"),
|
||||
onboardingTourProgressJson: json("onboardingTourProgressJson"),
|
||||
disclaimerAcknowledgedAt: instant("disclaimerAcknowledgedAt"),
|
||||
disclaimerAcknowledgedVersion: plain("disclaimerAcknowledgedVersion"),
|
||||
passkeyUpgradeNudgeDismissed: plain("passkeyUpgradeNudgeDismissed"),
|
||||
};
|
||||
|
||||
/** Exactly the setting columns, so the read is the declaration. */
|
||||
const SETTINGS_SELECT = Object.fromEntries(
|
||||
ACCOUNT_SETTING_COLUMNS.map((column) => [column, true]),
|
||||
) as Record<AccountSettingColumn, true>;
|
||||
|
||||
/**
|
||||
* Build the account-settings slice of a user's full backup.
|
||||
*
|
||||
* Takes the delegate it uses rather than a whole `PrismaClient`, matching the
|
||||
* other section builders.
|
||||
*/
|
||||
export async function buildAccountSettingsBackupSection(
|
||||
prisma: Pick<PrismaClient, "user">,
|
||||
userId: string,
|
||||
options: AccountSettingsBackupOptions = {},
|
||||
): Promise<AccountSettingsBackupSection> {
|
||||
const disasterRecovery = options.purpose === "disaster-recovery";
|
||||
const row = (await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: SETTINGS_SELECT,
|
||||
})) as SettingsRow | null;
|
||||
if (!row) return { accountSettings: null };
|
||||
|
||||
const entry: AccountSettingsBackupEntry = {};
|
||||
for (const column of ACCOUNT_SETTING_COLUMNS) {
|
||||
Object.assign(
|
||||
entry,
|
||||
ACCOUNT_SETTING_CODECS[column].write(row, disasterRecovery),
|
||||
);
|
||||
}
|
||||
return { accountSettings: entry };
|
||||
}
|
||||
|
||||
/** What the file's settings section turned into, before anything is written. */
|
||||
export interface AdmittedAccountSettings {
|
||||
/** Column -> the value to write. Only setting columns ever appear here. */
|
||||
data: Partial<Record<AccountSettingColumn, unknown>>;
|
||||
/** Columns (or `column.part`) the file carried and this host refused. */
|
||||
refused: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide, without writing, what the file's settings section may put on the
|
||||
* account. Pure, so every refusal rule is testable without a database.
|
||||
*
|
||||
* `currentAiProvider` is the account's provider before the restore, for the
|
||||
* one rule that spans two columns: only the Local provider uses
|
||||
* `aiBaseUrl`, and the settings route clears the address whenever the
|
||||
* provider moves away from Local so that a cloud key is never sent to it.
|
||||
*/
|
||||
export function admitAccountSettings(
|
||||
entry: AccountSettingsBackupEntry,
|
||||
ctx: AccountSettingsRestoreContext,
|
||||
currentAiProvider: string | null = null,
|
||||
): AdmittedAccountSettings {
|
||||
const data: Partial<Record<AccountSettingColumn, unknown>> = {};
|
||||
const refused: string[] = [];
|
||||
|
||||
for (const column of ACCOUNT_SETTING_COLUMNS) {
|
||||
const value = ACCOUNT_SETTING_CODECS[column].read(entry, ctx);
|
||||
if (value === undefined) continue;
|
||||
if (value instanceof Refused) {
|
||||
refused.push(value.key);
|
||||
continue;
|
||||
}
|
||||
if (value instanceof PartlyRefused) {
|
||||
data[column] = value.value;
|
||||
refused.push(...value.refused);
|
||||
continue;
|
||||
}
|
||||
if (column === "avatarBytes") {
|
||||
// The avatar codec answers for all three of its columns at once.
|
||||
Object.assign(data, value);
|
||||
continue;
|
||||
}
|
||||
data[column] = value;
|
||||
}
|
||||
|
||||
const provider =
|
||||
"aiProvider" in data
|
||||
? (data.aiProvider as string | null)
|
||||
: currentAiProvider;
|
||||
if (typeof data.aiBaseUrl === "string" && provider !== "LOCAL") {
|
||||
delete data.aiBaseUrl;
|
||||
refused.push("aiBaseUrl");
|
||||
}
|
||||
|
||||
return { data, refused };
|
||||
}
|
||||
|
||||
/** The slice of a parsed backup this restore consumes. */
|
||||
export interface AccountSettingsRestoreInput {
|
||||
/** Absent from every file written before the section existed. */
|
||||
accountSettings?: AccountSettingsBackupEntry | null;
|
||||
}
|
||||
|
||||
export interface AccountSettingsRestoreResult {
|
||||
/** How many setting columns were written. */
|
||||
applied: number;
|
||||
/** What the file carried and the account did not take, by column. */
|
||||
refused: string[];
|
||||
/** Whether a unit preference changed, so stored texts quote old units. */
|
||||
unitsChanged: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the account's settings back, inside the caller's transaction.
|
||||
*
|
||||
* Only the columns `admitAccountSettings` admitted, and only setting columns
|
||||
* can be admitted. Identity, credentials and the host's own bookkeeping on
|
||||
* the row are never in the write, so they stay exactly as the receiving
|
||||
* account has them. A file without the section leaves the row untouched,
|
||||
* which is how every backup written before the section existed restores.
|
||||
*/
|
||||
export async function restoreAccountSettings(
|
||||
tx: Prisma.TransactionClient,
|
||||
ownerId: string,
|
||||
payload: AccountSettingsRestoreInput,
|
||||
): Promise<AccountSettingsRestoreResult> {
|
||||
const entry = payload.accountSettings;
|
||||
if (!entry) return { applied: 0, refused: [], unitsChanged: false };
|
||||
|
||||
const current = await tx.user.findUniqueOrThrow({
|
||||
where: { id: ownerId },
|
||||
select: {
|
||||
role: true,
|
||||
aiProvider: true,
|
||||
unitPreference: true,
|
||||
glucoseUnit: true,
|
||||
},
|
||||
});
|
||||
const { data, refused } = admitAccountSettings(
|
||||
entry,
|
||||
{ operatorTrusted: current.role === "ADMIN" },
|
||||
current.aiProvider,
|
||||
);
|
||||
|
||||
const columns = Object.keys(data);
|
||||
if (columns.length > 0) {
|
||||
await tx.user.update({
|
||||
where: { id: ownerId },
|
||||
data: data as Prisma.UserUpdateInput,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
applied: columns.length,
|
||||
refused,
|
||||
unitsChanged:
|
||||
("unitPreference" in data &&
|
||||
data.unitPreference !== current.unitPreference) ||
|
||||
("glucoseUnit" in data && data.glucoseUnit !== current.glucoseUnit),
|
||||
};
|
||||
}
|
||||
@@ -738,6 +738,209 @@ export const AUTH_MODELS_OUT_OF_SCOPE: readonly string[] = [
|
||||
"InviteRedemption",
|
||||
];
|
||||
|
||||
/**
|
||||
* Every column of the `User` row, and whether it travels.
|
||||
*
|
||||
* The models above are decided one table at a time. The account row cannot
|
||||
* be: it holds the person's own choices next to their sign-in credentials,
|
||||
* the integration keys, the operator's policy for the account and a dozen
|
||||
* cursors, and a single verdict for the whole row would either leave the
|
||||
* choices behind or carry the credentials with them. Before this table the
|
||||
* row was simply not in the backup, so a restore brought the record back and
|
||||
* dropped units, language, time zone, modules, reminder thresholds and the
|
||||
* rest of how the account had been set up.
|
||||
*
|
||||
* `SETTING` — something the person chose. Exported in the
|
||||
* `accountSettings` section and written back to the account
|
||||
* being restored, column by column, after the same checks
|
||||
* the settings routes apply. Both ends live in
|
||||
* `src/lib/export/account-settings-backup.ts`.
|
||||
* `CREDENTIAL` — a secret, a token, a provider registration or an external
|
||||
* identity binding, and the state that only means something
|
||||
* next to one. A backup file is not a credential store; the
|
||||
* same reasoning as `ApiToken` above.
|
||||
* `IDENTITY` — what the account IS rather than how it is set up: its key,
|
||||
* its sign-in name, its role, whether it is a managed record,
|
||||
* and the limits the operator set for it. A restore puts data
|
||||
* back into an account; it never changes which account that
|
||||
* is or what the operator allowed it.
|
||||
* `OPERATIONAL` — cursors, caches and timestamps this host wrote while
|
||||
* working for the account. Carrying them would assert work
|
||||
* the receiving host never did, the reason `IntegrationStatus`
|
||||
* and `ProviderHealth` are excluded above.
|
||||
*
|
||||
* `src/__tests__/user-column-backup-classification.test.ts` reads the
|
||||
* schema and fails on a column that is missing here, so a new column gets a
|
||||
* decision rather than a default.
|
||||
*/
|
||||
export const USER_COLUMN_BACKUP_CLASS = {
|
||||
// ── Identity ──────────────────────────────────────────────────────────────
|
||||
id: "IDENTITY",
|
||||
username: "IDENTITY",
|
||||
email: "IDENTITY",
|
||||
role: "IDENTITY",
|
||||
createdAt: "IDENTITY",
|
||||
updatedAt: "IDENTITY",
|
||||
// Whether somebody else runs this record. Account shape, and the gate for
|
||||
// the guardian's reach: the wipe keeps it for the same reason.
|
||||
managedProfileAt: "IDENTITY",
|
||||
// The operator's per-account policy and quota, set by the operator and not
|
||||
// by the person; a file from Monday must not undo Tuesday's decision.
|
||||
mfaEnforced: "IDENTITY",
|
||||
documentQuotaBytes: "IDENTITY",
|
||||
|
||||
// ── Credentials ───────────────────────────────────────────────────────────
|
||||
passwordHash: "CREDENTIAL",
|
||||
oidcIssuer: "CREDENTIAL",
|
||||
oidcSub: "CREDENTIAL",
|
||||
totpSecretEncrypted: "CREDENTIAL",
|
||||
totpConfirmedAt: "CREDENTIAL",
|
||||
totpLastStep: "CREDENTIAL",
|
||||
codexAccessTokenEncrypted: "CREDENTIAL",
|
||||
codexRefreshTokenEncrypted: "CREDENTIAL",
|
||||
codexTokenExpiresAt: "CREDENTIAL",
|
||||
codexConnectedAt: "CREDENTIAL",
|
||||
codexConnectionStatus: "CREDENTIAL",
|
||||
aiAnthropicKeyEncrypted: "CREDENTIAL",
|
||||
aiLocalKeyEncrypted: "CREDENTIAL",
|
||||
aiOpenaiKeyEncrypted: "CREDENTIAL",
|
||||
aiCompatKeyEncrypted: "CREDENTIAL",
|
||||
// The bot token, the chat it is bound to, and the switch that only means
|
||||
// something with both: the same reasoning as `NotificationChannel`.
|
||||
telegramBotToken: "CREDENTIAL",
|
||||
telegramChatId: "CREDENTIAL",
|
||||
telegramEnabled: "CREDENTIAL",
|
||||
withingsClientIdEncrypted: "CREDENTIAL",
|
||||
withingsClientSecretEncrypted: "CREDENTIAL",
|
||||
whoopClientIdEncrypted: "CREDENTIAL",
|
||||
whoopClientSecretEncrypted: "CREDENTIAL",
|
||||
fitbitClientIdEncrypted: "CREDENTIAL",
|
||||
fitbitClientSecretEncrypted: "CREDENTIAL",
|
||||
googleHealthClientIdEncrypted: "CREDENTIAL",
|
||||
googleHealthClientSecretEncrypted: "CREDENTIAL",
|
||||
// The address is stored sealed beside its token because it can carry one,
|
||||
// and the private-host exemption is a security decision about that one
|
||||
// connection, not a preference that should arrive ahead of it.
|
||||
nightscoutUrlEncrypted: "CREDENTIAL",
|
||||
nightscoutTokenEncrypted: "CREDENTIAL",
|
||||
nightscoutAllowPrivateHost: "CREDENTIAL",
|
||||
polarAccessTokenEncrypted: "CREDENTIAL",
|
||||
polarUserIdEncrypted: "CREDENTIAL",
|
||||
polarClientIdEncrypted: "CREDENTIAL",
|
||||
polarClientSecretEncrypted: "CREDENTIAL",
|
||||
ouraAccessTokenEncrypted: "CREDENTIAL",
|
||||
ouraRefreshTokenEncrypted: "CREDENTIAL",
|
||||
ouraClientIdEncrypted: "CREDENTIAL",
|
||||
ouraClientSecretEncrypted: "CREDENTIAL",
|
||||
stravaClientIdEncrypted: "CREDENTIAL",
|
||||
stravaClientSecretEncrypted: "CREDENTIAL",
|
||||
stravaAccessTokenEncrypted: "CREDENTIAL",
|
||||
stravaRefreshTokenEncrypted: "CREDENTIAL",
|
||||
stravaAthleteId: "CREDENTIAL",
|
||||
|
||||
// ── Operational ───────────────────────────────────────────────────────────
|
||||
insightsCachedAt: "OPERATIONAL",
|
||||
insightsCachedText: "OPERATIONAL",
|
||||
insightsCachedLocale: "OPERATIONAL",
|
||||
insightsSnapshotHash: "OPERATIONAL",
|
||||
insightsWarmFailedAt: "OPERATIONAL",
|
||||
insightsBriefingRerollDate: "OPERATIONAL",
|
||||
morningDigestRefreshedOn: "OPERATIONAL",
|
||||
coachLastSeenAt: "OPERATIONAL",
|
||||
stravaLastActivityAt: "OPERATIONAL",
|
||||
stravaBackfillCompletedAt: "OPERATIONAL",
|
||||
healthKitLastSyncedAt: "OPERATIONAL",
|
||||
healthKitLastSyncTrigger: "OPERATIONAL",
|
||||
healthKitLastBackgroundSyncAt: "OPERATIONAL",
|
||||
lastSyncedAt: "OPERATIONAL",
|
||||
// The restore stamps its own: paired clients' cursors from before it must
|
||||
// be refused, which a carried value would undo.
|
||||
syncResetAt: "OPERATIONAL",
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────
|
||||
// The body and the person, for the reference ranges, the score and the
|
||||
// doctor's report. No other section carries them.
|
||||
heightCm: "SETTING",
|
||||
dateOfBirth: "SETTING",
|
||||
gender: "SETTING",
|
||||
hasDiabetes: "SETTING",
|
||||
displayName: "SETTING",
|
||||
fullName: "SETTING",
|
||||
insurerName: "SETTING",
|
||||
insuranceNumberEncrypted: "SETTING",
|
||||
insurerIkNumber: "SETTING",
|
||||
lastReportPracticeName: "SETTING",
|
||||
avatarBytes: "SETTING",
|
||||
avatarContentType: "SETTING",
|
||||
avatarUpdatedAt: "SETTING",
|
||||
// Home, which the environment history is resolved against.
|
||||
homeLat: "SETTING",
|
||||
homeLon: "SETTING",
|
||||
homeLabel: "SETTING",
|
||||
homeTimezone: "SETTING",
|
||||
homeSince: "SETTING",
|
||||
// How the interface reads.
|
||||
timezone: "SETTING",
|
||||
locale: "SETTING",
|
||||
unitPreference: "SETTING",
|
||||
glucoseUnit: "SETTING",
|
||||
timeFormat: "SETTING",
|
||||
dateFormat: "SETTING",
|
||||
// What is switched on, what is shown where, and what counts as in range.
|
||||
modulePreferencesJson: "SETTING",
|
||||
thresholdsJson: "SETTING",
|
||||
healthScoreConfigJson: "SETTING",
|
||||
sourcePriorityJson: "SETTING",
|
||||
dashboardWidgetsJson: "SETTING",
|
||||
insightsLayoutJson: "SETTING",
|
||||
medicationListLayoutJson: "SETTING",
|
||||
moodTagLayoutJson: "SETTING",
|
||||
reportSelectionJson: "SETTING",
|
||||
globalExcludedInjectionSites: "SETTING",
|
||||
healthKitConfigJson: "SETTING",
|
||||
// Reminders and notifications, as preferences rather than channels.
|
||||
notificationPrefs: "SETTING",
|
||||
moodReminderEnabled: "SETTING",
|
||||
// The AI choices, never the keys: which provider, which model, which
|
||||
// order, how long to wait, what may be sent, and whether to use it at all.
|
||||
// The keys stay behind, so a restored choice of provider reads as "not
|
||||
// configured" until a key is entered, which is also how it reads after a
|
||||
// key is removed in Settings.
|
||||
aiProvider: "SETTING",
|
||||
aiModel: "SETTING",
|
||||
aiBaseUrl: "SETTING",
|
||||
aiCompatBaseUrl: "SETTING",
|
||||
aiCompatModel: "SETTING",
|
||||
aiProviderChain: "SETTING",
|
||||
aiResponseTimeoutSeconds: "SETTING",
|
||||
useCentralCodex: "SETTING",
|
||||
insightsPrivacyMode: "SETTING",
|
||||
insightsExcludeMetrics: "SETTING",
|
||||
disableCoach: "SETTING",
|
||||
coachPrefsJson: "SETTING",
|
||||
documentsAutoAiRead: "SETTING",
|
||||
labsLocalOcrEnabled: "SETTING",
|
||||
// Where the person is in the app's introductions. Without these a restored
|
||||
// account is sent through setup and the tour again, and asked to accept the
|
||||
// disclaimer it already accepted (a newer disclaimer still asks, because the
|
||||
// version is compared).
|
||||
onboardingCompletedAt: "SETTING",
|
||||
onboardingTourCompleted: "SETTING",
|
||||
onboardingTourProgressJson: "SETTING",
|
||||
disclaimerAcknowledgedAt: "SETTING",
|
||||
disclaimerAcknowledgedVersion: "SETTING",
|
||||
passkeyUpgradeNudgeDismissed: "SETTING",
|
||||
} as const satisfies Readonly<
|
||||
Record<string, "SETTING" | "CREDENTIAL" | "IDENTITY" | "OPERATIONAL">
|
||||
>;
|
||||
|
||||
/** The `User` columns the `accountSettings` section carries. */
|
||||
export type AccountSettingColumn = {
|
||||
[
|
||||
K in keyof typeof USER_COLUMN_BACKUP_CLASS
|
||||
]: (typeof USER_COLUMN_BACKUP_CLASS)[K] extends "SETTING" ? K : never;
|
||||
}[keyof typeof USER_COLUMN_BACKUP_CLASS];
|
||||
|
||||
export type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>();
|
||||
const restoredBiomarkerIds = new Set<string>();
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -77,7 +77,12 @@ import {
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
|
||||
import {
|
||||
TWO_ENDED_MODELS,
|
||||
USER_COLUMN_BACKUP_CLASS,
|
||||
type TwoEndedModel,
|
||||
} from "@/lib/export/backup-plan";
|
||||
import { ACCOUNT_SETTING_COLUMNS } from "@/lib/export/account-settings-backup";
|
||||
import { parseOnboardingSteps } from "@/lib/onboarding/needs";
|
||||
import { POST } from "./restore-job-driver";
|
||||
import {
|
||||
@@ -3641,3 +3646,197 @@ describe("every column of every two-ended model survives a real restore", () =>
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A 1x1 PNG: the smallest picture the avatar upload, and so the restore,
|
||||
* accepts.
|
||||
*/
|
||||
const AVATAR_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
/**
|
||||
* Values for `User` columns where the type alone would produce one the
|
||||
* restore rightly refuses: a vocabulary, a time zone, an AI endpoint, a
|
||||
* threshold band, an image.
|
||||
*/
|
||||
const USER_FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
|
||||
"User.gender": "FEMALE",
|
||||
"User.locale": "fr",
|
||||
"User.timezone": "America/Chicago",
|
||||
"User.homeTimezone": "Asia/Tokyo",
|
||||
"User.unitPreference": "imperial",
|
||||
"User.glucoseUnit": "mmol/L",
|
||||
"User.insightsPrivacyMode": "raw",
|
||||
"User.aiProvider": "LOCAL",
|
||||
"User.aiBaseUrl": "https://llm.example.com/v1",
|
||||
"User.aiCompatBaseUrl": "https://gateway.example.com/v1",
|
||||
"User.thresholdsJson": { WEIGHT: { min: 60, max: 80 } },
|
||||
"User.globalExcludedInjectionSites": ["THIGH_LEFT"],
|
||||
"User.avatarBytes": () => {
|
||||
const bytes = new Uint8Array(new ArrayBuffer(AVATAR_PNG.byteLength));
|
||||
bytes.set(AVATAR_PNG);
|
||||
return bytes;
|
||||
},
|
||||
"User.avatarContentType": "image/png",
|
||||
};
|
||||
|
||||
/**
|
||||
* Columns of the account row the restore writes for reasons of its own, so
|
||||
* "unchanged on the target" does not apply to them.
|
||||
*/
|
||||
const USER_ROW_STAMPED: Readonly<Record<string, string>> = {
|
||||
updatedAt: "@updatedAt; Prisma stamps it on the restore's write",
|
||||
syncResetAt:
|
||||
"the restore stamps it so paired clients' delta cursors are refused",
|
||||
};
|
||||
|
||||
describe("the account's own settings survive a real restore", () => {
|
||||
it("brings every setting column back and leaves the rest of the target's row alone", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await createOwner(prisma);
|
||||
|
||||
const [userModel] = readAuditModels(prisma, ["User"]);
|
||||
const enumValues = enumValuesFromSchema();
|
||||
const settings = new Set<string>(ACCOUNT_SETTING_COLUMNS);
|
||||
const classes = USER_COLUMN_BACKUP_CLASS as Readonly<
|
||||
Record<string, string>
|
||||
>;
|
||||
|
||||
// Every column the source account can be given a non-default value in,
|
||||
// settings and everything else alike: a credential that came back on the
|
||||
// target is as much a failure as a setting that did not.
|
||||
const fillable = userModel.columns.filter(
|
||||
(column) =>
|
||||
!column.isId &&
|
||||
!column.isUpdatedAt &&
|
||||
column.name !== "username" &&
|
||||
column.name !== "role",
|
||||
);
|
||||
const unfillable: string[] = [];
|
||||
for (const column of fillable) {
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: {
|
||||
[column.name]: syntheticValue(
|
||||
column,
|
||||
enumValues,
|
||||
USER_FILL_OVERRIDES,
|
||||
),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
unfillable.push(
|
||||
`${column.name}: ${String(error).split("\n").slice(-3).join(" ").slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(unfillable, "columns the fill could not set").toEqual([]);
|
||||
const source = (await prisma.user.findUniqueOrThrow({
|
||||
where: { id: OWNER_ID },
|
||||
})) as unknown as Record<string, unknown>;
|
||||
const defaulted = [...settings].filter((name) =>
|
||||
isDefaultValue(
|
||||
userModel.columns.find((c) => c.name === name)!,
|
||||
source[name],
|
||||
),
|
||||
);
|
||||
expect(
|
||||
defaulted,
|
||||
"these settings still hold their default, so coming back with the " +
|
||||
"default would read as carried",
|
||||
).toEqual([]);
|
||||
|
||||
let json = "";
|
||||
await streamFullBackupJson(
|
||||
prisma,
|
||||
OWNER_ID,
|
||||
(chunk) => {
|
||||
json += chunk;
|
||||
},
|
||||
{
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: new Date("2026-08-01T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
// Nothing but setting columns reaches the file, and no secret at all.
|
||||
const section = (JSON.parse(json) as { accountSettings: object })
|
||||
.accountSettings;
|
||||
expect(
|
||||
Object.keys(section).filter(
|
||||
(key) => !settings.has(key) && key !== "insuranceNumber",
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
// A fresh account under the same id, the way an operator recreates one
|
||||
// on a new host, given credentials of its own that must survive.
|
||||
await prisma.user.delete({ where: { id: OWNER_ID } });
|
||||
await createOwner(prisma);
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: {
|
||||
passwordHash: "target-account-password-hash",
|
||||
aiOpenaiKeyEncrypted: encrypt("target account key"),
|
||||
totpSecretEncrypted: encrypt("target account totp"),
|
||||
},
|
||||
});
|
||||
const target = (await prisma.user.findUniqueOrThrow({
|
||||
where: { id: OWNER_ID },
|
||||
})) as unknown as Record<string, unknown>;
|
||||
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "ACCOUNT_SETTINGS_ROUND_TRIP",
|
||||
data: await legacyStreamedBlobFrom(async (write) => {
|
||||
await write(json);
|
||||
}),
|
||||
},
|
||||
});
|
||||
const response = await POST(
|
||||
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(response.status, JSON.stringify(body)).toBe(200);
|
||||
const refused = (
|
||||
body.data.skipped.catalogueKeys as Array<{ catalogue: string }>
|
||||
).filter((entry) => entry.catalogue === "accountSetting");
|
||||
expect(refused, "the restore refused a setting the fixture set").toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
const after = (await prisma.user.findUniqueOrThrow({
|
||||
where: { id: OWNER_ID },
|
||||
})) as unknown as Record<string, unknown>;
|
||||
|
||||
const lost = [...settings].filter(
|
||||
(name) => opened(after[name]) !== opened(source[name]),
|
||||
);
|
||||
expect(
|
||||
lost,
|
||||
"these settings left with a value and came back without it",
|
||||
).toEqual([]);
|
||||
expect(settings.size).toBeGreaterThanOrEqual(55);
|
||||
|
||||
const overwritten = userModel.columns
|
||||
.map((column) => column.name)
|
||||
.filter(
|
||||
(name) =>
|
||||
classes[name] !== "SETTING" && !Object.hasOwn(USER_ROW_STAMPED, name),
|
||||
)
|
||||
.filter((name) => opened(after[name]) !== opened(target[name]));
|
||||
expect(
|
||||
overwritten,
|
||||
"the restore changed these identity, credential or bookkeeping columns " +
|
||||
"on the account it restored into",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user