# Data Export & GDPR Compliance

**Date:** 2026-05-31  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `foundation-monorepo`, `system-communications-notifications`, `settings-module`, `zync-subscription`  
**Referenced by:** `tenant-audit-log`, `admin-dashboard`

---

## Overview

Zync serves Israeli businesses (and may serve EU-adjacent clients). This spec covers: (1) full tenant workspace data export for business continuity, (2) individual user personal data export (GDPR Article 20 — right to data portability), and (3) account deletion flows (GDPR Article 17 — right to erasure). Cookie consent is noted for future reference but not implemented in v1.

---

## Tenant Full Export

### Access
OWNER only. Located at `/settings/data` → "Export" tab.

### User Flow
1. OWNER clicks "Request full data export"
2. Confirmation dialog: "Generating your export may take a few minutes. You'll receive an email with a download link when it's ready."
3. POST /api/export/request → creates `export_jobs` row → enqueues Cloudflare Queue job
4. OWNER receives email with signed R2 download link (48-hour expiry)
5. Download link is also shown in `/settings/data` export history table

### Rate limit: max 1 export per 24 hours per tenant. If a job is already pending/processing, show status instead of "Request" button.

### Export Contents (ZIP file)

```
{tenant_slug}_export_{YYYY-MM-DD}.zip
├── README.txt                  # Column descriptions, export date, tenant info
├── customers.csv
├── contacts.csv
├── invoices.csv
├── invoices/
│   └── *.pdf                   # Invoice PDFs (fetched from R2)
├── projects.csv
├── tasks.csv
├── time-entries.csv
├── expenses.csv
├── receipts/
│   └── *.{jpg,png,pdf}         # Expense receipt files (fetched from R2)
├── contracts.csv
├── team-members.csv
├── audit-log.csv               # Last 90 or 365 days (per retention tier)
└── recurring-invoice-templates.csv
```

**CSV encoding:** UTF-8 with BOM (for Excel compatibility with Hebrew text). Date format: ISO 8601. Currency values: unformatted numeric.

### Export job timeout
If a job takes longer than 5 minutes (e.g., many PDFs), it continues running via Durable Object or Queue (not a time-limited Worker). R2 upload is streamed to avoid memory limits.

---

## User Personal Data Export

### Access
Any authenticated user. Located at `/profile` → "Privacy" tab.

### Contents (ZIP):
```
{user_email}_data_export_{YYYY-MM-DD}.zip
├── profile.json                # Name, email, phone, created_at
├── time-entries.csv            # All time entries logged by this user
├── task-assignments.csv        # Tasks assigned to this user
├── comments.csv                # Comments authored by this user
├── login-history.csv           # Auth events from audit log for this user
```

### Rate limit: max 1 export per 7 days per user.

### Delivery
Email with signed R2 download link (48h expiry). Download link also shown in the Privacy tab.

---

## Account Deletion

### Tenant Account Deletion (OWNER only)

**Location:** `/settings/data` → "Danger Zone" section.

**Flow:**
1. OWNER clicks "Delete workspace"
2. Modal: "This will permanently delete your Zync workspace and all its data. To confirm, type your workspace name below."
3. Text input: must match `tenant.slug` exactly (case-sensitive)
4. Checkbox: "I understand this cannot be undone after the 30-day recovery window"
5. "Delete workspace" button (red, destructive)
6. On confirm: `tenants.deleted_at = now()` (soft delete), all new logins to this tenant are blocked
7. Confirmation email sent to OWNER with: date of request, recovery deadline (30 days)
8. If tenant has an active Zync subscription: cancels subscription immediately (no proration refund)

**30-day recovery window:**
- During this period, OWNER can email support@zync.is to restore the workspace
- After 30 days: hard-delete job runs (see below)

**Hard-delete job (Cron Trigger, daily):**
```sql
SELECT * FROM tenants WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '30 days'
```
For each expired tenant: enqueue a `tenant-deletion` job (handled by the deletion worker — same `deleteTenant()` function defined in Deletion Progress Tracking section above). The cron only enqueues; the queue worker does the actual deletion asynchronously.

Log to system audit (not tenant audit — tenant will be gone) before deleting.

**Prerequisite:** if tenant has active subscription, cancel it before deletion is allowed. Show warning if subscription is active.

---

### User Self-Deletion

**Location:** `/profile` → "Privacy" tab → "Delete my account".

**Rules:**
- If user is the OWNER of any tenant and there is no other OWNER, they must transfer ownership first. Show error: "You are the only owner of {tenant name}. Transfer ownership before deleting your account."
- If user is the only OWNER of their last tenant, they must delete the tenant first, or add a co-owner.
- Once confirmed: user is removed from all tenants, `users.deleted_at` set, login blocked

