Skip to content

feat(patients): account deletion with clinical-record retention: anonymise identity, keep the audit trail - #156

Open
zainabwahab-eth wants to merge 1 commit into
DocForum:mainfrom
zainabwahab-eth:feat/41-account-deletion-retention
Open

zainabwahab-eth wants to merge 1 commit into
DocForum:mainfrom
zainabwahab-eth:feat/41-account-deletion-retention

Conversation

@zainabwahab-eth

Copy link
Copy Markdown
Contributor

Closes #41

Decision record

docs/adr/0007-account-deletion-and-clinical-record-retention.md, written before the code. It has the full FK inventory (read from the committed migrations, not assumed) and the per-model retention table: what's deleted, anonymised, or retained, for how long, and why.

The legal retention period for clinical, order and payment records is not set by anything in the repo, and this PR does not invent one. Those records are kept indefinitely, and the ADR lists the period as an open decision. That was confirmed with the maintainer before implementation. Keeping too long is reversible; deleting too early isn't.

What changed

DELETE /patients/me with body { password }, patient-only, account taken from the session. In one transaction it:

Field Becomes
User.email deleted+<userId>@deleted.invalid (frees the address; the .invalid TLD is reserved by RFC 2606)
User.phone, PatientProfile.dateOfBirth, PatientProfile.guardianUserId null
PatientProfile.fullName Deleted patient
User.passwordHash bcrypt hash of 32 random bytes
User.emailVerifiedAt null
User.deletedAt, PatientProfile.anonymizedAt (new) set

