mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
612 lines
27 KiB
TypeScript
612 lines
27 KiB
TypeScript
/**
|
|
* 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",
|
|
// The completion ledger before the reminder it hangs off: it cascades either
|
|
// way, but the child deleted first is what keeps its own count truthful on
|
|
// the audit row (v1.37.20, #223 / iOS #68).
|
|
"MeasurementReminderEvent",
|
|
"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",
|
|
|
|
// ── Visits ──────────────────────────────────────────────────────────────
|
|
// The three link tables cascade from the encounter AND from the far side
|
|
// they point at — a lab result, a document, a condition episode — so they
|
|
// are deleted before any of those, or their per-model counts read zero on an
|
|
// audit row that claims to say what was removed. The encounter follows its
|
|
// own links; the address book it points at is a parent and goes last.
|
|
"EncounterDocumentLink",
|
|
"EncounterLabLink",
|
|
"EncounterConditionLink",
|
|
"Encounter",
|
|
|
|
// ── Vaccinations ────────────────────────────────────────────────────────
|
|
// Same ordering argument as the visit links: the document link cascades
|
|
// from the dose AND from the stored document, so it is deleted before
|
|
// either or its count reads zero on the audit row. The dose points at a
|
|
// practitioner and an encounter with SetNull, which is why it goes here,
|
|
// ahead of both.
|
|
"VaccinationDocumentLink",
|
|
"VaccinationRecord",
|
|
|
|
// ── 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",
|
|
// What this host last put in the operator's bucket for this account. The
|
|
// objects themselves stay — the worker holds no DeleteObject grant and the
|
|
// bucket's lifecycle rule owns their retirement — but the row saying when
|
|
// they were written is about this account and goes with it.
|
|
"OffhostBackupState",
|
|
"AuditLog",
|
|
"ConsentReceipt",
|
|
"UserKnownDevice",
|
|
// v1.39 — the setup answers and the step ledger. The USER_RESET block below
|
|
// already puts the wizard of today back to the start; this is the same
|
|
// decision for the needs-based flow, and it belongs on the same side of the
|
|
// line for the same reason: the record the answers were about is gone, so
|
|
// the answers describe nothing.
|
|
"OnboardingRecord",
|
|
|
|
// ── Parents, last ───────────────────────────────────────────────────────
|
|
"Medication",
|
|
"MoodTag",
|
|
"MoodTagCategory",
|
|
"CustomMetric",
|
|
"IllnessEpisode",
|
|
"Workout",
|
|
"InboundDocument",
|
|
// Encounters point here with SetNull, so the address book outlives the
|
|
// visits it is attached to and is removed after them.
|
|
"Practitioner",
|
|
"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<Record<string, string>> = {
|
|
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<Record<string, string>> = {
|
|
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,
|
|
insightsCachedLocale: 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,
|
|
|
|
// 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<Record<string, string>> = {
|
|
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<Record<string, readonly string[]>> = {
|
|
// 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<Record<string, string>> = {
|
|
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<string, never>): 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<string, RawDeleteDelegate>)[
|
|
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({}) };
|
|
}
|