All docs

DSR — Data Subject Request flow

DSR — Data Subject Request flow

Tables: dsr_requests, users.deletion_scheduled_at (migration 0014_dsr_requests.sql) Drizzle: packages/db/src/schema/dsr.ts, tenancy.ts Helpers: @drobek/core (buildUserExport, requestAccountDeletion, cancelAccountDeletion, hardDeleteScheduledUsers, assertNotSoleOwner) Routes: apps/web/app/routes/api.dsr.export.tsx, api.dsr.delete.tsx, _app.account.tsx Reaper: pnpm -F @drobek/core dsr:reap (also task dsr:reap inside the web container) Decided by: ADR 0008

Flows

Export (GDPR Art. 15 + 20)

  1. User clicks "Download export" on /account (or GET /api/dsr/export).
  2. Loader calls buildUserExport(db, userId) which collects:

- the users row, - all memberships + the org rows behind them, - plans the user approved (plans.approved_by_user_id = userId), - task comments authored by the user, - task amendments proposed by the user, - the user's sessions (history of where they signed in from), - pro-waitlist row if the user's email is on the list, - this user's own dsr_requests history, - agent_audit rows where user_id = userId.

  1. The response is application/json; charset=utf-8 with

Content-Disposition: attachment; filename="drobek-export-<userId>-<ts>.json".

  1. dsr_requests row written with kind='export', state='completed',

payload = { bytes }.

  1. Rate-limited to 3/hour per user (Redis token bucket).

The export tree is versioned via the top-level schema: "dsr_export_v1" field — bump on any structural change.

Delete (GDPR Art. 17)

  1. User opens /account, clicks "Delete account…", types their email to

confirm.

  1. POST /api/dsr/delete calls requestAccountDeletion(db, userId, …) which:

- asserts the user is not the sole owner of any org (else throws ConflictError), - sets users.deletion_scheduled_at = now() + graceDays (default 30), - revokes the user's active sessions in the same transaction, - inserts a dsr_requests row with kind='delete'.

  1. The route drops the session cookie (Set-Cookie with Max-Age=0).
  2. While the schedule is set, the global _app layout shows a sticky banner

on every page with a "Cancel deletion" link to /account.

  1. User signs back in → opens /account → submits cancel → `POST

/api/dsr/delete with op=cancelcancelAccountDeletion clears the column and writes a cancel_delete` row.

  1. If the user does nothing, the reaper hard-deletes the row when

deletion_scheduled_at <= now().

Reaper

# Inside the dev web container
task dsr:reap

# Or directly
pnpm -F @drobek/core dsr:reap

Production wires this to a daily cron at the host level (task 49 monitoring). Per-user transaction; the sole-owner check runs again inside the reap so a race with a membership change between scheduling and reaping doesn't orphan an org.

For testing with a zero grace, set DSR_DELETE_GRACE_DAYS=0 on the scheduling request — the reaper itself only reads the column, no env override needed.

CASCADE behaviour on hard-delete

Postgres handles dependent rows via the FKs declared in earlier migrations:

Cross-org content (comments, amendments, audit) stays — these belong to the org, not the user. The author column goes to NULL and the UI renders "(deleted user)" when both author_user_id IS NULL and author_kind = 'user'. No enum change required.

Sole-owner block

assertNotSoleOwner(db, userId) returns ConflictError if the user holds the only owner membership in any org. Resolution path: transfer ownership (/o/<slug>/members) or delete the org from its settings page first. The error message names the offending org so the UI can deep-link there.

Tests

  • Unit/integration: packages/core/src/dsr/dsr.integration.test.ts — 6 tests

covering export shape, sole-owner block, schedule/cancel, and reap CASCADE.

  • E2E: see apps/web/app/e2e/dsr.e2e.test.ts (added with this task) for the

HTTP-level surface.

Privacy Policy hook

Task 43 (legal) references this flow as the "self-service" path for GDPR Art. 15/17/20. The Privacy Policy must mention /account as the self-service entry point — no manual support contact required.