It also deletes the user's EmailVerificationToken rows and the LoginAttempt bucket keyed by the old address.

  • Appointments and payment intents are untouched and still reference the same profile id.
  • Login is deactivated. Login and refresh refuse deletedAt. Every patient route refuses an anonymised profile (getMyPatientProfile), and booking/payments refuse the cleared verification. A still-unexpired access token can reach nothing (ARCHITECTURE §6.1.6; the token window itself is [Phase 8] JWT refresh-token revocation #18's job).
  • Re-confirmation shares the login throttle ([Phase 8] Login brute-force protection: per-account and per-IP throttling with backoff #35), so a stolen access token can't brute-force the password here. Wrong password returns 403 PASSWORD_CONFIRMATION_FAILED, then 429 + Retry-After.
  • Idempotent. A repeat request returns 204 and changes nothing. Concurrent requests anonymise exactly once (conditional UPDATE ... WHERE deletedAt IS NULL).

Migration 20261017084237_account_deletion_retention. PatientProfile.userId → User goes from CASCADE to RESTRICT, and that is the only FK changed. Appointment/PaymentIntent → PatientProfile were already RESTRICT (Prisma's default) and are now written explicitly in the schema; the generated SQL confirms there's no change there. Doctor and facility cascades are deliberately left alone: no deletion flow exists for those roles, and their clinical/payment references are already RESTRICT (reasoning in the ADR).

Scheduled purge (src/jobs/account-purge.ts, a plain interval). It hard-deletes only record-free tombstones, after ACCOUNT_PURGE_GRACE_HOURS (default 24, operational rather than legal). Each module answers "does this patient have records?" through its own service (src/retention-holds.ts), and the RESTRICT FKs are the race backstop. It fails closed if no holds are registered, logs counts and error types only, and is safe to re-run.

Found while testing: Postgres reports ON DELETE RESTRICT as SQLSTATE 23001, which Prisma surfaces as PrismaClientUnknownRequestError, not P2003. The purge classifier (isForeignKeyRefusal) handles both and is tested against the real error. The global error handler maps neither to a 4xx today; that's noted in ARCHITECTURE §6.1.6 and left alone as out of scope.

Docs

  • ADR 0007
  • ARCHITECTURE.md §3.1 (deletion and retention holds: what a new clinical module must do), §4.1 (deleted_at, anonymized_at, FK), §6.1.6
  • ARCHITECTURE_ESSENTIALS.md
  • docs/api/README.md (endpoint, semantics, errors, after-deletion behaviour)
  • backend/src/jobs/README.md, .env.example
  • ROADMAP.md (Phase 8 item and changelog)

Tests

tests/integration/account-deletion.test.mts (10, own embedded Postgres on port 5439, populated DB: a doctor, five patients, real bookings through the API, escrowed payment intents with tx hashes, phone/DOB/guardian filled in, a throttle bucket from a failed login):

  • missing/wrong password and non-patients → nothing changes
  • wrong confirmations hit the throttle → 429
  • PII gone from both rows, tokens and throttle bucket deleted, appointments + payments byte-for-byte identical, slot still booked
  • login (original and tombstone address), refresh, /appointments/mine and booking all refused for the old session
  • original email signs up as a new empty account
  • repeat and concurrent deletion are idempotent
  • an unverified account can delete itself
  • RESTRICT refuses a raw User/PatientProfile delete (exercising the changed FK and the existing ones)
  • purge removes only the tombstone past grace with no records; keeps the one inside grace and the one with records; re-run is a no-op
  • unrelated patient completely unchanged

tests/unit/account-deletion.test.ts (5): tombstone address properties, isForeignKeyRefusal for both error shapes and for unrelated errors.

Mutation check: leaving dateOfBirth un-anonymised makes the anonymisation test fail.

npm run typecheck        → clean
npm test                 → Test Files 7 passed (7) · Tests 35 passed (35)
npm run test:integration → Test Files 5 passed (5) · Tests 32 passed (32)
npm run build            → clean

These were run in Ubuntu (WSL) on Node 20 to match CI, with --hookTimeout raised locally (the existing suites' beforeAll exceeds 10s on this machine).

Notes for reviewers

🤖 Generated with Claude Code

@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@zainabwahab-eth Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

CI is failing on this PR. main itself is green, so the cause is in this branch:

Typecheck fails: src/app.ts(18,3): Cannot find name 'registerRetentionHolds'. app.ts calls it, but the import from ./retention-holds is missing, probably dropped when you merged main in. Add the import. This PR and #155 both add a register-call at the same spot in createApp(), so whichever merges second will need a small rebase.

Please push a fix and CI will re-run. Before pushing, run npm run prisma:generate && npm run typecheck && npm test && npm run test:integration in backend/.

@afeezorobsco-cyber afeezorobsco-cyber left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed: password re-confirmation shares the login throttle bucket, anonymisation is a single conditional transaction (idempotent under concurrency), refresh/login reject deleted accounts, and the purge fails closed with no retention holds and is backstopped by RESTRICT FKs. LGTM.

This now conflicts with main after #155 (patient export) was merged — both touch schema.prisma, app.ts, config, and the patients module. Please rebase onto main; it can merge once CI is green again.

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@zainabwahab-eth resolve conflicts

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@zainabwahab-eth Thanks for the PR. It can't be merged yet. To unblock it:

Merge conflicts: this branch conflicts with main. Please rebase onto the latest main (git fetch upstream && git rebase upstream/main), resolve the conflicts (often ROADMAP.md, ARCHITECTURE*.md or prisma/schema.prisma), and force-push.

Push to this same branch and CI will re-run. Once it's green and conflict-free, it'll be reviewed for merge.

@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 20, 2026
…ymise identity, keep the audit trail

ADR 0007 decides, per model, what is deleted, anonymised or retained. The
FK inventory was taken from the committed migrations. The legal retention
period for clinical, order and payment records is left as an explicit
open decision (kept indefinitely).

- DELETE /patients/me with password re-confirmation, sharing the login
  throttle. It anonymises in place in one transaction: email tombstoned
  to deleted+<id>@deleted.invalid (frees the address), phone/DOB/guardian
  cleared, name -> "Deleted patient", unusable password, verification
  cleared, User.deletedAt + PatientProfile.anonymizedAt set, verification
  tokens and the email throttle bucket deleted. Idempotent and safe under
  concurrency.
- Login and refresh refuse deleted accounts; every patient route refuses
  an anonymised profile.
- Appointments and payment intents are kept unchanged.
- Migration: PatientProfile.userId FK CASCADE -> RESTRICT (the only FK
  changed). Appointment/PaymentIntent RESTRICT is now explicit in the
  schema; no SQL change there.
- Interval purge of record-free tombstones after a grace period.
  Retention holds come from each module's service (src/retention-holds.ts),
  with the RESTRICT FKs as the race backstop.

Closes DocForum#41

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@freebuff-web
freebuff-web Bot force-pushed the feat/41-account-deletion-retention branch from 8c53132 to bd6dd11 Compare October 21, 2026 04:45
@github-actions github-actions Bot added needs-rebase Has merge conflicts with main; the author needs to rebase and removed needs-rebase Has merge conflicts with main; the author needs to rebase labels Oct 21, 2026
@github-actions

Copy link
Copy Markdown

@zainabwahab-eth This PR now has merge conflicts with main, most likely because #211 (feat(referrals): route specialty-only referrals to the least-loaded e…) was just merged, so it can't be merged until it's rebased:

git fetch upstream            # upstream = https://github.com/DocForum/docforum-core.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-lease

The usual culprits are shared docs like ROADMAP.md and ARCHITECTURE*.md. If the conflict is in the ROADMAP.md changelog, move your entry into a new file under changelog.d/ (see changelog.d/README.md) rather than resolving it in place, so it can't conflict again. CI re-runs when you push, and the needs-rebase label is removed automatically once the conflicts are resolved.

Automated message. It won't repeat unless the PR conflicts again after your next push.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-rebase Has merge conflicts with main; the author needs to rebase

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Phase 8] Account deletion with clinical-record retention: anonymise identity, keep the audit trail

2 participants