diff --git a/apps/ottabase-template-app-tanstack/src/__tests__/user-data.test.ts b/apps/ottabase-template-app-tanstack/src/__tests__/user-data.test.ts new file mode 100644 index 000000000..61580dd5e --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/__tests__/user-data.test.ts @@ -0,0 +1,340 @@ +/** + * Tests for GDPR user data export & account deletion handlers + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ============================================================ +// Hoisted mock fns — these are accessible inside vi.mock factories +// ============================================================ +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + createD1Driver: vi.fn(() => ({})), + registerConnection: vi.fn(), + getOttabaseConfig: vi.fn(() => ({ + packages: { comments: false, referrals: false, shortlinks: false, ottablog: false }, + })), + getAuthOptions: vi.fn(() => ({})), + enforceRateLimit: vi.fn(() => null), + getClientIpAddress: vi.fn(() => '127.0.0.1'), + readJson: vi.fn(), + + // Models + userFind: vi.fn(), + userDelete: vi.fn(), + accountWhere: vi.fn(), + accountDelete: vi.fn(), + sessionWhere: vi.fn(), + sessionDelete: vi.fn(), + userRoleWhere: vi.fn(), + userRoleDelete: vi.fn(), + orgMemberWhere: vi.fn(), + orgMemberDelete: vi.fn(), + auditLogWhere: vi.fn(), + auditLogLog: vi.fn(), + todoWhere: vi.fn(), + todoDelete: vi.fn(), +})); + +vi.mock('@ottabase/auth/backend', () => ({ + getSession: mocks.getSession, +})); + +vi.mock('@ottabase/db/drizzle-d1', () => ({ + createD1Driver: mocks.createD1Driver, +})); + +vi.mock('@ottabase/ottaorm', () => ({ + registerConnection: mocks.registerConnection, +})); + +vi.mock('@ottabase/ottaorm/models', () => ({ + User: { find: mocks.userFind, delete: mocks.userDelete }, + Account: { where: mocks.accountWhere, delete: mocks.accountDelete }, + Session: { where: mocks.sessionWhere, delete: mocks.sessionDelete }, + UserRole: { where: mocks.userRoleWhere, delete: mocks.userRoleDelete }, + OrganizationMember: { where: mocks.orgMemberWhere, delete: mocks.orgMemberDelete }, + AuditLog: { where: mocks.auditLogWhere, log: mocks.auditLogLog }, +})); + +vi.mock('../../ottabase/models/Todo', () => ({ + Todo: { where: mocks.todoWhere, delete: mocks.todoDelete }, +})); + +vi.mock('../../ottabase/config.loader', () => ({ + getOttabaseConfig: mocks.getOttabaseConfig, +})); + +vi.mock('../../worker/lib/auth-utils', () => ({ + getAuthOptions: mocks.getAuthOptions, +})); + +vi.mock('../../worker/lib/rate-limiting', () => ({ + enforceRateLimit: mocks.enforceRateLimit, +})); + +vi.mock('../../worker/lib/utils', () => ({ + getClientIpAddress: mocks.getClientIpAddress, + readJson: mocks.readJson, +})); + +// Now import the handlers +import { handleUserAccountDelete, handleUserDataExport } from '../../worker/routes/user-data'; + +function createContext(overrides: Partial = {}) { + const request = overrides.request || new Request('https://example.com/api/users/me/export', { method: 'GET' }); + return { + request, + env: { + OBCF_D1: {}, + ...(overrides.env || {}), + }, + url: overrides.url || new URL(request.url), + withAuthCors: overrides.withAuthCors || ((r: Response) => r), + } as any; +} + +function createMockModel(data: Record) { + return { + toJson: () => ({ ...data }), + get: (key: string) => data[key], + set: vi.fn((key: string, value: any) => { + data[key] = value; + }), + save: vi.fn(), + }; +} + +describe('handleUserDataExport', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.enforceRateLimit.mockReturnValue(null); + mocks.auditLogLog.mockResolvedValue({}); + mocks.getOttabaseConfig.mockReturnValue({ + packages: { comments: false, referrals: false, shortlinks: false, ottablog: false }, + }); + }); + + it('returns 401 when not authenticated', async () => { + mocks.getSession.mockResolvedValue(null); + const response = await handleUserDataExport(createContext()); + expect(response.status).toBe(401); + }); + + it('returns 500 when D1 is not configured', async () => { + const response = await handleUserDataExport(createContext({ env: { OBCF_D1: undefined } })); + expect(response.status).toBe(500); + }); + + it('returns user data as downloadable JSON', async () => { + mocks.getSession.mockResolvedValue({ user: { id: 'user-1', email: 'test@example.com' } }); + + mocks.userFind.mockResolvedValue( + createMockModel({ id: 'user-1', name: 'Test User', email: 'test@example.com' }), + ); + mocks.accountWhere.mockResolvedValue([]); + mocks.sessionWhere.mockResolvedValue([]); + mocks.userRoleWhere.mockResolvedValue([]); + mocks.orgMemberWhere.mockResolvedValue([]); + mocks.auditLogWhere.mockResolvedValue([]); + mocks.todoWhere.mockResolvedValue([]); + + const response = await handleUserDataExport(createContext()); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Disposition')).toContain('user-data-export'); + expect(response.headers.get('Content-Type')).toBe('application/json'); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + + const body = JSON.parse(await response.text()); + expect(body.profile).toBeDefined(); + expect(body.profile.id).toBe('user-1'); + expect(body.exportedAt).toBeDefined(); + }); + + it('strips sensitive fields from exported data', async () => { + mocks.getSession.mockResolvedValue({ user: { id: 'user-1', email: 'test@example.com' } }); + + mocks.userFind.mockResolvedValue( + createMockModel({ id: 'user-1', email: 'test@example.com', passwordHash: 'secret-hash' }), + ); + mocks.accountWhere.mockResolvedValue([ + createMockModel({ + id: 'acc-1', + provider: 'google', + access_token: 'secret-token', + refresh_token: 'secret-refresh', + id_token: 'secret-id-token', + }), + ]); + mocks.sessionWhere.mockResolvedValue([]); + mocks.userRoleWhere.mockResolvedValue([]); + mocks.orgMemberWhere.mockResolvedValue([]); + mocks.auditLogWhere.mockResolvedValue([]); + mocks.todoWhere.mockResolvedValue([]); + + const response = await handleUserDataExport(createContext()); + const body = JSON.parse(await response.text()); + + expect(body.profile.passwordHash).toBeUndefined(); + expect(body.accounts[0].access_token).toBeUndefined(); + expect(body.accounts[0].refresh_token).toBeUndefined(); + expect(body.accounts[0].id_token).toBeUndefined(); + expect(body.accounts[0].provider).toBe('google'); + }); + + it('logs the export action to audit', async () => { + mocks.getSession.mockResolvedValue({ user: { id: 'user-1', email: 'test@example.com' } }); + mocks.userFind.mockResolvedValue(createMockModel({ id: 'user-1' })); + mocks.accountWhere.mockResolvedValue([]); + mocks.sessionWhere.mockResolvedValue([]); + mocks.userRoleWhere.mockResolvedValue([]); + mocks.orgMemberWhere.mockResolvedValue([]); + mocks.auditLogWhere.mockResolvedValue([]); + mocks.todoWhere.mockResolvedValue([]); + + await handleUserDataExport(createContext()); + + expect(mocks.auditLogLog).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + action: 'export', + resourceType: 'user', + }), + ); + }); +}); + +describe('handleUserAccountDelete', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.enforceRateLimit.mockReturnValue(null); + mocks.auditLogLog.mockResolvedValue({}); + mocks.getOttabaseConfig.mockReturnValue({ + packages: { comments: false, referrals: false, shortlinks: false, ottablog: false }, + }); + }); + + it('returns 401 when not authenticated', async () => { + mocks.getSession.mockResolvedValue(null); + const ctx = createContext({ + request: new Request('https://example.com/api/users/me/delete', { method: 'POST' }), + }); + const response = await handleUserAccountDelete(ctx); + expect(response.status).toBe(401); + }); + + it('returns 400 when email confirmation is missing', async () => { + mocks.getSession.mockResolvedValue({ user: { id: 'user-1', email: 'test@example.com' } }); + mocks.readJson.mockResolvedValue({}); + + const ctx = createContext({ + request: new Request('https://example.com/api/users/me/delete', { method: 'POST' }), + }); + const response = await handleUserAccountDelete(ctx); + expect(response.status).toBe(400); + + const body = await response.json(); + expect(body.code).toBe('CONFIRMATION_REQUIRED'); + }); + + it('returns 400 when email does not match', async () => { + mocks.getSession.mockResolvedValue({ user: { id: 'user-1', email: 'test@example.com' } }); + mocks.readJson.mockResolvedValue({ confirmEmail: 'wrong@example.com' }); + mocks.userFind.mockResolvedValue(createMockModel({ id: 'user-1', email: 'test@example.com' })); + + const ctx = createContext({ + request: new Request('https://example.com/api/users/me/delete', { method: 'POST' }), + }); + const response = await handleUserAccountDelete(ctx); + expect(response.status).toBe(400); + + const body = await response.json(); + expect(body.code).toBe('EMAIL_MISMATCH'); + }); + + it('deletes user and related data on correct email confirmation', async () => { + mocks.getSession.mockResolvedValue({ user: { id: 'user-1', email: 'test@example.com' } }); + mocks.readJson.mockResolvedValue({ confirmEmail: 'test@example.com' }); + mocks.userFind.mockResolvedValue(createMockModel({ id: 'user-1', email: 'test@example.com' })); + mocks.userDelete.mockResolvedValue(true); + + mocks.sessionWhere.mockResolvedValue([createMockModel({ id: 'sess-1' })]); + mocks.sessionDelete.mockResolvedValue(true); + mocks.accountWhere.mockResolvedValue([createMockModel({ id: 'acc-1' })]); + mocks.accountDelete.mockResolvedValue(true); + mocks.userRoleWhere.mockResolvedValue([]); + mocks.orgMemberWhere.mockResolvedValue([]); + mocks.todoWhere.mockResolvedValue([]); + mocks.auditLogWhere.mockResolvedValue([]); + + const ctx = createContext({ + request: new Request('https://example.com/api/users/me/delete', { method: 'POST' }), + }); + const response = await handleUserAccountDelete(ctx); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.success).toBe(true); + expect(body.deletedTables).toContain('users'); + expect(body.deletedTables).toContain('sessions'); + expect(body.deletedTables).toContain('accounts'); + }); + + it('anonymizes audit logs instead of deleting them', async () => { + mocks.getSession.mockResolvedValue({ user: { id: 'user-1', email: 'test@example.com' } }); + mocks.readJson.mockResolvedValue({ confirmEmail: 'test@example.com' }); + mocks.userFind.mockResolvedValue(createMockModel({ id: 'user-1', email: 'test@example.com' })); + mocks.userDelete.mockResolvedValue(true); + + const auditLog = createMockModel({ + id: 'audit-1', + userId: 'user-1', + userEmail: 'test@example.com', + }); + mocks.sessionWhere.mockResolvedValue([]); + mocks.accountWhere.mockResolvedValue([]); + mocks.userRoleWhere.mockResolvedValue([]); + mocks.orgMemberWhere.mockResolvedValue([]); + mocks.todoWhere.mockResolvedValue([]); + mocks.auditLogWhere.mockResolvedValue([auditLog]); + + const ctx = createContext({ + request: new Request('https://example.com/api/users/me/delete', { method: 'POST' }), + }); + await handleUserAccountDelete(ctx); + + // Verify audit log was anonymized, not deleted + expect(auditLog.set).toHaveBeenCalledWith('userId', null); + expect(auditLog.set).toHaveBeenCalledWith('userEmail', 'deleted-user'); + expect(auditLog.set).toHaveBeenCalledWith('ipAddress', null); + expect(auditLog.set).toHaveBeenCalledWith('userAgent', null); + expect(auditLog.save).toHaveBeenCalled(); + }); + + it('logs deletion to audit before deleting', async () => { + mocks.getSession.mockResolvedValue({ user: { id: 'user-1', email: 'test@example.com' } }); + mocks.readJson.mockResolvedValue({ confirmEmail: 'test@example.com' }); + mocks.userFind.mockResolvedValue(createMockModel({ id: 'user-1', email: 'test@example.com' })); + mocks.userDelete.mockResolvedValue(true); + mocks.sessionWhere.mockResolvedValue([]); + mocks.accountWhere.mockResolvedValue([]); + mocks.userRoleWhere.mockResolvedValue([]); + mocks.orgMemberWhere.mockResolvedValue([]); + mocks.todoWhere.mockResolvedValue([]); + mocks.auditLogWhere.mockResolvedValue([]); + + const ctx = createContext({ + request: new Request('https://example.com/api/users/me/delete', { method: 'POST' }), + }); + await handleUserAccountDelete(ctx); + + expect(mocks.auditLogLog).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + action: 'delete', + resourceType: 'user', + metadata: expect.objectContaining({ type: 'gdpr_account_deletion' }), + }), + ); + }); +}); diff --git a/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx b/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx index a70b48dd0..3727c86c0 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/user/UserProfilePage.tsx @@ -38,7 +38,7 @@ import { } from '@ottabase/ui-shadcn'; import { getTimezonesForSelect, setTimezoneConfig } from '@ottabase/utils/timezone'; import { IconExternalLink, IconPencil, IconTrash } from '@tabler/icons-react'; -import { Calendar, Check, Loader2, Mail, User } from 'lucide-react'; +import { Calendar, Check, Download, Loader2, Mail, ShieldAlert, User } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { AvatarEditModal } from './AvatarEditModal'; @@ -71,6 +71,12 @@ export function UserProfilePage() { const [verificationStatus, setVerificationStatus] = useState<'idle' | 'sending' | 'sent'>('idle'); const [verificationError, setVerificationError] = useState(null); + // Data & Privacy state + const [isExporting, setIsExporting] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteConfirmEmail, setDeleteConfirmEmail] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + const normalize = useCallback((value: string) => value.trim(), []); // OttaSelect items: id = IANA name, name = display label (searchable). Browser timezone always first. @@ -297,6 +303,54 @@ export function UserProfilePage() { } }; + const handleDataExport = async () => { + setIsExporting(true); + try { + const response = await fetch('/api/users/me/export', { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`Export failed: ${response.status}`); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `user-data-export-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + toast.success('Export complete', 'Your data has been downloaded.'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to export data'; + toast.error('Export failed', message); + } finally { + setIsExporting(false); + } + }; + + const handleAccountDelete = async () => { + if (!user?.email) return; + setIsDeleting(true); + try { + await api('/api/users/me/delete', { + method: 'POST', + body: { confirmEmail: deleteConfirmEmail }, + }); + toast.success('Account deleted', 'Your account and all data have been removed.'); + setDeleteDialogOpen(false); + // Sign out and redirect to home + window.location.href = '/'; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete account'; + toast.error('Deletion failed', message); + } finally { + setIsDeleting(false); + } + }; + if (!user) { return null; } @@ -681,6 +735,112 @@ export function UserProfilePage() { + + {/* Data & Privacy */} + + + + + Data & Privacy + + Export your data or delete your account + + +
+
+

