mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
`MoodPrediction` holds the expected value of a day beside the day's own rating: one row per account per local day, with a band and the number of days the fit used. Two tables rather than four columns on the entry, because the point of the second value is that the person cannot correct it into agreement with the first — the distance between them is the finding, and a value the edit form can write is not a second reading. The table stays out of the disaster-recovery backup and the reason is written where the completeness guard reads it: every row is recomputable from the entries, the contexts and the linked modules, and carrying one would restore a forecast made by a model version that may no longer exist against data the restore has just moved. It is in the wipe plan, because a derived row is still a day-by-day statement about somebody. The write-surface guard lands red on purpose and names what is missing: `lib/jobs/mood-prognosis-refresh.ts` does not exist yet, so the writer set is empty where it should hold exactly that one file. The job closes it. The rest of the guard is green now — no route, page or component writes the table, and the matcher proves itself against a call broken across lines, an aliased client, a read and a different model.
576 lines
25 KiB
TypeScript
576 lines
25 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",
|
|
"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<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,
|
|
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<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({}) };
|
|
}
|