What this is
Swappr supports both legs of the GDPR “data subject rights” surface that an individual user can self-serve:| Right | Article | Entry point | Outcome |
|---|---|---|---|
| Right of access | GDPR Art. 15 | GET /me/export | A ZIP of the user’s data, emailed as a 24h signed URL. |
| Right to erasure | GDPR Art. 17 | DELETE /me | Soft-delete now, hard-delete + tombstone in 30 days. |
Right of access — data export
The flow
The 24-hour rate limit
A user is limited to one successful export per 24h. The rate-limit check is on the latest non-FAILED row:| Latest row status | requestedAt age | Behavior |
|---|---|---|
PENDING / PROCESSING / READY | < 24h | Block — 429 RATE_LIMITED with retryAfterSec. |
PENDING / PROCESSING / READY | ≥ 24h | Allow — a new PENDING row is created. |
FAILED | any | Allow. A failed export must be retryable immediately. |
| (no row) | — | Allow. |
resetAt returned to the controller is latest.requestedAt + 24h, giving the client a precise wait time.
What is in the export
| Included | Excluded |
|---|---|
users — profile fields, timestamps, status, terms/privacy acceptance, notificationsMuted. | users.passwordHash (never exported). |
current_homes — the user’s listing including media metadata. | Other users’ data — matches/messages include the counterpart’s user id but no profile dereference. |
user_preferences — the raw preferences blob. | Tenancy document file contents — only the metadata (reviewStatus, timestamps, fileKey) is included. The user already owns the document and can re-download it via the regular surface. |
matches — match rows the user is on either side of. | Audit log rows (admin-scope; never user-facing). |
conversations + messages where senderId = userId — only messages the user themselves sent. | Messages the counterpart sent (privacy; the counterpart can export their own data). |
subscriptions — the user’s RevenueCat-linked subscription state. | |
push_tokens — platform + updatedAt only. The token string is redacted. | The actual FCM token strings (they’re operational secrets, not personal data). |
uploads — metadata (fileKey, mimeType, sizeBytes, timestamps). | Object contents (the user already has them). |
tenancy_verifications — review status, timestamps, reviewer audit. |
GDPR_EXPORT_VERSION = 1) so a future schema change can be detected by automated processing.
Signed-URL semantics
- The email contains a 24-hour signed GET URL minted at READY time.
GET /me/export/:jobIdalso returns a freshly minted 24h URL on every poll, as long as the underlying row hasn’t passedexpiresAt.- The underlying S3 object lives for 30 days from completion; after that it’s eligible for cleanup (a future broom job, not implemented in Phase 7).
Right to erasure — account delete
The two-step model
Erasure is two-stage, and the first stage is reversible for the whole 30-day window:- Day 0 — soft-delete (
DELETE /me). Re-auth via current password → setusers.deletedAt, revoke all refresh tokens, cascade soft-delete tocurrent_homes. The row physically remains. - Days 0–30 — reversible (
POST /auth/reactivate). The user can undo the deletion by re-authenticating; this clearsdeletedAt/deletedReason, issues a fresh session, and writes auser.reactivatedaudit row. A plain login during this window does not reactivate — it returns403 ACCOUNT_PENDING_DELETION(carryingmeta.deletionScheduledAt), which is the client’s cue to show a reactivation screen. - Day 30 — hard-delete + tombstone (the daily 04:00 UTC
gdpr-erasureworker, or a SUPER admin’s manual trigger). Child rows are hard-deleted; the user row is tombstoned (PII nulled,userErasedAtset). Past this point reactivation is impossible.
Why tombstone, not hard-delete?
The user row’s_id is referenced by:
audit_logs.actorId— admin actions performed against this user, or actions this user performed. Per §14.3 we keep the row but anonymize theactorIdtonulland the JSONpayloadfor any rows whereactorId === userId.subscriptions.userId— financial records (7-year retention required under UK law).conversations.participantIdsthat other users were part of.
_id alive while nulling all PII fields (email, firstName, lastName, dateOfBirth, passwordHash, phone) and setting userErasedAt — preserves FK integrity for the surviving counterparties’ data while still satisfying erasure (no PII left on the row).
Carve-outs
Per QUESTIONS.md §9.1 + §14.3, three classes of data survive the 30-day erasure:| Surviving data | Why | What happens |
|---|---|---|
audit_logs (admin actions) | Regulatory / dispute trail | Row stays. actorId anonymized to null if it referenced the erased user. JSON payload also scrubbed for that user’s id. |
subscriptions | UK financial 7-year retention | Row stays. userErasedAt is tagged onto the row for bookkeeping — it’s still linked to the tombstoned user _id via foreign key. |
gdpr_export_jobs | Deleted with user | Rows + the S3 ZIP objects are hard-deleted (no point retaining a ZIP of a now-erased user). |
current_homes, user_preferences, tenancy_verifications, uploads (rows + S3 objects), matches, conversations + messages where the user is a participant, reports (filed by OR against the user), blocks (both directions), refresh_tokens — is hard-deleted.
Cron timing
| Worker | Schedule | What it does |
|---|---|---|
retention-cleanup | 03:00 UTC daily | Deletes tenancy documents 30 days post-decision. See tenancy-retention. |
gdpr-erasure | 04:00 UTC daily | Hard-deletes + tombstones soft-deleted users older than 30 days. |
The manual trigger
POST /admin/erasure/run — SUPER only — lets ops enqueue a one-off run mid-day. Same handler as the scheduled run; returns 202 Accepted with no result counts (logged by the worker). Mirrors the retention manual trigger shape exactly.
Failure semantics
The erasure worker uses per-usertry/catch and never fails the whole batch on one bad user:
| Failure | Handling |
|---|---|
eraseUser throws for one user (DB blip, S3 transient) | Logged at warn with { userId, err }. failed++. The user’s userErasedAt stays null → retried next run. |
Per-collection sub-step throws inside eraseUser | The adapter’s responsibility — it logs and continues so that as much of the user’s data is erased as possible. Worst case the user reappears in tomorrow’s run. |
Already-erased user (userErasedAt !== null) | Never seen — the scan filter excludes them at the store level. |
Failure isolation and observability
Both workers log a run summary ({ scanned, erased, failed, cutoff } for erasure; the equivalent for export). The currently observable trail:
- Worker logs — every run summary, every per-row failure with structured fields.
- Audit log — manual triggers (retention + erasure) write a normal admin-actor audit row. The autonomous daily runs do not write to the audit log (deferred decision — see tenancy-retention for the parallel discussion).
See also
- Request GDPR data export — the user-facing entry point.
- Get export status — poll the export job.
- Delete account — the user-facing erasure entry point.
- Trigger erasure run — SUPER-only manual kick.
- Tenancy retention — the sibling 30-day deletion worker for tenancy documents.
- Worker bootstrap and queues — the BullMQ wiring + scheduling.
apps/worker/src/jobs/gdpr-export.ts— export job handler.apps/worker/src/jobs/gdpr-erasure.ts— erasure job handler.apps/api/src/modules/me/delete-account.service.ts— the soft-delete entry point.