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)
- User clicks "Download export" on
/account(orGET /api/dsr/export). - 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.
- The response is
application/json; charset=utf-8with
Content-Disposition: attachment; filename="drobek-export-<userId>-<ts>.json".
dsr_requestsrow written withkind='export',state='completed',
payload = { bytes }.
- 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)
- User opens
/account, clicks "Delete account…", types their email to
confirm.
POST /api/dsr/deletecallsrequestAccountDeletion(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'.
- The route drops the session cookie (
Set-CookiewithMax-Age=0). - While the schedule is set, the global
_applayout shows a sticky banner
on every page with a "Cancel deletion" link to /account.
- User signs back in → opens
/account→ submits cancel → `POST
/api/dsr/delete with op=cancel → cancelAccountDeletion clears the column and writes a cancel_delete` row.
- 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:reapProduction 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:
| Table | FK action on user delete |
|---|---|
memberships | CASCADE |
sessions | CASCADE |
oauth_identities | CASCADE |
magic_links | CASCADE |
dsr_requests | CASCADE |
plans.approved_by_user_id | SET NULL |
task_comments.author_user_id | SET NULL |
task_amendments.proposed_by_user_id | SET NULL |
agent_audit.user_id | SET NULL |
agents.created_by_user_id | SET NULL |
agent_tokens.revoked_by_user_id | SET NULL |
invitations.invited_by_user_id | CASCADE |
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.