mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
refactor(backup): let the account-settings domain own the User column verdicts
The SETTING verdicts are now the keys of the codec table, so a column becomes a setting by getting a codec and nothing else has to name it. The verdicts for every other User column move beside it into account-settings-classes.ts, and the restore half into account-settings-restore.ts. The backup plan keeps the model verdicts. The classification guard now also refuses a column that has both a codec and another verdict, since the composed table would quietly carry it as a setting.
This commit is contained in:
1 parent
66f8b18cf8
commit
dcf49dd14e
11 files changed
+329
-340
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 129,
|
||||
"total": 156466,
|
||||
"total": 156231,
|
||||
"entries": {
|
||||
"app/api/dashboard/summary/route.ts": 1032,
|
||||
"app/api/documents/inbound/route.ts": 811,
|
||||
@@ -68,7 +68,7 @@
|
||||
"lib/dashboard/snapshot.ts": 1549,
|
||||
"lib/day/records.ts": 1175,
|
||||
"lib/doctor-report/collect.ts": 803,
|
||||
"lib/export/backup-plan.ts": 1089,
|
||||
"lib/export/backup-plan.ts": 854,
|
||||
"lib/export/coach-backup.ts": 1070,
|
||||
"lib/export/full-backup-payload.ts": 1520,
|
||||
"lib/export/profile-backup.ts": 1019,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2962,
|
||||
"total": 10592,
|
||||
"total": 10588,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -2042,7 +2042,7 @@
|
||||
"lib/export/__tests__/records-backup.test.ts": 2,
|
||||
"lib/export/__tests__/restore-backup-round-trip.test.ts": 1,
|
||||
"lib/export/account-settings-backup.ts": 2,
|
||||
"lib/export/backup-plan.ts": 13,
|
||||
"lib/export/backup-plan.ts": 9,
|
||||
"lib/export/coach-backup.ts": 13,
|
||||
"lib/export/full-backup-payload.ts": 8,
|
||||
"lib/export/intraday-profile-backup.ts": 1,
|
||||
|
||||
@@ -25,7 +25,7 @@ const SEALED_ALLOWED: Readonly<Record<string, string>> = {
|
||||
"lib/insights/cached-text-cipher.ts": "the one module that owns the column",
|
||||
"lib/crypto/encrypted-columns.ts":
|
||||
"the registry of every ciphertext-bearing column",
|
||||
"lib/export/backup-plan.ts":
|
||||
"lib/export/account-settings-classes.ts":
|
||||
"the backup classification names every User column",
|
||||
"lib/data-wipe/wipe-plan.ts": "the wipe reset names every User column",
|
||||
};
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
* 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
|
||||
* tomorrow gets a verdict in the account-settings domain — a codec in
|
||||
* `src/lib/export/account-settings-backup.ts` makes it a setting, an entry in
|
||||
* `src/lib/export/account-settings-classes.ts` gives it any other verdict —
|
||||
* 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.
|
||||
*
|
||||
@@ -17,8 +19,11 @@
|
||||
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";
|
||||
import {
|
||||
ACCOUNT_SETTING_COLUMNS,
|
||||
USER_COLUMN_BACKUP_CLASS,
|
||||
} from "@/lib/export/account-settings-backup";
|
||||
import { USER_COLUMNS_OUTSIDE_SETTINGS } from "@/lib/export/account-settings-classes";
|
||||
|
||||
const columns = parseWipeSchema().get("User")?.scalars ?? [];
|
||||
const classified = USER_COLUMN_BACKUP_CLASS as Readonly<Record<string, string>>;
|
||||
@@ -31,9 +36,9 @@ describe("User column backup classification", () => {
|
||||
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",
|
||||
"account-settings-classes.ts as CREDENTIAL, IDENTITY or " +
|
||||
"OPERATIONAL, or give it a codec in account-settings-backup.ts, " +
|
||||
"which makes it a SETTING",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -42,6 +47,17 @@ describe("User column backup classification", () => {
|
||||
expect(Object.keys(classified).filter((c) => !known.has(c))).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives no column both a codec and another verdict", () => {
|
||||
// The composed table spreads the settings over the other verdicts, so a
|
||||
// column in both would silently travel as a setting.
|
||||
expect(ACCOUNT_SETTING_COLUMNS.length).toBeGreaterThan(50);
|
||||
expect(
|
||||
ACCOUNT_SETTING_COLUMNS.filter((column) =>
|
||||
Object.hasOwn(USER_COLUMNS_OUTSIDE_SETTINGS, column),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries exactly the columns classified as settings", () => {
|
||||
expect([...ACCOUNT_SETTING_COLUMNS].sort()).toEqual(
|
||||
Object.entries(classified)
|
||||
|
||||
@@ -55,9 +55,9 @@ import {
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { readLocation, sealLocation } from "@/lib/environment/location-cipher";
|
||||
import {
|
||||
USER_COLUMN_BACKUP_CLASS,
|
||||
type AccountSettingColumn,
|
||||
} from "@/lib/export/backup-plan";
|
||||
USER_COLUMNS_OUTSIDE_SETTINGS,
|
||||
type UserColumnBackupClass,
|
||||
} from "@/lib/export/account-settings-classes";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { thresholdsUpdateSchema } from "@/lib/validations/thresholds";
|
||||
@@ -79,12 +79,7 @@ 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>;
|
||||
type SettingsRow = Record<string, unknown>;
|
||||
|
||||
/** What the restore knows about the account it writes into. */
|
||||
export interface AccountSettingsRestoreContext {
|
||||
@@ -124,7 +119,7 @@ function has(entry: AccountSettingsBackupEntry, key: string): boolean {
|
||||
}
|
||||
|
||||
/** Carried as it is: a string, number, boolean or list, or null. */
|
||||
function plain(column: AccountSettingColumn): SettingCodec {
|
||||
function plain(column: string): SettingCodec {
|
||||
return {
|
||||
write: (row) => ({ [column]: row[column] }),
|
||||
read: (entry) => (has(entry, column) ? entry[column] : undefined),
|
||||
@@ -132,7 +127,7 @@ function plain(column: AccountSettingColumn): SettingCodec {
|
||||
}
|
||||
|
||||
/** A timestamp, carried as ISO-8601. */
|
||||
function instant(column: AccountSettingColumn): SettingCodec {
|
||||
function instant(column: string): SettingCodec {
|
||||
return {
|
||||
write: (row) => ({
|
||||
[column]:
|
||||
@@ -154,7 +149,7 @@ function instant(column: AccountSettingColumn): SettingCodec {
|
||||
* 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 {
|
||||
function json(column: string): SettingCodec {
|
||||
return {
|
||||
write: (row) => ({ [column]: row[column] ?? null }),
|
||||
read: (entry) => {
|
||||
@@ -167,7 +162,7 @@ function json(column: AccountSettingColumn): SettingCodec {
|
||||
|
||||
/** A closed vocabulary. A value outside it is refused, not coerced. */
|
||||
function oneOf(
|
||||
column: AccountSettingColumn,
|
||||
column: string,
|
||||
values: readonly string[],
|
||||
nullable: boolean,
|
||||
): SettingCodec {
|
||||
@@ -197,10 +192,7 @@ function isTimeZone(value: unknown): value is string {
|
||||
}
|
||||
|
||||
/** An IANA zone this runtime knows, the check the settings routes apply. */
|
||||
function timeZone(
|
||||
column: AccountSettingColumn,
|
||||
nullable: boolean,
|
||||
): SettingCodec {
|
||||
function timeZone(column: string, nullable: boolean): SettingCodec {
|
||||
return {
|
||||
write: (row) => ({ [column]: row[column] }),
|
||||
read: (entry) => {
|
||||
@@ -490,9 +482,15 @@ const homeLocation: SettingCodec = {
|
||||
},
|
||||
};
|
||||
|
||||
const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
Record<AccountSettingColumn, SettingCodec>
|
||||
> = {
|
||||
/**
|
||||
* Every setting the account row carries, and how each one travels. The
|
||||
* keys of this table ARE the `SETTING` verdicts of `USER_COLUMN_BACKUP_CLASS`
|
||||
* below: a column becomes a setting by getting a codec here, and a codec
|
||||
* cannot exist for anything else.
|
||||
*/
|
||||
const ACCOUNT_SETTING_CODECS = {
|
||||
// The body and the person, for the reference ranges, the score and the
|
||||
// doctor's report. No other section carries them.
|
||||
heightCm: plain("heightCm"),
|
||||
dateOfBirth: instant("dateOfBirth"),
|
||||
gender: oneOf("gender", ["MALE", "FEMALE", "OTHER"], true),
|
||||
@@ -506,10 +504,14 @@ const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
avatarBytes: avatar,
|
||||
avatarContentType: ridesWithAvatar,
|
||||
avatarUpdatedAt: ridesWithAvatar,
|
||||
// Home, which the environment history is resolved against.
|
||||
homeTimezone: timeZone("homeTimezone", true),
|
||||
homeSince: instant("homeSince"),
|
||||
// The sealed home location (the only copy since v1.43),
|
||||
// and the account's switch for the air-quality part of the module.
|
||||
homeLocationEncrypted: homeLocation,
|
||||
environmentAirQualityEnabled: plain("environmentAirQualityEnabled"),
|
||||
// How the interface reads.
|
||||
timezone: timeZone("timezone", false),
|
||||
locale: oneOf("locale", locales, true),
|
||||
// The vocabularies the two preference routes accept, read from their own
|
||||
@@ -526,6 +528,7 @@ const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
),
|
||||
timeFormat: oneOf("timeFormat", Object.values(TimeFormatPreference), false),
|
||||
dateFormat: oneOf("dateFormat", Object.values(DateFormatPreference), false),
|
||||
// What is switched on, what is shown where, and what counts as in range.
|
||||
modulePreferencesJson: json("modulePreferencesJson"),
|
||||
thresholdsJson: thresholds,
|
||||
healthScoreConfigJson: json("healthScoreConfigJson"),
|
||||
@@ -541,8 +544,14 @@ const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
reportSelectionJson: json("reportSelectionJson"),
|
||||
globalExcludedInjectionSites: excludedSites,
|
||||
healthKitConfigJson: json("healthKitConfigJson"),
|
||||
// Reminders and notifications, as preferences rather than channels.
|
||||
notificationPrefs: json("notificationPrefs"),
|
||||
moodReminderEnabled: plain("moodReminderEnabled"),
|
||||
// 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: plain("aiProvider"),
|
||||
aiModel: plain("aiModel"),
|
||||
aiBaseUrl: aiEndpoint("aiBaseUrl"),
|
||||
@@ -558,19 +567,53 @@ const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
),
|
||||
insightsExcludeMetrics: plain("insightsExcludeMetrics"),
|
||||
disableCoach: plain("disableCoach"),
|
||||
// The account's own AI usage level; NULL follows the
|
||||
// instance default, and a restored level above this host's ceiling is
|
||||
// capped at read time like any stored choice.
|
||||
aiUsageLevel: oneOf("aiUsageLevel", Object.values(AiUsageLevel), true),
|
||||
coachPrefsJson: json("coachPrefsJson"),
|
||||
documentsAutoAiRead: plain("documentsAutoAiRead"),
|
||||
labsLocalOcrEnabled: plain("labsLocalOcrEnabled"),
|
||||
storeWorkoutRoutes: plain("storeWorkoutRoutes"),
|
||||
// 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: instant("onboardingCompletedAt"),
|
||||
onboardingTourCompleted: plain("onboardingTourCompleted"),
|
||||
onboardingTourProgressJson: json("onboardingTourProgressJson"),
|
||||
disclaimerAcknowledgedAt: instant("disclaimerAcknowledgedAt"),
|
||||
disclaimerAcknowledgedVersion: plain("disclaimerAcknowledgedVersion"),
|
||||
passkeyUpgradeNudgeDismissed: plain("passkeyUpgradeNudgeDismissed"),
|
||||
} as const satisfies Readonly<Record<string, SettingCodec>>;
|
||||
|
||||
/** The `User` columns the `accountSettings` section carries. */
|
||||
export type AccountSettingColumn = keyof typeof ACCOUNT_SETTING_CODECS;
|
||||
|
||||
/**
|
||||
* Every column of the `User` row, and whether it travels: the settings from
|
||||
* the codec table above, every other column from
|
||||
* `account-settings-classes.ts`, which says what each verdict means.
|
||||
* `src/__tests__/user-column-backup-classification.test.ts` reads the schema
|
||||
* and fails on a column that has neither.
|
||||
*/
|
||||
export const USER_COLUMN_BACKUP_CLASS: Readonly<
|
||||
Record<
|
||||
keyof typeof USER_COLUMNS_OUTSIDE_SETTINGS | AccountSettingColumn,
|
||||
UserColumnBackupClass
|
||||
>
|
||||
> = {
|
||||
...USER_COLUMNS_OUTSIDE_SETTINGS,
|
||||
...(Object.fromEntries(
|
||||
Object.keys(ACCOUNT_SETTING_CODECS).map((column) => [column, "SETTING"]),
|
||||
) as Record<AccountSettingColumn, "SETTING">),
|
||||
};
|
||||
|
||||
/** Every setting column, in the order the codec table declares them. */
|
||||
export const ACCOUNT_SETTING_COLUMNS = Object.keys(
|
||||
ACCOUNT_SETTING_CODECS,
|
||||
) as AccountSettingColumn[];
|
||||
|
||||
/** Exactly the setting columns, so the read is the declaration. */
|
||||
const SETTINGS_SELECT = Object.fromEntries(
|
||||
ACCOUNT_SETTING_COLUMNS.map((column) => [column, true]),
|
||||
@@ -661,68 +704,3 @@ export function admitAccountSettings(
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* The verdict for every `User` column that is not a setting.
|
||||
*
|
||||
* Part of the account-settings domain: `account-settings-backup.ts` composes
|
||||
* these with the settings its codec table declares into
|
||||
* `USER_COLUMN_BACKUP_CLASS`.
|
||||
*/
|
||||
/**
|
||||
* Every column of the `User` row, and whether it travels.
|
||||
*
|
||||
* The models (`backup-plan.ts`) 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. A setting is a column with a
|
||||
* codec in `ACCOUNT_SETTING_CODECS`
|
||||
* (`account-settings-backup.ts`), which is where its verdict
|
||||
* comes from; this file holds every other verdict.
|
||||
* `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` in the backup plan.
|
||||
* `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 from the backup.
|
||||
*
|
||||
* `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 type UserColumnBackupClass =
|
||||
"SETTING" | "CREDENTIAL" | "IDENTITY" | "OPERATIONAL";
|
||||
|
||||
/** The `User` columns that are not settings, each with its verdict. */
|
||||
export const USER_COLUMNS_OUTSIDE_SETTINGS = {
|
||||
// ── 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",
|
||||
// The Dawarich connection, credentials like the Nightscout pair. The
|
||||
// sealed preferences name a place id on that instance, which means nothing
|
||||
// without the key, so they stay behind with it.
|
||||
dawarichUrlEncrypted: "CREDENTIAL",
|
||||
dawarichApiKeyEncrypted: "CREDENTIAL",
|
||||
dawarichPrefsEncrypted: "CREDENTIAL",
|
||||
dawarichAllowPrivateHost: "CREDENTIAL",
|
||||
polarAccessTokenEncrypted: "CREDENTIAL",
|
||||
polarUserIdEncrypted: "CREDENTIAL",
|
||||
polarClientIdEncrypted: "CREDENTIAL",
|
||||
polarClientSecretEncrypted: "CREDENTIAL",
|
||||
ouraAccessTokenEncrypted: "CREDENTIAL",
|
||||
ouraRefreshTokenEncrypted: "CREDENTIAL",
|
||||
ouraClientIdEncrypted: "CREDENTIAL",
|
||||
ouraClientSecretEncrypted: "CREDENTIAL",
|
||||
// What the Oura grant on this host does not cover; meaningless beside a
|
||||
// grant that does not travel, and found out again by the first sync.
|
||||
ouraScopeDenied: "CREDENTIAL",
|
||||
ouraUserId: "CREDENTIAL",
|
||||
// Names the guardian whose provider app the record borrows; the app itself
|
||||
// does not travel, so neither does the marker.
|
||||
clientAppSharedBy: "CREDENTIAL",
|
||||
stravaClientIdEncrypted: "CREDENTIAL",
|
||||
stravaClientSecretEncrypted: "CREDENTIAL",
|
||||
stravaAccessTokenEncrypted: "CREDENTIAL",
|
||||
stravaRefreshTokenEncrypted: "CREDENTIAL",
|
||||
stravaAthleteId: "CREDENTIAL",
|
||||
|
||||
// ── Operational ───────────────────────────────────────────────────────────
|
||||
insightsCachedAt: "OPERATIONAL",
|
||||
insightsCachedTextEncrypted: "OPERATIONAL",
|
||||
insightsCachedLocale: "OPERATIONAL",
|
||||
insightsSnapshotHash: "OPERATIONAL",
|
||||
insightsWarmFailedAt: "OPERATIONAL",
|
||||
insightsBriefingRerollDate: "OPERATIONAL",
|
||||
morningDigestRefreshedOn: "OPERATIONAL",
|
||||
coachLastSeenAt: "OPERATIONAL",
|
||||
stravaLastActivityAt: "OPERATIONAL",
|
||||
stravaBackfillCompletedAt: "OPERATIONAL",
|
||||
stravaBackfillResumeBefore: "OPERATIONAL",
|
||||
// The restored account's Oura backfill reads its history again from the
|
||||
// provider; a carried note would claim history this host never imported.
|
||||
ouraBackfillProgress: "OPERATIONAL",
|
||||
ouraBackfillCompletedAt: "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",
|
||||
// The air-quality history backfill's progress; the restored
|
||||
// account's job recounts it on its next run.
|
||||
environmentAqHistoryJson: "OPERATIONAL",
|
||||
} as const satisfies Readonly<
|
||||
Record<string, Exclude<UserColumnBackupClass, "SETTING">>
|
||||
>;
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The restore half of the account-settings domain: write what
|
||||
* `admitAccountSettings` admits onto the account row, inside the restore's
|
||||
* transaction.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
admitAccountSettings,
|
||||
type AccountSettingsBackupEntry,
|
||||
} from "@/lib/export/account-settings-backup";
|
||||
|
||||
/** 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),
|
||||
};
|
||||
}
|
||||
@@ -836,241 +836,6 @@ 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",
|
||||
// v1.43 — the Dawarich connection, credentials like the Nightscout pair. The
|
||||
// sealed preferences name a place id on that instance, which means nothing
|
||||
// without the key, so they stay behind with it.
|
||||
dawarichUrlEncrypted: "CREDENTIAL",
|
||||
dawarichApiKeyEncrypted: "CREDENTIAL",
|
||||
dawarichPrefsEncrypted: "CREDENTIAL",
|
||||
dawarichAllowPrivateHost: "CREDENTIAL",
|
||||
polarAccessTokenEncrypted: "CREDENTIAL",
|
||||
polarUserIdEncrypted: "CREDENTIAL",
|
||||
polarClientIdEncrypted: "CREDENTIAL",
|
||||
polarClientSecretEncrypted: "CREDENTIAL",
|
||||
ouraAccessTokenEncrypted: "CREDENTIAL",
|
||||
ouraRefreshTokenEncrypted: "CREDENTIAL",
|
||||
ouraClientIdEncrypted: "CREDENTIAL",
|
||||
ouraClientSecretEncrypted: "CREDENTIAL",
|
||||
// What the Oura grant on this host does not cover; meaningless beside a
|
||||
// grant that does not travel, and found out again by the first sync.
|
||||
ouraScopeDenied: "CREDENTIAL",
|
||||
ouraUserId: "CREDENTIAL",
|
||||
// Names the guardian whose provider app the record borrows; the app itself
|
||||
// does not travel, so neither does the marker.
|
||||
clientAppSharedBy: "CREDENTIAL",
|
||||
stravaClientIdEncrypted: "CREDENTIAL",
|
||||
stravaClientSecretEncrypted: "CREDENTIAL",
|
||||
stravaAccessTokenEncrypted: "CREDENTIAL",
|
||||
stravaRefreshTokenEncrypted: "CREDENTIAL",
|
||||
stravaAthleteId: "CREDENTIAL",
|
||||
|
||||
// ── Operational ───────────────────────────────────────────────────────────
|
||||
insightsCachedAt: "OPERATIONAL",
|
||||
insightsCachedTextEncrypted: "OPERATIONAL",
|
||||
insightsCachedLocale: "OPERATIONAL",
|
||||
insightsSnapshotHash: "OPERATIONAL",
|
||||
insightsWarmFailedAt: "OPERATIONAL",
|
||||
insightsBriefingRerollDate: "OPERATIONAL",
|
||||
morningDigestRefreshedOn: "OPERATIONAL",
|
||||
coachLastSeenAt: "OPERATIONAL",
|
||||
stravaLastActivityAt: "OPERATIONAL",
|
||||
stravaBackfillCompletedAt: "OPERATIONAL",
|
||||
stravaBackfillResumeBefore: "OPERATIONAL",
|
||||
// The restored account's Oura backfill reads its history again from the
|
||||
// provider; a carried note would claim history this host never imported.
|
||||
ouraBackfillProgress: "OPERATIONAL",
|
||||
ouraBackfillCompletedAt: "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",
|
||||
// v1.42 — the air-quality history backfill's progress; the restored
|
||||
// account's job recounts it on its next run.
|
||||
environmentAqHistoryJson: "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.
|
||||
homeTimezone: "SETTING",
|
||||
homeSince: "SETTING",
|
||||
// v1.42 (#615) — the sealed home location (the only copy since v1.43),
|
||||
// and the account's switch for the air-quality part of the module.
|
||||
homeLocationEncrypted: "SETTING",
|
||||
environmentAirQualityEnabled: "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",
|
||||
documentsLayoutJson: "SETTING",
|
||||
moduleListLayoutJson: "SETTING",
|
||||
previewFeaturesJson: "SETTING",
|
||||
activityGoalsJson: "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",
|
||||
// v1.43 (#1217) — the account's own AI usage level; NULL follows the
|
||||
// instance default, and a restored level above this host's ceiling is
|
||||
// capped at read time like any stored choice.
|
||||
aiUsageLevel: "SETTING",
|
||||
coachPrefsJson: "SETTING",
|
||||
documentsAutoAiRead: "SETTING",
|
||||
labsLocalOcrEnabled: "SETTING",
|
||||
storeWorkoutRoutes: "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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -100,7 +100,7 @@ import { restoreOnboardingData } from "@/lib/export/onboarding-backup";
|
||||
import {
|
||||
restoreAccountSettings,
|
||||
type AccountSettingsRestoreResult,
|
||||
} from "@/lib/export/account-settings-backup";
|
||||
} from "@/lib/export/account-settings-restore";
|
||||
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
|
||||
import { restoreVisitsData } from "@/lib/export/visits-backup";
|
||||
import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup";
|
||||
|
||||
@@ -74,12 +74,11 @@ import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids";
|
||||
import { createBackupPreviewCollector } from "@/lib/export/backup-preview";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { summarizeBackup } from "@/lib/validations/backup-summary";
|
||||
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
|
||||
import {
|
||||
TWO_ENDED_MODELS,
|
||||
ACCOUNT_SETTING_COLUMNS,
|
||||
USER_COLUMN_BACKUP_CLASS,
|
||||
type TwoEndedModel,
|
||||
} from "@/lib/export/backup-plan";
|
||||
import { ACCOUNT_SETTING_COLUMNS } from "@/lib/export/account-settings-backup";
|
||||
} from "@/lib/export/account-settings-backup";
|
||||
import { parseOnboardingSteps } from "@/lib/onboarding/needs";
|
||||
import { POST } from "./restore-job-driver";
|
||||
import {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* restored into a fresh account, and read back there.
|
||||
*
|
||||
* The table below has one entry per write, and every `User` column the backup
|
||||
* classifies as a `SETTING` (`USER_COLUMN_BACKUP_CLASS` in
|
||||
* `src/lib/export/backup-plan.ts`) appears in exactly one entry. A new setting
|
||||
* classifies as a `SETTING` (a codec in `ACCOUNT_SETTING_CODECS`,
|
||||
* `src/lib/export/account-settings-backup.ts`) appears in exactly one entry. A new setting
|
||||
* column fails the inventory case until it is given an entry, which is the
|
||||
* point: the class of bug this file exists for is a setting that is stored but
|
||||
* not read back, read back but not published, or published but dropped by the
|
||||
@@ -29,7 +29,7 @@ import type { User } from "@/generated/prisma/client";
|
||||
import {
|
||||
USER_COLUMN_BACKUP_CLASS,
|
||||
type AccountSettingColumn,
|
||||
} from "@/lib/export/backup-plan";
|
||||
} from "@/lib/export/account-settings-backup";
|
||||
|
||||
import { GET as getMe } from "@/app/api/auth/me/route";
|
||||
import * as profile from "@/app/api/user/profile/route";
|
||||
|
||||
Reference in new issue
Block a user