**Flow:**
1. User clicks "Delete my account"
2. Modal: "Are you sure? You'll lose access to all your Zync workspaces." + email confirmation input
3. User types their email to confirm
4. On confirm: soft-delete user, send confirmation email

**Retention of user-generated data:**
- Time entries, invoices, tasks remain in the tenant's data (business data, not personal)
- Only personal identifiers are anonymized: `users` row soft-deleted, name/email retained for 30 days then overwritten with `[deleted user]` / `deleted-{id}@deleted.zync.is` by the same hard-delete cron.

---

## Data Model

```sql
CREATE TABLE export_jobs (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id           UUID    NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  requested_by        UUID    NOT NULL REFERENCES users(id) ON DELETE SET NULL,
  export_type         TEXT    NOT NULL CHECK (export_type IN ('tenant_full', 'user_personal')),
  status              TEXT    NOT NULL DEFAULT 'pending'
                        CHECK (status IN ('pending', 'processing', 'ready', 'failed', 'expired')),
  r2_key              TEXT,                         -- R2 object key of the ZIP file
  download_url        TEXT,                         -- signed URL (cached, refreshed on GET)
  download_expires_at TIMESTAMPTZ,
  error_message       TEXT,
  row_count           BIGINT,                       -- total rows exported (informational)
  file_size_bytes     BIGINT,
  -- Deletion progress tracking (for tenant workspace deletion flow)
  deletion_stage      TEXT,                         -- NULL unless export_type = 'deletion_progress'
    -- 'cancelling_subscription' | 'exporting_data' | 'deleting_r2' | 'deleting_db' | 'done' | 'failed'
  deletion_stage_pct  INTEGER DEFAULT 0,            -- 0–100 progress within current stage
  deletion_error      TEXT,                         -- human-readable error if stage = 'failed'
  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);

-- Tenant deletion tracking (soft-delete column on tenants table)
ALTER TABLE tenants ADD COLUMN deleted_at           TIMESTAMPTZ;
ALTER TABLE tenants ADD COLUMN deletion_requested_by UUID REFERENCES users(id);
ALTER TABLE tenants ADD COLUMN deletion_job_id       UUID REFERENCES export_jobs(id);

-- User soft-delete
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;
```

---

## Deletion Progress Tracking

Workspace deletion is a long-running operation (R2 cleanup alone may take 30–120 seconds for tenants with large file stores). The deletion flow uses `export_jobs` with a synthetic `export_type = 'deletion_progress'` row to track progress.

### Deletion stages

| Stage | Description |
|-------|-------------|
| `cancelling_subscription` | Calling payment adapter `cancelSubscription` |
| `exporting_data` | Creating tenant ZIP (optional final export before deletion) |
| `deleting_r2` | Deleting all R2 objects under `tenants/{tenantId}/` prefix |
| `deleting_db` | Running CASCADE deletes; `deleted_at` on tenant row |
| `done` | All stages complete; tenant and all rows purged |
| `failed` | Error in any stage; safe to retry |

### Worker pseudocode

```ts
// apps/zync-api/src/workers/tenant-deletion.ts
export async function deleteTenant(tenantId: string, jobId: string, env: Env) {
  const progress = async (stage: string, pct: number) => {
    await db.query(
      `UPDATE export_jobs SET deletion_stage = $1, deletion_stage_pct = $2 WHERE id = $3`,
      [stage, pct, jobId]
    )
  }

  try {
    await progress('cancelling_subscription', 0)
    await cancelSubscriptionIfActive(tenantId, env)
    await progress('cancelling_subscription', 100)

    await progress('deleting_r2', 0)
    await deleteR2Prefix(env.R2, `tenants/${tenantId}/`)
    await progress('deleting_r2', 100)

    await progress('deleting_db', 0)
    // CASCADE from tenants FK handles all child tables
    await db.query(`DELETE FROM tenants WHERE id = $1`, [tenantId])
    await progress('deleting_db', 100)

    await db.query(`UPDATE export_jobs SET status = 'ready', deletion_stage = 'done', completed_at = NOW() WHERE id = $1`, [jobId])
  } catch (err) {
    await db.query(
      `UPDATE export_jobs SET status = 'failed', deletion_stage = 'failed', deletion_error = $1 WHERE id = $2`,
      [(err as Error).message, jobId]
    )
  }
}
```

This runs in a Cloudflare Queue consumer (not in-request). The deletion request endpoint enqueues the job and returns immediately with the `jobId`.

### Progress polling endpoint

```
GET /api/settings/data/deletion-progress
    Auth: OWNER session (tenant must be in deletion_pending state)
    Response: {
      stage: string,
      stagePct: number,
      status: 'pending' | 'processing' | 'done' | 'failed',
      error?: string
    }
    Cache: no-cache (client polls every 2s while status != done/failed)
```

### `/settings/data` Danger Zone — deletion UI

