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
@@ -11,6 +11,7 @@ import {
|
||||
date,
|
||||
jsonb,
|
||||
doublePrecision,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
// ============================================================
|
||||
@@ -715,6 +716,58 @@ export const webhook_deliveries = pgTable("webhook_deliveries", {
|
||||
updated_at: updatedAt(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// ACCOUNT DELETION REQUESTS (GDPR right to erasure)
|
||||
// ============================================================
|
||||
// A user's self-service "delete my account" request. Deletion is deferred by a
|
||||
// grace period (LEGAL.dataDeletionDays) during which the user can cancel; the
|
||||
// gdpr cron then hard-deletes the account, its data, and its stored files.
|
||||
// user_id is intentionally NOT a cascading FK — the completed request must
|
||||
// survive the user's deletion as evidence the DSAR was honored. `email` is
|
||||
// kept only while the request is pending (to notify) and nulled on completion.
|
||||
export const account_deletion_requests = pgTable(
|
||||
"account_deletion_requests",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
user_id: text("user_id").notNull(),
|
||||
email: text("email"),
|
||||
status: text("status").$type<"pending" | "cancelled" | "completed">().notNull().default("pending"),
|
||||
reason: text("reason"),
|
||||
scheduled_for: tstz("scheduled_for").notNull(),
|
||||
cancelled_at: tstz("cancelled_at"),
|
||||
completed_at: tstz("completed_at"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
created_at: createdAt(),
|
||||
updated_at: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
// At most ONE open request per user — the request/cancel flow relies on this.
|
||||
uniqueIndex("account_deletion_requests_pending_user_idx")
|
||||
.on(t.user_id)
|
||||
.where(sql`status = 'pending'`),
|
||||
]
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// CONSENT LOG (GDPR proof of consent / acceptance)
|
||||
// ============================================================
|
||||
// Records when a person accepted the Terms/Privacy Policy (at signup) or made a
|
||||
// cookie/marketing consent choice. user_id has no FK so the record survives
|
||||
// account deletion as compliance evidence; identifying fields (email, ip) are
|
||||
// anonymized by the deletion flow.
|
||||
export const consent_log = pgTable("consent_log", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
user_id: text("user_id"),
|
||||
email: text("email"),
|
||||
kind: text("kind").$type<"terms" | "privacy" | "cookies" | "marketing">().notNull(),
|
||||
granted: boolean("granted").notNull(),
|
||||
policy_version: text("policy_version"),
|
||||
source: text("source"),
|
||||
ip_address: text("ip_address"),
|
||||
user_agent: text("user_agent"),
|
||||
created_at: createdAt(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// RELATIONS (for Drizzle relational queries)
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user