import { and, eq, lte } from "drizzle-orm" import { db } from "@/lib/db" import { user as userTable, profiles, verification, consent_log, admin_audit_log, account_deletion_requests, } from "@/lib/db/schema" import { deleteUserStorage } from "@/lib/storage" import { stripe } from "@/lib/stripe/client" import { sendEmail, accountDeletionCompletedHtml } from "@/lib/email/send" // ============================================================================ // GDPR account deletion (Article 17 — right to erasure). // // The DB schema does most of the cascading for us: every data table references // profiles(id) ON DELETE CASCADE, and profiles references user(id) ON DELETE // CASCADE — so deleting the auth user row erases the entire portfolio, // sessions, and linked accounts in one statement. What the cascade CANNOT // reach lives here: uploaded files in object storage, the Stripe // subscription/customer, email-keyed verification rows, and PII embedded in // the FK-less compliance tables (consent_log). // ============================================================================ export type DeletionOutcome = { userId: string filesDeleted: number stripeSubscription: "cancelled" | "none" | "error" stripeCustomer: "deleted" | "none" | "error" /** PayPal has no server-side cancel integration — surfaced so ops can follow up. */ paypalSubscriptionLeftActive: string | null userRowDeleted: boolean } /** * Irreversibly delete a user's account, data, files, and billing. Safe to call * for an already-deleted user (it still purges storage and returns cleanly). */ export async function executeAccountDeletion(userId: string): Promise { const outcome: DeletionOutcome = { userId, filesDeleted: 0, stripeSubscription: "none", stripeCustomer: "none", paypalSubscriptionLeftActive: null, userRowDeleted: false, } const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, userId), columns: { email: true, stripe_subscription_id: true, stripe_customer_id: true, paypal_subscription_id: true, }, }) // 1. Billing — stop money moving before the data disappears. if (profile?.stripe_subscription_id) { try { await stripe.subscriptions.cancel(profile.stripe_subscription_id) outcome.stripeSubscription = "cancelled" } catch (e) { // Already-cancelled subscriptions throw; treat "not found"-style errors as done. outcome.stripeSubscription = isStripeGone(e) ? "cancelled" : "error" } } if (profile?.stripe_customer_id) { try { await stripe.customers.del(profile.stripe_customer_id) outcome.stripeCustomer = "deleted" } catch (e) { outcome.stripeCustomer = isStripeGone(e) ? "deleted" : "error" } } if (profile?.paypal_subscription_id) { outcome.paypalSubscriptionLeftActive = profile.paypal_subscription_id } // 2. Stored files (Spaces / local disk) — outside the DB cascade. try { outcome.filesDeleted = await deleteUserStorage(userId) } catch (e) { console.error(`[gdpr] storage purge failed for ${userId}:`, e) } // 3. PII in FK-less compliance tables: keep the consent facts, drop identifiers. await db .update(consent_log) .set({ email: null, ip_address: null, user_agent: null }) .where(eq(consent_log.user_id, userId)) // 4. Email-keyed verification tokens (password reset / email verification). if (profile?.email) { await db.delete(verification).where(eq(verification.identifier, profile.email)) } // 5. The user row — cascades profiles → every portfolio table, sessions, accounts. const deleted = await db.delete(userTable).where(eq(userTable.id, userId)).returning({ id: userTable.id }) outcome.userRowDeleted = deleted.length > 0 // Close out any open self-service request (covers admin-initiated deletes too). await db .update(account_deletion_requests) .set({ status: "completed", completed_at: new Date().toISOString(), email: null }) .where( and( eq(account_deletion_requests.user_id, userId), eq(account_deletion_requests.status, "pending") ) ) // 6. Immutable evidence that the erasure ran (admin_id null = system action). await db.insert(admin_audit_log).values({ admin_id: null, action: "gdpr_delete_account", target_user_id: userId, metadata: { ...outcome }, }) return outcome } function isStripeGone(e: unknown): boolean { const msg = e instanceof Error ? e.message : String(e) return /no such|already.*cancel|resource_missing/i.test(msg) } /** * Process deletion requests whose grace period has elapsed. Called by the * daily gdpr cron. Failures stay `pending` (with the error recorded) so the * next run retries them. */ export async function processDueDeletions(limit = 25): Promise<{ processed: number; deleted: number }> { const due = await db .select() .from(account_deletion_requests) .where( and( eq(account_deletion_requests.status, "pending"), lte(account_deletion_requests.scheduled_for, new Date().toISOString()) ) ) .limit(limit) let deleted = 0 for (const request of due) { const email = request.email try { const outcome = await executeAccountDeletion(request.user_id) await db .update(account_deletion_requests) .set({ status: "completed", completed_at: new Date().toISOString(), // Data minimization: the request itself must not keep PII after erasure. email: null, metadata: { ...request.metadata, outcome }, }) .where(eq(account_deletion_requests.id, request.id)) deleted++ if (email) { await sendEmail({ to: email, subject: "Your account and data have been deleted", html: accountDeletionCompletedHtml(), }) } } catch (e) { console.error(`[gdpr] deletion failed for ${request.user_id}:`, e) await db .update(account_deletion_requests) .set({ metadata: { ...request.metadata, last_error: e instanceof Error ? e.message : String(e), last_error_at: new Date().toISOString(), }, }) .where(eq(account_deletion_requests.id, request.id)) } } return { processed: due.length, deleted } }