When deletion is initiated (user clicked "Delete workspace" and confirmed):

```
┌────────────────────────────────────────────────────────────┐
│  Deleting workspace...                                     │
│                                                            │
│  ✓ Subscription cancelled                                  │
│  ⠋ Deleting files (R2)...                    [47%]        │
│  ○ Deleting workspace data                                 │
│                                                            │
│  Do not close this tab. You will be signed out when done. │
└────────────────────────────────────────────────────────────┘
```

Client polls `GET /api/settings/data/deletion-progress` every 2 seconds. On `status = 'done'`: redirect to `zync.is/goodbye`. On `status = 'failed'`: show error with "Contact support" link.

---

## Features & Screens

### `/settings/data`

**Access:** OWNER only.

**Export tab:**
- "Request full data export" button (disabled if job pending in last 24h; shows countdown instead)
- Export history table: Date requested | Type | Status | Download
  - Status badges: Pending (spinner) | Processing (spinner) | Ready (download button) | Failed | Expired
  - Download links expire after 48h; expired rows show "Expired" badge
  - Max 10 rows shown

**Danger Zone tab:**
- "Delete workspace" section with explanatory text about the 30-day recovery window
- Delete flow as described above

---

### `/profile` → Privacy tab

**Any authenticated user.**

Sections:
1. **Your data** — "Download a copy of your personal data" button
   - Rate limit info: "You can request one export per 7 days"
   - Last export date shown if applicable
2. **Delete your account** — destructive action with email confirmation
   - Ownership transfer warning displayed if applicable

---

## Permissions

| Action | OWNER | ADMIN | MEMBER | CONTRACTOR |
|--------|-------|-------|--------|------------|
| Request tenant full export | Yes | No | No | No |
| View own export history | Yes | No | No | No |
| Delete workspace | Yes | No | No | No |
| Request personal data export | Yes | Yes | Yes | Yes |
| Delete own account | Yes | Yes | Yes | Yes |

---

## API Endpoints

### `POST /api/export/request`
Body: `{ "export_type": "tenant_full" | "user_personal" }`

Validates rate limits, creates `export_jobs` row, enqueues queue message. Returns:
```json
{ "job_id": "abc123", "status": "pending", "message": "Export queued. You'll receive an email when ready." }
```

### `GET /api/export/jobs`
Returns export job history for the current user's tenant (OWNER) or current user's personal jobs.

Query params: `export_type`, `limit` (default 10).

### `GET /api/export/jobs/:id/download`
If `status = 'ready'` and not expired: generates/refreshes signed R2 URL and redirects (302) to it.
If expired: returns 410 Gone with `{ "error": "Export expired. Please request a new export." }`.

### `DELETE /api/tenants/me`
Body: `{ "confirmation": "{tenant_slug}" }`

Initiates workspace soft-delete. Returns 200 on success with `{ "recovery_deadline": 1748649600 }`.

### `DELETE /api/users/me`
Body: `{ "confirmation": "{user_email}" }`

Initiates user self-deletion. Returns 409 if user is last owner of any tenant.

---

## Architecture Decisions

### Async ZIP generation via Queue
Large exports (many PDFs/receipts) can exceed Worker CPU/memory limits. The `POST /api/export/request` enqueues a message; a Queue Consumer Worker:
1. Sets `status = 'processing'`
2. Streams data from Postgres in batches, writes CSVs to an in-memory buffer
3. Fetches R2 objects (PDFs, receipts) in parallel (max 10 concurrent)
4. Assembles ZIP using a streaming ZIP library (no full ZIP in memory)
5. Uploads ZIP to R2 at key `exports/{tenant_id}/{job_id}.zip`
6. Generates a pre-signed R2 URL (48h TTL)
7. Updates `export_jobs` row: `status = 'ready'`, `r2_key`, `download_expires_at`
8. Sends email to OWNER/user

### R2 key structure
```
exports/{tenant_id}/{job_id}.zip             # tenant full exports
exports/user-personal/{user_id}/{job_id}.zip # user personal exports
```
Both paths have a lifecycle rule: auto-delete after 48 hours (R2 lifecycle rule, not application-level).

### Export expiry
`download_expires_at = completed_at + 172800` (48 hours). The `/download` endpoint checks this and returns 410 if expired. R2 pre-signed URL TTL also set to 48h from generation time.

### Hard-delete idempotency
The nightly hard-delete cron is idempotent: if it fails mid-way (e.g. R2 delete times out), re-running it will attempt to delete remaining objects and rows without error.

### Cookie consent (future note)
Zync's own application uses only essential cookies (session token in HttpOnly cookie, no tracking). No third-party analytics scripts in v1. If Posthog or similar is added later, a consent banner must be implemented before the script loads. This spec does not implement a consent system; record this decision here for future reference.
