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:
Leon Serfaty
2026-07-03 06:03:27 -04:00
co-authored by Claude Fable 5
parent 0d11018019
commit 5a555c715e
24 changed files with 5069 additions and 9 deletions
+189
View File
@@ -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 }
}
+186
View File
@@ -0,0 +1,186 @@
import { desc, eq, or } from "drizzle-orm"
import { db } from "@/lib/db"
import {
user as userTable,
session,
account,
profiles,
properties,
units,
tenants,
rent_payments,
maintenance_requests,
leases,
expenses,
documents,
notifications,
usage_events,
vendors,
inspections,
ai_recommendations,
ai_predictions,
follow_up_rules,
follow_up_log,
activity_log,
admin_audit_log,
account_members,
api_keys,
accounting_connections,
esign_connections,
signature_requests,
webhook_endpoints,
consent_log,
account_deletion_requests,
} from "@/lib/db/schema"
// ============================================================================
// GDPR data export (Articles 15 & 20 — access + portability).
//
// Produces a single machine-readable JSON object containing every record the
// platform stores about a user, EXCLUDING credentials and third-party secrets
// (password hashes, OAuth/API tokens, session tokens). What's excluded is
// declared in `omitted` so the export is honest about its own boundaries.
// ============================================================================
export async function buildUserDataExport(userId: string) {
const [
authUser,
profile,
sessions,
linkedAccounts,
propertyRows,
unitRows,
tenantRows,
paymentRows,
maintenanceRows,
leaseRows,
expenseRows,
documentRows,
vendorRows,
inspectionRows,
] = await Promise.all([
db.query.user.findFirst({
where: eq(userTable.id, userId),
columns: { id: true, name: true, email: true, emailVerified: true, image: true, createdAt: true },
}),
db.query.profiles.findFirst({ where: eq(profiles.id, userId) }),
db.query.session.findMany({
where: eq(session.userId, userId),
columns: { token: false }, // active credential — never exported
orderBy: desc(session.createdAt),
}),
db.query.account.findMany({
where: eq(account.userId, userId),
columns: { id: true, providerId: true, accountId: true, scope: true, createdAt: true },
}),
db.query.properties.findMany({ where: eq(properties.user_id, userId) }),
db.query.units.findMany({ where: eq(units.user_id, userId) }),
db.query.tenants.findMany({ where: eq(tenants.user_id, userId) }),
db.query.rent_payments.findMany({ where: eq(rent_payments.user_id, userId) }),
db.query.maintenance_requests.findMany({ where: eq(maintenance_requests.user_id, userId) }),
db.query.leases.findMany({ where: eq(leases.user_id, userId) }),
db.query.expenses.findMany({ where: eq(expenses.user_id, userId) }),
db.query.documents.findMany({ where: eq(documents.user_id, userId) }),
db.query.vendors.findMany({ where: eq(vendors.user_id, userId) }),
db.query.inspections.findMany({ where: eq(inspections.user_id, userId) }),
])
const [
notificationRows,
usageRows,
activityRows,
recommendationRows,
predictionRows,
followUpRuleRows,
followUpLogRows,
apiKeyRows,
webhookRows,
accountingRows,
esignRows,
signatureRows,
teamRows,
adminActionsOnUser,
consentRows,
deletionRows,
] = await Promise.all([
db.query.notifications.findMany({ where: eq(notifications.user_id, userId) }),
db.query.usage_events.findMany({ where: eq(usage_events.user_id, userId) }),
db.query.activity_log.findMany({ where: eq(activity_log.user_id, userId) }),
db.query.ai_recommendations.findMany({ where: eq(ai_recommendations.user_id, userId) }),
db.query.ai_predictions.findMany({ where: eq(ai_predictions.user_id, userId) }),
db.query.follow_up_rules.findMany({ where: eq(follow_up_rules.user_id, userId) }),
db.query.follow_up_log.findMany({ where: eq(follow_up_log.user_id, userId) }),
db.query.api_keys.findMany({
where: eq(api_keys.user_id, userId),
columns: { key_hash: false },
}),
db.query.webhook_endpoints.findMany({ where: eq(webhook_endpoints.user_id, userId) }),
db.query.accounting_connections.findMany({
where: eq(accounting_connections.user_id, userId),
columns: { access_token: false, refresh_token: false },
}),
db.query.esign_connections.findMany({
where: eq(esign_connections.user_id, userId),
columns: { access_token: false, refresh_token: false },
}),
db.query.signature_requests.findMany({ where: eq(signature_requests.user_id, userId) }),
db.query.account_members.findMany({
where: or(eq(account_members.owner_id, userId), eq(account_members.member_id, userId)),
columns: { invite_token: false },
}),
db.query.admin_audit_log.findMany({
where: eq(admin_audit_log.target_user_id, userId),
columns: { action: true, created_at: true },
}),
db.query.consent_log.findMany({ where: eq(consent_log.user_id, userId) }),
db.query.account_deletion_requests.findMany({
where: eq(account_deletion_requests.user_id, userId),
}),
])
return {
format: "propertymanagement.network/data-export",
version: 1,
generated_at: new Date().toISOString(),
omitted: [
"password hashes, OAuth/refresh tokens, session tokens, and API-key hashes (credentials are never exported)",
"webhook delivery logs (operational copies of the event records included above)",
"uploaded file BYTES — file metadata is under portfolio.documents; download the files themselves from the Documents page",
],
data_subject: { user: authUser ?? null, profile: profile ?? null },
security: { sessions, linked_sign_in_providers: linkedAccounts },
portfolio: {
properties: propertyRows,
units: unitRows,
tenants: tenantRows,
rent_payments: paymentRows,
maintenance_requests: maintenanceRows,
leases: leaseRows,
expenses: expenseRows,
documents: documentRows,
vendors: vendorRows,
inspections: inspectionRows,
},
communications: { notifications: notificationRows, follow_up_log: followUpLogRows },
automation: {
follow_up_rules: followUpRuleRows,
webhook_endpoints: webhookRows,
api_keys: apiKeyRows,
},
ai: { recommendations: recommendationRows, predictions: predictionRows },
integrations: {
accounting_connections: accountingRows,
esign_connections: esignRows,
signature_requests: signatureRows,
},
team: { memberships: teamRows },
activity: { activity_log: activityRows, usage_events: usageRows },
privacy: {
consent_log: consentRows,
deletion_requests: deletionRows,
admin_actions_affecting_you: adminActionsOnUser,
},
}
}
export type UserDataExport = Awaited<ReturnType<typeof buildUserDataExport>>