What this is

Swappr supports both legs of the GDPR “data subject rights” surface that an individual user can self-serve:
RightArticleEntry pointOutcome
Right of accessGDPR Art. 15GET /me/exportA ZIP of the user’s data, emailed as a 24h signed URL.
Right to erasureGDPR Art. 17DELETE /meSoft-delete now, hard-delete + tombstone in 30 days.
This page covers the lifecycle of both — what’s included, what’s deliberately carved out, why the user row is tombstoned rather than deleted, the cron timing, and the failure semantics that keep the workers idempotent. Source of truth: SWAPPR_BACKEND_PLAN §14.3 + QUESTIONS.md §9.1.

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 statusrequestedAt ageBehavior
PENDING / PROCESSING / READY< 24hBlock — 429 RATE_LIMITED with retryAfterSec.
PENDING / PROCESSING / READY≥ 24hAllow — a new PENDING row is created.
FAILEDanyAllow. A failed export must be retryable immediately.
(no row)Allow.
The resetAt returned to the controller is latest.requestedAt + 24h, giving the client a precise wait time.

What is in the export

IncludedExcluded
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_tokensplatform + 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.
The export is versioned (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/:jobId also returns a freshly minted 24h URL on every poll, as long as the underlying row hasn’t passed expiresAt.
  • 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:
  1. Day 0 — soft-delete (DELETE /me). Re-auth via current password → set users.deletedAt, revoke all refresh tokens, cascade soft-delete to current_homes. The row physically remains.
  2. Days 0–30 — reversible (POST /auth/reactivate). The user can undo the deletion by re-authenticating; this clears deletedAt/deletedReason, issues a fresh session, and writes a user.reactivated audit row. A plain login during this window does not reactivate — it returns 403 ACCOUNT_PENDING_DELETION (carrying meta.deletionScheduledAt), which is the client’s cue to show a reactivation screen.
  3. Day 30 — hard-delete + tombstone (the daily 04:00 UTC gdpr-erasure worker, or a SUPER admin’s manual trigger). Child rows are hard-deleted; the user row is tombstoned (PII nulled, userErasedAt set). 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 the actorId to null and the JSON payload for any rows where actorId === userId.
  • subscriptions.userId — financial records (7-year retention required under UK law).
  • conversations.participantIds that other users were part of.
Hard-deleting the user row would orphan all of these. Tombstoning — keeping the _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 dataWhyWhat happens
audit_logs (admin actions)Regulatory / dispute trailRow stays. actorId anonymized to null if it referenced the erased user. JSON payload also scrubbed for that user’s id.
subscriptionsUK financial 7-year retentionRow stays. userErasedAt is tagged onto the row for bookkeeping — it’s still linked to the tombstoned user _id via foreign key.
gdpr_export_jobsDeleted with userRows + the S3 ZIP objects are hard-deleted (no point retaining a ZIP of a now-erased user).
Everything else — 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

WorkerScheduleWhat it does
retention-cleanup03:00 UTC dailyDeletes tenancy documents 30 days post-decision. See tenancy-retention.
gdpr-erasure04:00 UTC dailyHard-deletes + tombstones soft-deleted users older than 30 days.
The 1-hour offset is deliberate — retention runs first so a tenancy document that’s both “30d post-decision” AND “owned by a soft-deleted user” is removed by retention first (its dedicated path), and the erasure worker doesn’t need to re-handle it.

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-user try/catch and never fails the whole batch on one bad user:
FailureHandling
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 eraseUserThe 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.
A run that hits 50 failures and 450 successes processes the 50 again tomorrow. The per-run cap is 500 users to match retention.

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