Build GDPR compliance system: data export, account deletion, consent
- Data export (Art. 15/20): GET /api/gdpr/export serves a full JSON export of the user's data (credentials/tokens excluded, exclusions declared) - Right to erasure (Art. 17): self-service deletion with 30-day grace period (Settings -> Privacy & Data), cancellable; daily /api/cron/gdpr drain cancels Stripe billing, purges Spaces files, cascade-deletes the account, anonymizes consent rows, and writes audit evidence - Migration 0011: account_deletion_requests (partial unique index = one pending per user) + FK-less consent_log (survives erasure) - Consent: terms/privacy acceptance logged at signup (email + Google); cookie banner with analytics opt-out (umami.disabled), choices logged server-side for signed-in users via POST /api/gdpr/consent - Admin deleteUser upgraded to the same full purge (was leaving Spaces files and Stripe subscriptions orphaned) - /gdpr legal page now points at the self-service tools - scripts/verify-gdpr.ts: end-to-end verification vs live dev DB (22/22) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0d11018019
commit
5a555c715e
@@ -0,0 +1,189 @@
|
||||
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<DeletionOutcome> {
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user