Download your data

+

+ Export all your personal data as a JSON file. +

+
+ +
+ + + +
+
+

Delete account

+

+ Permanently delete your account and all associated data. This action cannot be undone. +

+
+ +
+
+
+ + {/* Delete Account Confirmation Dialog */} + + + + Delete your account? + + + This will permanently delete your account and all associated data including your + profile, sessions, roles, and any content you have created. This action cannot be + undone. + + + Type your email address to confirm: + + + + setDeleteConfirmEmail(e.target.value)} + disabled={isDeleting} + autoComplete="off" + data-1p-ignore + /> + + Cancel + { + e.preventDefault(); + await handleAccountDelete(); + }} + disabled={ + isDeleting || + deleteConfirmEmail.trim().toLowerCase() !== (user.email || '').trim().toLowerCase() + } + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? ( + <> + + Deleting... + + ) : ( + 'Delete my account' + )} + + + + ); } diff --git a/apps/ottabase-template-app-tanstack/worker/routes/router.ts b/apps/ottabase-template-app-tanstack/worker/routes/router.ts index 622e2e1a4..1ffa94a42 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/router.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/router.ts @@ -94,6 +94,7 @@ import { handleShortlinksCreate, handleShortlinksList, } from './shortlinks'; +import { handleUserAccountDelete, handleUserDataExport } from './user-data'; import { handleCustomRoutes } from '../../ottabase/config.routes'; import { getOttabaseConfig } from '../../ottabase/config.loader'; @@ -180,6 +181,10 @@ async function handleGetRoutes(context: ApiRouteContext): Promise Response; +} + +// ============================================================ +// HELPERS +// ============================================================ + +/** Placeholder email for anonymized audit log records */ +const ANONYMIZED_USER_EMAIL = 'deleted-user'; + +/** Resolve the authenticated user from the session, returning userId or an error Response */ +async function resolveAuthenticatedUser( + request: Request, + env: CloudflareEnv, +): Promise<{ userId: string; userEmail?: string } | Response> { + if (!env.OBCF_D1) { + return errorResponse('D1 database binding not configured', 500, { code: 'CONFIG_ERROR' }); + } + + const session = await getSession(request, env as any, getAuthOptions(env)); + const userId = session?.user?.id; + + if (!userId) { + return errorResponse('Unauthorized', 401, { code: 'UNAUTHORIZED' }); + } + + registerConnection('default', createD1Driver(env.OBCF_D1)); + + return { userId, userEmail: session?.user?.email ?? undefined }; +} + +/** Safely collect records from a model, returning empty array on error */ +async function safeCollect(fn: () => Promise): Promise { + try { + return await fn(); + } catch { + return []; + } +} + +/** Strip sensitive fields from a record */ +function sanitizeRecord(record: Record, sensitiveFields: string[] = []): Record { + const out = { ...record }; + for (const field of sensitiveFields) { + delete out[field]; + } + return out; +} + +// ============================================================ +// DATA EXPORT +// ============================================================ + +/** + * Collect all data owned by a user across all registered models. + * Returns a structured object keyed by data category. + */ +async function collectUserData(userId: string, env: CloudflareEnv) { + const packages = getOttabaseConfig(env).packages; + + // Core user profile + const user = await User.find(userId); + const profile = user ? sanitizeRecord(user.toJson(), ['passwordHash']) : null; + + // Auth accounts (OAuth providers) + const accounts = (await safeCollect(() => Account.where({ userId }))).map((a) => + sanitizeRecord(a.toJson(), ['access_token', 'refresh_token', 'id_token']), + ); + + // Active sessions + const sessions = (await safeCollect(() => Session.where({ userId }))).map((s) => + sanitizeRecord(s.toJson(), ['sessionToken']), + ); + + // Role assignments + const roles = (await safeCollect(() => UserRole.where({ userId }))).map((r) => r.toJson()); + + // Organization memberships + const memberships = (await safeCollect(() => OrganizationMember.where({ userId }))).map((m) => m.toJson()); + + // Audit logs (user's own activity) + const auditLogs = (await safeCollect(() => AuditLog.where({ userId }, { limit: 1000 }))).map((a) => a.toJson()); + + // App-specific: Todos + const todos = (await safeCollect(() => Todo.where({ userId }))).map((t) => t.toJson()); + + // Package data (conditional on enabled packages) + let comments: Record[] = []; + if (packages.comments) { + try { + const { Comment } = await import('@ottabase/comments'); + comments = (await safeCollect(() => Comment.where({ userId }))).map((c) => c.toJson()); + } catch { + // Package not available + } + } + + let referrals: Record[] = []; + if (packages.referrals) { + try { + const { ReferralTracking } = await import('@ottabase/referrals'); + referrals = (await safeCollect(() => ReferralTracking.where({ userId }))).map((r) => r.toJson()); + } catch { + // Package not available + } + } + + return { + exportedAt: new Date().toISOString(), + profile, + accounts, + sessions, + roles, + memberships, + auditLogs, + todos, + ...(comments.length > 0 ? { comments } : {}), + ...(referrals.length > 0 ? { referrals } : {}), + }; +} + +/** + * GET /api/users/me/export + * Download all personal data as JSON (GDPR Article 20 - Right to data portability) + */ +export async function handleUserDataExport(context: UserDataRouteContext): Promise { + const { request, env, withAuthCors } = context; + + // Rate limit: 5 exports per hour per IP + const ip = getClientIpAddress(request); + const rateLimit = await enforceRateLimit(request, env, `user-data-export:${ip}`); + if (rateLimit) return withAuthCors(rateLimit); + + const authResult = await resolveAuthenticatedUser(request, env); + if (authResult instanceof Response) return withAuthCors(authResult); + const { userId, userEmail } = authResult; + + try { + const data = await collectUserData(userId, env); + + // Audit log the export action + try { + await AuditLog.log({ + userId, + userEmail, + action: 'export', + resourceType: 'user', + resourceId: userId, + metadata: { + type: 'gdpr_data_export', + ipAddress: ip, + categories: Object.keys(data).filter((k) => k !== 'exportedAt'), + }, + ipAddress: ip, + userAgent: request.headers.get('user-agent') || undefined, + status: 'success', + }); + } catch { + // Audit log failure should not block export + } + + // Return as downloadable JSON + const body = JSON.stringify(data, null, 2); + return withAuthCors( + new Response(body, { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="user-data-export-${new Date().toISOString().slice(0, 10)}.json"`, + 'Cache-Control': 'no-store', + }, + }), + ); + } catch (error) { + console.error('Data export failed:', error); + return withAuthCors( + errorResponse('Failed to export data', 500, { + code: 'EXPORT_FAILED', + details: 'An error occurred while collecting your data. Please try again.', + }), + ); + } +} + +// ============================================================ +// ACCOUNT DELETION +// ============================================================ + +/** + * Delete all user-owned rows across all models. + * Order matters: delete dependent rows first, then the user. + */ +async function deleteAllUserData(userId: string, env: CloudflareEnv): Promise<{ deletedTables: string[] }> { + const packages = getOttabaseConfig(env).packages; + const deletedTables: string[] = []; + + // 1. Sessions (invalidate all active sessions) + try { + const sessions = await Session.where({ userId }); + for (const s of sessions) await Session.delete(s.get('id') as string); + if (sessions.length > 0) deletedTables.push('sessions'); + } catch { + // Table may not exist + } + + // 2. Accounts (OAuth providers) + try { + const accounts = await Account.where({ userId }); + for (const a of accounts) await Account.delete(a.get('id') as string); + if (accounts.length > 0) deletedTables.push('accounts'); + } catch { + // Table may not exist + } + + // 3. UserRoles + try { + const roles = await UserRole.where({ userId }); + for (const r of roles) await UserRole.delete(r.get('id') as string); + if (roles.length > 0) deletedTables.push('user_roles'); + } catch { + // Table may not exist + } + + // 4. Organization memberships + try { + const memberships = await OrganizationMember.where({ userId }); + for (const m of memberships) await OrganizationMember.delete(m.get('id') as string); + if (memberships.length > 0) deletedTables.push('organization_members'); + } catch { + // Table may not exist + } + + // 5. Todos (app-specific) + try { + const todos = await Todo.where({ userId }); + for (const t of todos) await Todo.delete(t.get('id') as string); + if (todos.length > 0) deletedTables.push('todos'); + } catch { + // Table may not exist + } + + // 6. Comments (if enabled) + if (packages.comments) { + try { + const { Comment } = await import('@ottabase/comments'); + const comments = await Comment.where({ userId }); + for (const c of comments) await Comment.delete(c.get('id') as string); + if (comments.length > 0) deletedTables.push('comments'); + } catch { + // Package not available + } + } + + // 7. Referral tracking (if enabled) + if (packages.referrals) { + try { + const { ReferralTracking } = await import('@ottabase/referrals'); + const referrals = await ReferralTracking.where({ userId }); + for (const r of referrals) await ReferralTracking.delete(r.get('id') as string); + if (referrals.length > 0) deletedTables.push('referral_tracking'); + } catch { + // Package not available + } + } + + // 8. Audit logs — anonymize rather than delete for compliance trail + try { + const auditLogs = await AuditLog.where({ userId }); + for (const log of auditLogs) { + log.set('userId', null); + log.set('userEmail', ANONYMIZED_USER_EMAIL); + log.set('ipAddress', null); + log.set('userAgent', null); + await log.save(); + } + if (auditLogs.length > 0) deletedTables.push('audit_logs (anonymized)'); + } catch { + // Table may not exist + } + + // 9. Finally, delete the user record + try { + await User.delete(userId); + deletedTables.push('users'); + } catch (error) { + console.error('Failed to delete user record:', error); + throw new Error('Failed to delete user record'); + } + + return { deletedTables }; +} + +/** + * POST /api/users/me/delete + * Delete account and all related data (GDPR Article 17 - Right to erasure) + * Requires confirmation via email in request body + */ +export async function handleUserAccountDelete(context: UserDataRouteContext): Promise { + const { request, env, withAuthCors } = context; + + // Rate limit: 3 attempts per hour per IP + const ip = getClientIpAddress(request); + const rateLimit = await enforceRateLimit(request, env, `user-account-delete:${ip}`); + if (rateLimit) return withAuthCors(rateLimit); + + const authResult = await resolveAuthenticatedUser(request, env); + if (authResult instanceof Response) return withAuthCors(authResult); + const { userId, userEmail } = authResult; + + // Require the user to confirm by providing their email + const body = await readJson<{ confirmEmail?: string }>(request); + const confirmEmail = typeof body.confirmEmail === 'string' ? body.confirmEmail.trim().toLowerCase() : ''; + + if (!confirmEmail) { + return withAuthCors( + errorResponse('Email confirmation required', 400, { + code: 'CONFIRMATION_REQUIRED', + details: 'Please provide your email address to confirm account deletion.', + }), + ); + } + + // Verify the confirmed email matches the user's email + const user = await User.find(userId); + if (!user) { + return withAuthCors(errorResponse('User not found', 404, { code: 'NOT_FOUND' })); + } + + const actualEmail = ((user.get('email') as string) || '').trim().toLowerCase(); + if (confirmEmail !== actualEmail) { + return withAuthCors( + errorResponse('Email does not match', 400, { + code: 'EMAIL_MISMATCH', + details: 'The provided email does not match your account email.', + }), + ); + } + + // Audit log BEFORE deletion (so we have a record even after the user is gone) + try { + await AuditLog.log({ + userId, + userEmail, + action: 'delete', + resourceType: 'user', + resourceId: userId, + metadata: { + type: 'gdpr_account_deletion', + ipAddress: ip, + }, + ipAddress: ip, + userAgent: request.headers.get('user-agent') || undefined, + status: 'success', + }); + } catch { + // Audit log failure should not block deletion + } + + try { + const result = await deleteAllUserData(userId, env); + + return withAuthCors( + jsonResponse( + { + success: true, + message: 'Your account and all associated data have been deleted.', + deletedTables: result.deletedTables, + }, + 200, + ), + ); + } catch (error) { + console.error('Account deletion failed:', error); + return withAuthCors( + errorResponse('Failed to delete account', 500, { + code: 'DELETION_FAILED', + details: 'An error occurred while deleting your account. Please contact support.', + }), + ); + } +}