# Data Export & GDPR Compliance — Implementation Plan

**Spec:** docs/specs/2026-05-31-data-export-gdpr.md  ·  **Slug:** data-export-gdpr  ·  **Wave:** 10
**Depends on:** foundation-auth-rbac, foundation-monorepo, settings-module, system-communications-notifications, zync-subscription

## Goal
Deliver GDPR-grade data portability and erasure for Zync tenants. Three capabilities: (1) full tenant workspace export (OWNER) as a streamed ZIP delivered via signed R2 link; (2) per-user personal data export (any authenticated user); (3) account deletion flows — tenant workspace soft-delete with a 30-day recovery window and an async hard-delete worker, plus user self-deletion with ownership-transfer guards and 30-day anonymization. All long-running work runs in Cloudflare Queue consumers / Cron, never in-request.

## Architecture
- **New table `export_jobs`** tracks all export jobs AND deletion-progress rows (synthetic `export_type = 'deletion_progress'`). Lives in `@zync/db`.
- **Soft-delete columns** added to existing `tenants` (`deleted_at`, `deletion_requested_by`, `deletion_job_id`) and `users` (`deleted_at`).
- **Request endpoints** (`POST /api/export/request`, `DELETE /api/tenants/me`, `DELETE /api/users/me`) validate rate limits / ownership, write an `export_jobs` row, and enqueue a `QUEUE` message — returning immediately.
- **Export consumer** (queue `export.generate`, already registered in foundation-monorepo) streams CSVs (UTF-8 BOM, ISO-8601), fetches R2 objects (PDFs/receipts) under `tenants/{tenantId}/`, assembles a streaming ZIP, uploads to R2 (`STORAGE`) at `exports/{tenant_id}/{job_id}.zip` (or `exports/user-personal/{user_id}/{job_id}.zip`), generates a 48h signed URL, updates the job to `ready`, and calls `sendEmail`.
- **Tenant-deletion worker** (`deleteTenant`) runs in a queue consumer: cancels subscription via `getPaymentAdapter(env, tenantId).cancelSubscription(tenantId)`, deletes the R2 prefix `tenants/{tenantId}/`, runs the CASCADE `DELETE FROM tenants`, and advances `export_jobs.deletion_stage`. **Cross-spec note:** foundation-monorepo's wrangler config has no dedicated tenant-deletion queue; this plan registers a new queue **`tenant.delete`** (Task 9 calls out the wrangler binding addition) — deletion must NOT share `export.generate` because their consumer routing differs.
- **Hard-delete cron** (`POST /api/cron/data-retention-purge`, daily) enqueues `tenant.delete` jobs for tenants whose `deleted_at < now() - 30 days`, and anonymizes users whose `deleted_at < now() - 30 days`.
- **Consumes upstream:** tables `tenants`, `users`, `tenant_memberships`, `zync_subscriptions`, `customers`, `customer_contacts`, plus invoices/projects/tasks/time/expenses/contracts tables (read-only for CSV export); exports `sendEmail`, `SendEmailOptions`, `getPaymentAdapter`, `ZyncPaymentAdapter`, `createDb`, `requirePermission`, `requireTier`, `authMiddleware`, `RoleId`, `Env`, `seedPermissions`, UI primitives (`Button`, `Card`, `Dialog`, `Badge`, `DataTable`, `Tabs`, `EmptyState`, `Toaster`/`toast`, `Alert`, `Progress`).

