/** * The one declaration of what "Delete All Data" means. * * The route this feeds used to carry its delete calls inline. That list was * correct on the day it was written and then stopped being correct, silently, * every time a model was added to `prisma/schema.prisma` — a new table is * user-scoped from birth and outside the wipe by default, and nothing anywhere * noticed. Sixty-odd tables accumulated on the wrong side of a confirmation * dialog that says "permanently delete all your health data". * * So the list lives here, next to its own exemption list, and * `src/__tests__/data-wipe-completeness.test.ts` enumerates the schema and * fails when a model belongs to neither. Adding a model to the schema now * forces a decision instead of granting a default. * * ── The boundary ─────────────────────────────────────────────────────────── * * This action erases everything the account owns EXCEPT what is needed to sign * in again. Removing the sign-in credentials too would not be data deletion, * it would be account deletion — and that has its own route * (`src/app/api/settings/account/route.ts`, which deletes the `User` row and * lets the schema's `onDelete: Cascade` do the rest). * * Everything in {@link WIPE_MODELS} is hard-deleted, including rows that were * only soft-deleted before: `deleteMany({ where: { userId } })` carries no * `deletedAt` filter, so a tombstone goes with the record. * * ── Why the list is ordered ──────────────────────────────────────────────── * * Correctness does not depend on the order — every child of a wiped model * cascades at the database level. The order exists so the per-model counts in * the audit row are truthful: delete a parent first and its children are gone * before their own `deleteMany` runs, which would report 0 for rows that did * exist. Children therefore come before their parents, and the completeness * test asserts that property rather than trusting the author. */ import { Prisma } from "@/generated/prisma/client"; /** * Every user-scoped model this action deletes, children before parents. * * Names are Prisma model names; {@link wipeDelegateKey} maps each to its * client delegate. Keeping model names (not delegate keys) is what lets the * completeness test compare this list against `schema.prisma` literally. */ export const WIPE_MODELS = [ // ── Measurements and their derived tiers ──────────────────────────────── "Measurement", // The rollup tier is a per-day, per-metric reconstruction of the same // history. A wipe that leaves it behind is not a wipe. "MeasurementRollup", // The intraday cumulative profile is the hourly shape of a day that the // fold kept after deleting the samples it came from. It is that day's // record and nothing else holds it, so a wipe that leaves it behind leaves // behind an account's activity pattern. "IntradayCumulativeProfile", // The health score as it was shown, one row per local day. Not a cache of // the live computation — nothing can rebuild a past day once the readings // behind it have moved — so leaving it behind leaves behind a record of how // the account was doing, day by day, after a confirmation that said // otherwise. "HealthScoreRecord", "MeasurementReminder", // ── Medication ────────────────────────────────────────────────────────── "MedicationIntakeEvent", "MedicationPauseEra", "MedicationSideEffect", "MedicationInventoryItem", "MedicationIntakeImportJob", "MedicationComplianceRollup", // ── Mood ──────────────────────────────────────────────────────────────── // The day context, before the entry it hangs off: it cascades either way, // but a parent deleted first leaves the child's own count reporting zero for // rows that existed, and the confirmation screen quotes those counts. "MoodContext", // Derived, and wiped anyway. The forecast rows are recomputable from the // entries above — which is why they stay out of the backup — but they are // still a day-by-day statement about somebody's mood, and leaving them // behind after a screen that promised to delete all health data would leave // the shape of the record standing where the record used to be. "MoodPrediction", "MoodEntry", "MoodEntryRollup", "MoodReminderDispatch", "MoodTagHidden", // ── Clinical record ───────────────────────────────────────────────────── "MentalHealthAssessment", "LabResult", "Biomarker", "CustomMetricEntry", "MenstrualCycle", "CycleDayLog", "CycleSymptom", "CycleProfile", "CyclePrediction", "Allergy", "FamilyHistoryEntry", "IllnessDayLog", "EcgRecording", "NutrientIntakeDay", // ── Activity ──────────────────────────────────────────────────────────── "WorkoutInsight", "WorkoutInsightGenerationClaim", "PersonalRecord", "StrainTrimpCache", // ── Context and engagement ────────────────────────────────────────────── "EnvironmentContext", "EnvironmentTravelLocation", "HealthProfileFactRevision", "CorrelationPattern", "UserHealthProfile", "UserAchievement", "ArrivalReaction", "DismissedPriorityItem", "RecommendationFeedback", // ── Documents ─────────────────────────────────────────────────────────── "DocumentContentIndex", "DocumentThumbnail", "DocumentConditionLink", "ExtractedFact", "ImportJob", // ── AI ────────────────────────────────────────────────────────────────── "CoachConversation", "CoachFact", "CoachPlan", "CoachReminder", "CoachUsage", "InsightNarrative", "ProviderHealth", // ── Sharing ───────────────────────────────────────────────────────────── // Deleting the row is the revocation: `resolveShareToken` / // `resolveShareGateState` both resolve an unknown `tokenHash` to `null`, and // null is the same blunt 404 a revoked link gets. "ClinicianShareLink", // v1.36.0 — account grants, on BOTH sides (see WIPE_OWNER_FIELDS): the ones // this account made over its own record, and the ones it holds over someone // else's. // // Deleting rather than revoking, and the difference is worth stating because // the domain module refuses to delete these rows anywhere else. Revocation // preserves the row precisely because the row is the consent record — but a // person who types the confirmation on "Delete All Data" is asking for the // record itself to be gone, and the same request already takes their entire // audit history and their consent receipts with it. Leaving a grant behind // would also leave a delegate holding live access to an account that just // erased everything it had. "AccountGrant", // ── Integrations ──────────────────────────────────────────────────────── "WithingsConnection", "WithingsOAuthState", "WhoopConnection", "WhoopOAuthState", "WhoopConnectTicket", "FitbitConnection", "FitbitOAuthState", "GoogleHealthConnection", "GoogleHealthOAuthState", "McpOAuthConnection", "IntegrationStatus", // ── Notification transports ───────────────────────────────────────────── "NotificationChannel", "PushSubscription", "PushAttempt", "NotificationEvent", // A Guardian egress claim belongs to both the record whose data prompted // the notification and the recipient whose channel would receive it. A // wipe by either account must remove it; see WIPE_OWNER_FIELDS below. "NotificationEgressAuthorization", "Device", "TelegramScheduledDeletion", "TelegramPromptContext", // ── API surface and operational trails ────────────────────────────────── "StepUpElevation", // Cached response envelopes — the bodies are the health payloads the API // just returned. "IdempotencyKey", "DataBackup", "AuditLog", "ConsentReceipt", "UserKnownDevice", // ── Parents, last ─────────────────────────────────────────────────────── "Medication", "MoodTag", "MoodTagCategory", "CustomMetric", "IllnessEpisode", "Workout", "InboundDocument", "ApiToken", ] as const; /** * User-scoped models this action deliberately keeps, each with the reason. * * The rule is narrow and stated in the confirmation copy: what the account * needs to sign in again survives. Anything added here that is not a sign-in * credential needs a reason that survives being read aloud to the person who * typed the confirmation. */ export const WIPE_EXEMPT: Readonly> = { Session: "the signed-in session performing the wipe; deleting it signs the person out mid-action and the account is preserved", RefreshToken: "per-device sign-in continuity; deleting it signs every paired device out and is account deletion by another name", Passkey: "a sign-in credential — without it the account may be unreachable", WebauthnMfaCredential: "a second-factor credential; removing it weakens the account rather than clearing data", MfaRecoveryCode: "the second-factor recovery path; removing it can lock the account out permanently", MfaChallenge: "an in-flight authentication challenge, seconds-lived and swept by its own expiry", AuthChallenge: "an in-flight WebAuthn challenge, seconds-lived and swept by its own expiry", TrustedDevice: "the remember-this-device grant that suppresses a second-factor prompt; a sign-in credential", OidcNativeHandoff: "an in-flight one-time sign-in code, minutes-lived and swept by its own expiry", InviteRedemption: "the instance's registration ledger, not the person's record; even account deletion only detaches it (onDelete: SetNull) rather than removing it", }; /** * Models that carry no `userId` and belong to the instance rather than to any * account, so no wipe of a single account may touch them. * * Everything else without a `userId` reaches a wiped or exempt model through a * chain of `onDelete: Cascade` relations and needs no entry here; the * completeness test proves that reachability rather than assuming it. */ export const INSTANCE_SCOPED: Readonly> = { AppSettings: "instance-wide configuration singleton", RateLimit: "instance-wide rate-limit buckets, keyed by string, self-expiring", HostMetric: "instance-wide host telemetry, not attributable to an account", CycleSymptomCategory: "seeded catalogue shared by every account; a user's custom symptoms live on CycleSymptom", IllnessSymptom: "seeded symptom catalogue shared by every account", InviteToken: "the instance's invite ledger; the creator relation cascades on account deletion, and a data wipe keeps the account", User: "the account itself — preserved by definition; its columns are handled by USER_RESET / USER_KEPT_FIELDS", }; /** * The `User` columns this action clears, and the value each is reset to. * * The same rot applies here: a column added to `User` is personal data from * birth and outside the reset by default. The completeness test compares these * keys against the model's scalar columns, so a new column has to be * classified. * * `Prisma.DbNull` rather than `null` for the JSON columns — on a nullable JSON * field `null` is the JSON value `null`, not SQL NULL. */ export const USER_RESET = { // Body and identity-of-the-record heightCm: null, dateOfBirth: null, gender: null, hasDiabetes: false, fullName: null, displayName: null, insurerName: null, insuranceNumberEncrypted: null, insurerIkNumber: null, lastReportPracticeName: null, avatarBytes: null, avatarContentType: null, avatarUpdatedAt: null, // Where the person lives — an environment-context input, and a location homeLat: null, homeLon: null, homeLabel: null, homeTimezone: null, homeSince: null, // Derived AI output cached on the row insightsPrivacyMode: "aggregated", insightsCachedAt: null, insightsCachedText: null, insightsSnapshotHash: null, insightsWarmFailedAt: null, insightsBriefingRerollDate: null, morningDigestRefreshedOn: null, insightsExcludeMetrics: [], insightsLayoutJson: Prisma.DbNull, // AI provider configuration and keys aiProvider: null, aiModel: null, aiBaseUrl: null, aiAnthropicKeyEncrypted: null, aiLocalKeyEncrypted: null, aiOpenaiKeyEncrypted: null, aiCompatBaseUrl: null, aiCompatKeyEncrypted: null, aiCompatModel: null, aiProviderChain: Prisma.DbNull, aiResponseTimeoutSeconds: null, disableCoach: false, coachPrefsJson: Prisma.DbNull, coachLastSeenAt: null, documentsAutoAiRead: false, labsLocalOcrEnabled: false, codexAccessTokenEncrypted: null, codexRefreshTokenEncrypted: null, codexTokenExpiresAt: null, codexConnectedAt: null, codexConnectionStatus: "disconnected", useCentralCodex: false, // Integration credentials and per-integration state telegramBotToken: null, telegramChatId: null, telegramEnabled: false, withingsClientIdEncrypted: null, withingsClientSecretEncrypted: null, whoopClientIdEncrypted: null, whoopClientSecretEncrypted: null, fitbitClientIdEncrypted: null, fitbitClientSecretEncrypted: null, googleHealthClientIdEncrypted: null, googleHealthClientSecretEncrypted: null, nightscoutUrlEncrypted: null, nightscoutTokenEncrypted: null, nightscoutAllowPrivateHost: false, polarAccessTokenEncrypted: null, polarUserIdEncrypted: null, polarClientIdEncrypted: null, polarClientSecretEncrypted: null, ouraAccessTokenEncrypted: null, ouraRefreshTokenEncrypted: null, ouraClientIdEncrypted: null, ouraClientSecretEncrypted: null, stravaClientIdEncrypted: null, stravaClientSecretEncrypted: null, stravaAccessTokenEncrypted: null, stravaRefreshTokenEncrypted: null, stravaAthleteId: null, stravaLastActivityAt: null, stravaBackfillCompletedAt: null, healthKitConfigJson: Prisma.DbNull, healthKitLastSyncedAt: null, healthKitLastSyncTrigger: null, healthKitLastBackgroundSyncAt: null, lastSyncedAt: null, sourcePriorityJson: Prisma.DbNull, // Choices that describe the record rather than the interface thresholdsJson: Prisma.DbNull, dashboardWidgetsJson: Prisma.DbNull, medicationListLayoutJson: Prisma.DbNull, moodTagLayoutJson: Prisma.DbNull, reportSelectionJson: Prisma.DbNull, modulePreferencesJson: Prisma.DbNull, healthScoreConfigJson: Prisma.DbNull, globalExcludedInjectionSites: [], moodReminderEnabled: false, notificationPrefs: Prisma.DbNull, researchModeEnabled: false, researchModeAcknowledgedAt: null, researchModeAcknowledgedVersion: null, // Back to the start of the tour — the record it was built around is gone onboardingCompletedAt: null, onboardingStep: 0, onboardingGoals: [], onboardingTourCompleted: false, onboardingTourProgressJson: Prisma.DbNull, } satisfies Prisma.UserUpdateInput; /** * `User` columns the wipe leaves alone, each with the reason. Same contract as * {@link WIPE_EXEMPT}: sign-in, identity, and how the interface is rendered. */ export const USER_KEPT_FIELDS: Readonly> = { id: "the account's primary key", username: "the account's sign-in identity", email: "the account's sign-in identity and recovery address", passwordHash: "a sign-in credential — the account password", role: "the account's authorisation level, set by the operator", oidcIssuer: "a sign-in credential — the external identity binding", oidcSub: "a sign-in credential — the external identity binding", createdAt: "when the account was created; the account is preserved", updatedAt: "row bookkeeping maintained by Prisma", managedProfileAt: "whether this record is one somebody else runs — account shape, not record content. Clearing it would strip the guardian's reach from a record that has no credentials of its own, leaving an account nobody can open, administer or delete. Emptying a record is not emancipating the person in it.", totpSecretEncrypted: "a second-factor credential", totpConfirmedAt: "second-factor enrolment state", totpLastStep: "second-factor replay guard; clearing it re-opens a replay window", mfaEnforced: "the operator's per-account second-factor policy", passkeyUpgradeNudgeDismissed: "an interface nudge the person already dismissed", timezone: "an interface preference — how dates and times are rendered", locale: "an interface preference — which language the interface speaks", unitPreference: "an interface preference — metric or imperial rendering", timeFormat: "an interface preference — 12- or 24-hour rendering", dateFormat: "an interface preference — date-order rendering", glucoseUnit: "an interface preference — mg/dL or mmol/L rendering", disclaimerAcknowledgedAt: "the account's acknowledgement of the medical disclaimer, which is about the app and not about the record", disclaimerAcknowledgedVersion: "the version of the medical disclaimer the account acknowledged", documentQuotaBytes: "a per-account storage quota set by the operator, not by the person", }; /** * Wiped models that do NOT carry a `userId`, and the columns that say the row * belongs to an account. * * The convention is one `userId` column and almost every table keeps it. A * table that relates two accounts cannot: an account grant names an owner and * a delegate, and a wipe by either of them has to take the row. * * Declared here rather than at the wipe route, so the route keeps asking for * exactly one thing — "delete this model's rows for this account" — and the * question of what "for this account" means for a given model stays in the * file that already answers every other question about the wipe. * {@link resolveWipeDelegate} translates; the route is unaware. */ export const WIPE_OWNER_FIELDS: Readonly> = { // The record owner owns the import payload. The actor is durable audit // attribution and must not let deleting a manager erase another record's // import history. MedicationIntakeImportJob: ["recordUserId"], NotificationEvent: ["recordUserId"], NotificationEgressAuthorization: ["recordUserId", "recipientUserId"], // Both sides. Wiping only the grantor side would leave the account still // holding read access to other people's records after it asked for // everything of its own to be deleted. AccountGrant: ["grantorId", "granteeId"], }; /** * Models the ADMIN wipe keeps although the per-account wipe deletes them. * * `DELETE /api/admin/data` is the same action pointed at every account at * once: the operator clearing a box before handing it on. It therefore reads * {@link WIPE_MODELS} rather than a list of its own — that list was written * inline once and had drifted to nine tables against a schema of a hundred and * twenty-two by the time anybody counted. * * The two actions differ in exactly one place, and it is written here rather * than folded into the route so the difference has to be argued for. */ export const ADMIN_WIPE_EXEMPT: Readonly> = { AccountGrant: "a grant is the only way into a managed profile — that record has no credentials of its own, so deleting every grant at once would leave a set of accounts nobody can open, administer or delete. This action preserves accounts by definition, and an access edge without the record behind it is not personal data: everything those profiles hold is wiped with everyone else's.", }; /** * Every model the admin wipe deletes, for all accounts, children first. * * Derived, so a model added to {@link WIPE_MODELS} is in both wipes at once * and a model excused from this one has to say why above. */ export const ADMIN_WIPE_MODELS: readonly string[] = WIPE_MODELS.filter( (model) => !(model in ADMIN_WIPE_EXEMPT), ); /** * Budget for a wipe transaction. * * Prisma's default interactive-transaction timeout is 5 s, which was already * optimistic for thirteen `deleteMany` statements and is not survivable for * the full model set on an account with a large import behind it — let alone * for the admin wipe, which runs the same set across every account. The whole * action is one transaction on purpose: a half-applied wipe is worse than * none, because the person is told it succeeded either way. */ export const WIPE_TRANSACTION_TIMEOUT_MS = 120_000; export const WIPE_TRANSACTION_MAX_WAIT_MS = 15_000; /** Prisma client delegate key for a model name (`MoodEntry` → `moodEntry`). */ export function wipeDelegateKey(model: string): string { return model.charAt(0).toLowerCase() + model.slice(1); } /** * The `where` that selects one account's rows of `model`. * * `{ userId }` for the convention, an `OR` over the declared owner columns for * the models that relate two accounts. */ export function wipeWhere(model: string, userId: string): object { const fields = WIPE_OWNER_FIELDS[model]; if (!fields) return { userId }; return { OR: fields.map((field) => ({ [field]: userId })) }; } /** The narrow slice of a Prisma delegate the wipe loop uses. */ export interface UserScopedDeleteDelegate { deleteMany(args: { where: { userId: string } }): Promise<{ count: number }>; } /** The same slice for the admin wipe, which selects no account at all. */ export interface UnscopedDeleteDelegate { deleteMany(args: Record): Promise<{ count: number }>; } interface RawDeleteDelegate { deleteMany(args: { where?: object }): Promise<{ count: number }>; } /** * The Prisma delegate for a model name, or a thrown error naming the model. * * One documented cast: {@link WIPE_MODELS} is a literal list of model names * checked against `schema.prisma` by the completeness test, and every entry * indexes a delegate that exposes `deleteMany`. The test also asserts every * entry resolves, so an unresolvable name fails the build rather than the * request. */ function rawDelegate(client: object, model: string): RawDeleteDelegate { const delegate = (client as Record)[ wipeDelegateKey(model) ]; if (!delegate || typeof delegate.deleteMany !== "function") { throw new Error(`No Prisma delegate for wipe model "${model}"`); } return delegate; } /** * Resolve a model name to its delegate on a client or transaction handle. * * A model listed in {@link WIPE_OWNER_FIELDS} gets a wrapper that rewrites the * caller's `{ userId }` into that model's own ownership predicate. The caller * still says "this account's rows" and still gets a count back; only the * translation moved, and it moved to the file that declares the columns rather * than to the loop that does not know about them. */ export function resolveWipeDelegate( client: object, model: string, ): UserScopedDeleteDelegate { const delegate = rawDelegate(client, model); if (!WIPE_OWNER_FIELDS[model]) { return delegate as UserScopedDeleteDelegate; } return { deleteMany: ({ where }) => delegate.deleteMany({ where: wipeWhere(model, where.userId) }), }; } /** * Resolve a model name to a delegate that deletes every row of it. * * The admin wipe asks a different question from the per-account one — "every * row of this model" rather than "this account's rows" — so it gets its own * resolver instead of a `where` the caller has to remember to leave out. * {@link WIPE_OWNER_FIELDS} is irrelevant here by construction: with no * account to select, a row that belongs to two of them is deleted once. */ export function resolveGlobalWipeDelegate( client: object, model: string, ): UnscopedDeleteDelegate { const delegate = rawDelegate(client, model); return { deleteMany: () => delegate.deleteMany({}) }; }