mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
feat(backup): export the account settings and restore them onto the account
Both backup purposes now carry an accountSettings section. The restore writes it to the account being restored inside the same transaction, names every refused value under the new accountSetting skip catalogue, and re-warms the stored texts after the transaction when a unit preference changed, the way the unit settings routes do. A file without the section leaves the account row as it is, which is how every older backup restores. The instance settings restore stays its own opt-in.
This commit is contained in:
1 parent
d731a1b8fe
commit
7abaa25c14
15 files changed
+159
-2
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
Reference in new issue
Block a user