## Tech Stack
- **Package `@zync/db`** — Drizzle schema (`export_jobs`, ALTERs), migration.
- **Package `@zync/export`** (NEW) — data-layer functions, CSV/ZIP builders, rate-limit checks, shared by API + workers. Exports listed per task.
- **App `apps/zync-api`** (Hono on Cloudflare Workers) — REST routes, queue consumers (`src/workers/`), cron handlers (`src/cron/`).
- **App `apps/zync-app`** (Vite + React) — `/settings/data` (Export + Danger Zone tabs) and `/profile` Privacy tab.
- **Bindings:** `DB` (Neon via Hyperdrive), `STORAGE` (R2), `QUEUE` (Cloudflare Queues; queues `export.generate`, `tenant.delete`), `CRON_SECRET`. R2 lifecycle rule auto-deletes `exports/**` after 48h (infra config note).
- **Libraries:** `@neondatabase/serverless`, Drizzle, a streaming ZIP lib (`fflate` — works in Workers, no Node fs), `@zync/notifications` (`sendEmail`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1 | `packages/db/src/schema/export.ts`, migration | No (foundation) |
| B — data layer | 2, 3 | `packages/export/**` | 2 then 3 (3 depends on 2) |
| C — API | 4, 5, 6 | `apps/zync-api/src/routes/**` | Yes (after B) |
| D — workers/cron | 7, 8, 9 | `apps/zync-api/src/workers/**`, `src/cron/**` | 7,8 parallel; 9 after 8 |
| E — email | 10 | `packages/notifications/src/templates/**` | Yes (after A) |
| F — UI | 11, 12 | `apps/zync-app/src/pages/**` | Yes (after C) |
| G — perms | 13 | `packages/auth` seed | Yes (after A) |

## Tasks

### Task 1: DB schema — `export_jobs` table + soft-delete columns
**Blocks:** 2,3,4,5,6,7,8,9,10,11,12,13  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/export.ts`
- Modify: `packages/db/src/schema/index.ts` (re-export), `packages/db/migrations/<timestamp>_data_export_gdpr.sql`
**Steps:**
- [ ] Add `export_jobs` Drizzle table + raw SQL migration with the DDL below.
- [ ] Add ALTER columns to `tenants` and `users` (do NOT redefine those tables; emit `ALTER TABLE`).
- [ ] Create the two indexes.
- [ ] Re-export `exportJobs` from the schema barrel.
**Schema / Interfaces:**
```sql
CREATE TABLE export_jobs (
  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  -- ON DELETE SET NULL (NOT cascade): the deletion_progress row must survive the
  -- tenant purge so the polling endpoint can read status='done' after DB delete.
  tenant_id             UUID REFERENCES tenants(id) ON DELETE SET NULL,
  -- nullable + SET NULL: users are soft-deleted/anonymized; a NOT NULL + SET NULL
  -- combination is self-contradictory and errors at delete time.
  requested_by          UUID REFERENCES users(id) ON DELETE SET NULL,
  export_type           TEXT NOT NULL
                          CHECK (export_type IN ('tenant_full', 'user_personal', 'deletion_progress')),
  status                TEXT NOT NULL DEFAULT 'pending'
                          CHECK (status IN ('pending', 'processing', 'ready', 'failed', 'expired')),
  r2_key                TEXT,
  download_url          TEXT,
  download_expires_at   TIMESTAMPTZ,
  error_message         TEXT,
  row_count             BIGINT,
  file_size_bytes       BIGINT,
  deletion_stage        TEXT
                          CHECK (deletion_stage IS NULL OR deletion_stage IN
                            ('cancelling_subscription','exporting_data','deleting_r2','deleting_db','done','failed')),
  deletion_stage_pct    INTEGER NOT NULL DEFAULT 0,
  deletion_error        TEXT,
  requested_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at          TIMESTAMPTZ
);

CREATE INDEX idx_export_jobs_tenant ON export_jobs(tenant_id, requested_at DESC);
CREATE INDEX idx_export_jobs_user   ON export_jobs(requested_by, requested_at DESC);

ALTER TABLE tenants ADD COLUMN deleted_at            TIMESTAMPTZ;
ALTER TABLE tenants ADD COLUMN deletion_requested_by UUID REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE tenants ADD COLUMN deletion_job_id       UUID REFERENCES export_jobs(id) ON DELETE SET NULL;

ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon; `deletion_progress` rows insert without CHECK violation.
- [ ] Deleting a tenant row leaves its `deletion_progress` `export_jobs` row intact (tenant_id → NULL).

### Task 2: Data-layer — rate limits, job CRUD, serializers (`@zync/export`)
**Blocks:** 3,4,5,6,7,8,9  ·  **Blocked by:** 1
**Files:**
- Create: `packages/export/package.json`, `packages/export/src/index.ts`, `packages/export/src/jobs.ts`, `packages/export/src/rate-limit.ts`
**Steps:**
- [ ] Scaffold `@zync/export` package (depends on `@zync/db`, `@zync/types`).
- [ ] Implement `createExportJob`, `getExportJob`, `listExportJobs`, `markJobProcessing`, `markJobReady`, `markJobFailed`, `expireStaleJobs`.
- [ ] Implement DB-based rate-limit checks (NOT a CF RateLimiter binding — windows are 24h/7d): `canRequestTenantExport(db, tenantId)` (no pending/processing job and no ready job in last 24h), `canRequestUserExport(db, userId)` (no job in last 7d).
- [ ] `refreshDownloadUrl(env, job)` — if `status='ready'` and `download_expires_at > now()`, generate/refresh a 48h R2 signed URL; else return expired sentinel.
**Schema / Interfaces:**
```ts
export type ExportType = 'tenant_full' | 'user_personal' | 'deletion_progress'
export type ExportStatus = 'pending' | 'processing' | 'ready' | 'failed' | 'expired'
export interface ExportJob {
  id: string; tenantId: string | null; requestedBy: string | null
  exportType: ExportType; status: ExportStatus
  r2Key: string | null; downloadUrl: string | null; downloadExpiresAt: Date | null
  errorMessage: string | null; rowCount: number | null; fileSizeBytes: number | null
  deletionStage: string | null; deletionStagePct: number; deletionError: string | null
  requestedAt: Date; completedAt: Date | null
}
export function createExportJob(db: Db, input: {
  tenantId: string | null; requestedBy: string | null; exportType: ExportType
}): Promise<ExportJob>
export function getExportJob(db: Db, id: string): Promise<ExportJob | null>
export function listExportJobs(db: Db, q: {
  tenantId?: string; requestedBy?: string; exportType?: ExportType; limit?: number
}): Promise<ExportJob[]>
export function markJobProcessing(db: Db, id: string): Promise<void>
export function markJobReady(db: Db, id: string, p: {
  r2Key: string; downloadExpiresAt: Date; rowCount: number; fileSizeBytes: number
}): Promise<void>
export function markJobFailed(db: Db, id: string, message: string): Promise<void>
export function canRequestTenantExport(db: Db, tenantId: string): Promise<boolean>
export function canRequestUserExport(db: Db, userId: string): Promise<boolean>
export function refreshDownloadUrl(env: Env, job: ExportJob): Promise<{ url: string } | { expired: true }>
```
**Acceptance:**
- [ ] `canRequestTenantExport` returns false when a pending/processing job exists or a ready job was created < 24h ago.
- [ ] `canRequestUserExport` returns false within 7 days of a prior user export.

### Task 3: Data-layer — CSV & streaming-ZIP builders
**Blocks:** 7  ·  **Blocked by:** 2
**Files:**
- Create: `packages/export/src/csv.ts`, `packages/export/src/zip.ts`, `packages/export/src/collect-tenant.ts`, `packages/export/src/collect-user.ts`
**Steps:**
- [ ] `toCsv(rows, columns)` — UTF-8 BOM prefix (`﻿`), RFC-4180 quoting, ISO-8601 dates, unformatted numeric currency.
- [ ] `buildZipStream(entries)` using `fflate` streaming API; accept async file entries (R2 object bodies) to avoid full-ZIP-in-memory; cap R2 fetch concurrency at 10.
- [ ] `collectTenantExport(db, env, tenantId)` — yields ZIP entries: `README.txt`, `customers.csv`, `contacts.csv`, `invoices.csv`, `invoices/*.pdf` (R2), `projects.csv`, `tasks.csv`, `time-entries.csv`, `expenses.csv`, `receipts/*` (R2), `contracts.csv`, `team-members.csv`, `audit-log.csv` (last 90/365 days per retention tier), `recurring-invoice-templates.csv`. Stream DB reads in batches.
- [ ] `collectUserExport(db, userId)` — `profile.json`, `time-entries.csv`, `task-assignments.csv`, `comments.csv`, `login-history.csv`.
- [ ] README.txt includes column descriptions, export date, tenant info.
**Schema / Interfaces:**
```ts
export interface CsvColumn<T> { header: string; value: (row: T) => string | number | null }
export function toCsv<T>(rows: T[], columns: CsvColumn<T>[]): string  // BOM-prefixed
export interface ZipEntry { path: string; body: Uint8Array | ReadableStream }
export function buildZipStream(entries: AsyncIterable<ZipEntry>): ReadableStream
export function collectTenantExport(db: Db, env: Env, tenantId: string): AsyncIterable<ZipEntry>
export function collectUserExport(db: Db, userId: string): AsyncIterable<ZipEntry>
```
**Acceptance:**
- [ ] Generated CSVs open in Excel with Hebrew text intact (BOM present).
- [ ] A tenant with 50 PDFs produces a valid ZIP without exceeding Worker memory (streamed).

### Task 4: API — export request & job history
**Blocks:** 11,12  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/export.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] `POST /api/export/request` — body `{ export_type }`; zod-validate. For `tenant_full`: require OWNER via `requirePermission('data:export')`; check `canRequestTenantExport` (429 with retry countdown if blocked). For `user_personal`: any authenticated user; check `canRequestUserExport` (429 if within 7d). Create job, enqueue `QUEUE.send({ kind:'export', jobId, exportType, tenantId, userId })` on `export.generate`. Return `{ job_id, status:'pending', message }`.
- [ ] `GET /api/export/jobs` — query `export_type`, `limit` (default 10, clamp 50). OWNER sees tenant jobs (`tenant_id = session.tenantId AND export_type='tenant_full'`); personal jobs filtered by `requested_by = session.userId`. Return serialized list with computed `status` (mark `expired` if `download_expires_at < now()`).
- [ ] `GET /api/export/jobs/:id/download` — authorize (job belongs to user's tenant/self). If ready & not expired: `refreshDownloadUrl` → 302 redirect to signed URL. If expired: 410 `{ error: "Export expired. Please request a new export." }`.
**Schema / Interfaces:**
```ts
// POST /api/export/request
// GET  /api/export/jobs
// GET  /api/export/jobs/:id/download
const exportRequestSchema = z.object({ export_type: z.enum(['tenant_full','user_personal']) })
```
**Acceptance:**
- [ ] Second tenant export within 24h returns 429 with countdown; UI button disabled accordingly.
- [ ] `/download` returns 410 once `download_expires_at` has passed.
- [ ] Non-OWNER requesting `tenant_full` gets 403.

### Task 5: API — tenant workspace deletion
**Blocks:** 11  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/tenant-deletion.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `DELETE /api/tenants/me` — `requirePermission('tenant:delete')` (OWNER). Body `{ confirmation }` must equal `tenant.slug` (case-sensitive, `timingSafeEqual`). If active `zync_subscriptions` row (status `active`/`trialing`/`past_due`): proceed but flag for immediate cancel in worker (no proration refund). Set `tenants.deleted_at = now()`, `deletion_requested_by = userId`; create `deletion_progress` `export_jobs` row (`status='pending'`, `tenant_id`, `deletion_stage='cancelling_subscription'`, `deletion_stage_pct=0`); set `tenants.deletion_job_id`. Enqueue `tenant.delete` message. Send confirmation email (Task 10). Return 200 `{ recovery_deadline: <unix deleted_at + 30d> }`.
- [ ] `GET /api/settings/data/deletion-progress` — OWNER session; tenant must be in deletion-pending state. Read the `deletion_progress` job via `tenants.deletion_job_id`. Return `{ stage, stagePct, status, error? }` with `Cache-Control: no-cache`. Maps job `status='ready'`→`'done'` for the client contract.
- [ ] Block all new logins to a tenant whose `deleted_at IS NOT NULL` (add guard in `authMiddleware`/session build — note this as a coordination point with foundation-auth-rbac; implement the check where tenant is resolved).
**Schema / Interfaces:**
```ts
// DELETE /api/tenants/me
// GET    /api/settings/data/deletion-progress
const deleteTenantSchema = z.object({ confirmation: z.string() })
```
**Acceptance:**
- [ ] Wrong `confirmation` string returns 400 and does NOT set `deleted_at`.
- [ ] After successful request, `deleted_at` set, a `deletion_progress` job exists, login to tenant blocked.

### Task 6: API — user self-deletion
**Blocks:** 12  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/user-deletion.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `DELETE /api/users/me` — any authenticated user. Body `{ confirmation }` must equal the user's email (`timingSafeEqual`). Ownership guard: for each tenant where the user holds OWNER role in `tenant_memberships`, if no other OWNER exists, return 409 `{ error: "You are the only owner of {tenantName}. Transfer ownership before deleting your account." }`.
- [ ] On pass: set `users.deleted_at = now()`, remove the user's `tenant_memberships` rows (or mark removed per membership model), block subsequent logins. Send confirmation email (Task 10). Personal-data retention: business data (time entries, invoices, tasks) stays; only personal identifiers anonymized later by the cron (Task 9).
**Schema / Interfaces:**
```ts
// DELETE /api/users/me
const deleteUserSchema = z.object({ confirmation: z.string() })
```
**Acceptance:**
- [ ] Sole-owner user is blocked with 409 and the named-tenant message.
- [ ] After deletion, `users.deleted_at` set and login is blocked.

### Task 7: Queue consumer — export ZIP generation (`export.generate`)
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/workers/export-generate.ts`
- Modify: `apps/zync-api/src/index.ts` (register queue consumer for `export.generate`)
**Steps:**
- [ ] On message `{ kind:'export', jobId, exportType, tenantId, userId }`: `markJobProcessing`.
- [ ] Choose `collectTenantExport` or `collectUserExport`; build streaming ZIP (`buildZipStream`); count rows; upload to R2 (`STORAGE`) at `exports/{tenant_id}/{job_id}.zip` (tenant) or `exports/user-personal/{user_id}/{job_id}.zip` (user) using streamed `put`.
- [ ] Compute `download_expires_at = completed_at + 172800s` (48h). `markJobReady` with `r2Key`, `downloadExpiresAt`, `rowCount`, `fileSizeBytes`.
- [ ] `sendEmail({ to, templateKey: 'data_export_ready_<locale>', vars:{ downloadUrl, expiresAt, exportName }, locale })` to OWNER (tenant) or user (personal); locale from tenant/user settings (Hebrew-first default).
- [ ] On any error: `markJobFailed(jobId, message)`; do not throw past the consumer (let CF retry policy apply, but mark failed for visibility).
**Acceptance:**
- [ ] Job transitions pending→processing→ready; R2 object exists at the documented key; email sent with a working 48h link.
- [ ] Filename matches `{tenant_slug}_export_{YYYY-MM-DD}.zip` / `{user_email}_data_export_{YYYY-MM-DD}.zip`.

### Task 8: Queue consumer — tenant hard-delete worker (`tenant.delete`)
**Blocks:** 9  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/workers/tenant-deletion.ts`
- Modify: `apps/zync-api/src/index.ts` (register `tenant.delete` consumer)
**Steps:**
- [ ] Implement `deleteTenant(tenantId, jobId, env)`:
  - `progress('cancelling_subscription', 0)` → `cancelSubscriptionIfActive(tenantId, env)` via `getPaymentAdapter(env, tenantId).cancelSubscription(tenantId)` if a non-canceled `zync_subscriptions` row exists → `progress('cancelling_subscription', 100)`.
  - `progress('deleting_r2', 0)` → `deleteR2Prefix(env.STORAGE, 'tenants/'+tenantId+'/')` (list+delete in batches; idempotent) → `progress('deleting_r2', 100)`.
  - `progress('deleting_db', 0)` → `DELETE FROM tenants WHERE id = $1` (FK CASCADE purges child tables) → `progress('deleting_db', 100)`.
  - Final: `UPDATE export_jobs SET status='ready', deletion_stage='done', completed_at=now() WHERE id=$jobId` (row survives because `export_jobs.tenant_id` is `ON DELETE SET NULL`).
  - On error: `UPDATE export_jobs SET status='failed', deletion_stage='failed', deletion_error=$msg WHERE id=$jobId`; idempotent retry-safe.
- [ ] Log to **system audit** (not tenant audit) before the DB delete (tenant will be gone).
**Schema / Interfaces:**
```ts
export async function deleteTenant(tenantId: string, jobId: string, env: Env): Promise<void>
async function deleteR2Prefix(bucket: R2Bucket, prefix: string): Promise<void> // idempotent, batched
async function cancelSubscriptionIfActive(tenantId: string, env: Env): Promise<void>
```
**Acceptance:**
- [ ] After worker completes, tenant + all child rows are gone, the `deletion_progress` job reads `status='ready'`/`stage='done'`, and the polling endpoint returns `done`.
- [ ] Re-running the worker after a mid-way R2 failure completes without error (idempotent).

### Task 9: Cron — daily retention purge (enqueue expired tenants + anonymize users)
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-api/src/cron/data-retention-purge.ts`
- Modify: `apps/zync-api/src/index.ts` (mount `POST /api/cron/data-retention-purge`), `apps/zync-api/wrangler.toml` (register `tenant.delete` queue producer+consumer binding; add cron schedule `0 3 * * *`)
**Steps:**
- [ ] `POST /api/cron/data-retention-purge` — require `CRON_SECRET` header (constant-time compare); reject otherwise 401.
- [ ] Select `tenants WHERE deleted_at IS NOT NULL AND deleted_at < now() - INTERVAL '30 days'`; for each, ensure a `deletion_progress` job exists (create if missing) and enqueue a `tenant.delete` message. Cron only enqueues; the worker (Task 8) deletes.
- [ ] Select `users WHERE deleted_at IS NOT NULL AND deleted_at < now() - INTERVAL '30 days'`; for each, anonymize: `name = '[deleted user]'`, `email = 'deleted-' || id || '@deleted.zync.is'` (one-time; skip if already anonymized).
- [ ] Mark `export_jobs` rows past `download_expires_at` as `status='expired'` (housekeeping via `expireStaleJobs`).
- [ ] Register a new Cloudflare Queue `tenant.delete` in `wrangler.toml` (foundation-monorepo's registry has `export.generate` but no deletion queue — this is the cross-spec config addition; document it).
**Schema / Interfaces:**
```ts
// POST /api/cron/data-retention-purge   (CRON_SECRET header)
```
**Acceptance:**
- [ ] Tenant soft-deleted > 30 days ago gets a `tenant.delete` message enqueued exactly once.
- [ ] User soft-deleted > 30 days ago has name/email overwritten with `[deleted user]` / `deleted-{id}@deleted.zync.is`.
- [ ] Endpoint returns 401 without a valid `CRON_SECRET`.

### Task 10: Email templates (he-IL + en-US)
**Blocks:** 7,5,6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/notifications/src/templates/data-export-ready.he-IL.ts`, `.en-US.ts`; `tenant-deletion-requested.he-IL.ts`, `.en-US.ts`; `user-deletion-confirmed.he-IL.ts`, `.en-US.ts`
- Modify: template registry/index in `packages/notifications/src`
**Steps:**
- [ ] `data_export_ready_*` — vars `downloadUrl`, `expiresAt`, `exportName`; "download link, valid 48 hours".
- [ ] `tenant_deletion_requested_*` — vars `recoveryDeadline`, `requestedAt`; mentions emailing support@zync.is to restore within 30 days.
- [ ] `user_deletion_confirmed_*` — confirms account deletion.
- [ ] All templates set `<html lang>`/`dir` per locale; Hebrew-first (never default to en-US).
**Acceptance:**
- [ ] `sendEmail` resolves each `templateKey` for both locales; Hebrew template is `dir="rtl"`.

### Task 11: UI — `/settings/data` (Export tab + Danger Zone + deletion progress)
**Blocks:** —  ·  **Blocked by:** 4,5
**Files:**
- Create: `apps/zync-app/src/pages/settings/DataExportPage.tsx`, `src/features/data-export/ExportHistoryTable.tsx`, `src/features/data-export/DeleteWorkspaceDialog.tsx`, `src/features/data-export/DeletionProgress.tsx`, `src/features/data-export/api.ts`
- Modify: settings route registry / sidebar nav to add `/settings/data` (OWNER-only)
**Steps:**
- [ ] OWNER-gated route. Two tabs (`Tabs`): **Export**, **Danger Zone**.
- [ ] Export tab: "Request full data export" `Button` (disabled with countdown if a job is pending/within-24h; reads from `GET /api/export/jobs`). Confirmation `Dialog`. History `DataTable` (max 10 rows): Date | Type | Status `Badge` (Pending/Processing spinner, Ready download `Button`, Failed, Expired) | Download. Use `EmptyState` when no jobs.
- [ ] Danger Zone tab: explanatory 30-day-recovery text; "Delete workspace" `DeleteWorkspaceDialog` — type-to-confirm input matching `tenant.slug` (case-sensitive), "I understand…" `Checkbox`, red destructive confirm. Warn if active subscription. On confirm → `DELETE /api/tenants/me`.
- [ ] On deletion initiated: render `DeletionProgress` (stage checklist + `Progress` bar), poll `GET /api/settings/data/deletion-progress` every 2s. On `done` → redirect `zync.is/goodbye` (sign out). On `failed` → error `Alert` with "Contact support" link.
- [ ] a11y: dialog `role="alertdialog"`, focus trap, `aria-live="polite"` on progress; honor `prefers-reduced-motion` for spinners.
**Acceptance:**
- [ ] Request button disabled with visible countdown within 24h of last export.
- [ ] Deletion progress advances through stages and redirects to `/goodbye` on done.

### Task 12: UI — `/profile` Privacy tab (personal export + self-deletion)
**Blocks:** —  ·  **Blocked by:** 4,6
**Files:**
- Create: `apps/zync-app/src/features/profile/PrivacyTab.tsx`, `src/features/profile/DeleteAccountDialog.tsx`
- Modify: `/profile` page to add the Privacy tab
**Steps:**
- [ ] "Your data" section: "Download a copy of your personal data" `Button` → `POST /api/export/request {export_type:'user_personal'}`; show "one export per 7 days" + last export date; disable with countdown if within 7d.
- [ ] "Delete your account" `DeleteAccountDialog`: email-confirmation input (must match user email), destructive confirm → `DELETE /api/users/me`. Render ownership-transfer warning `Alert` when 409 returns the sole-owner message.
- [ ] a11y: `alertdialog` role, focus trap, error `aria-live`.
**Acceptance:**
- [ ] Personal export button disabled with countdown within 7 days.
- [ ] Sole-owner deletion attempt shows the named-tenant transfer-ownership warning.

### Task 13: Permission seeding
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/auth/src/seed/permissions.ts` (or wherever `seedPermissions`/`seedSystemRoles` define permission rows)
**Steps:**
- [ ] Add permissions `data:export` and `tenant:delete`; grant both to OWNER only (per permissions matrix: tenant export, view export history, delete workspace = OWNER; personal export + delete-own-account need no special permission — any authenticated user).
- [ ] Ensure `role_permissions` seed wires OWNER → both new permissions; do NOT grant to ADMIN/MEMBER/CONTRACTOR.
**Acceptance:**
- [ ] `requirePermission('data:export')` and `requirePermission('tenant:delete')` pass for OWNER, fail (403) for all other roles.
