Repository navigation
feat(patients): account deletion with clinical-record retention: anonymise identity, keep the audit trail - #156
Conversation
|
@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! 🚀 |
|
CI is failing on this PR. Typecheck fails: Please push a fix and CI will re-run. Before pushing, run |
afeezorobsco-cyber
left a comment
There was a problem hiding this comment.
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.
|
@zainabwahab-eth resolve conflicts |
|
@zainabwahab-eth Thanks for the PR. It can't be merged yet. To unblock it: Merge conflicts: this branch conflicts with Push to this same branch and CI will re-run. Once it's green and conflict-free, it'll be reviewed for merge. |
…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>
8c53132 to
bd6dd11
Compare
|
@zainabwahab-eth This PR now has merge conflicts with 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-leaseThe usual culprits are shared docs like Automated message. It won't repeat unless the PR conflicts again after your next push. |
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/mewith body{ password }, patient-only, account taken from the session. In one transaction it:User.emaildeleted+<userId>@deleted.invalid(frees the address; the.invalidTLD is reserved by RFC 2606)User.phone,PatientProfile.dateOfBirth,PatientProfile.guardianUserIdnullPatientProfile.fullNameDeleted patientUser.passwordHashUser.emailVerifiedAtnullUser.deletedAt,PatientProfile.anonymizedAt(new)It also deletes the user's
EmailVerificationTokenrows and theLoginAttemptbucket keyed by the old address.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).403 PASSWORD_CONFIRMATION_FAILED, then429+Retry-After.204and changes nothing. Concurrent requests anonymise exactly once (conditionalUPDATE ... WHERE deletedAt IS NULL).Migration
20261017084237_account_deletion_retention.PatientProfile.userId → Usergoes from CASCADE to RESTRICT, and that is the only FK changed.Appointment/PaymentIntent → PatientProfilewere 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, afterACCOUNT_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 RESTRICTas SQLSTATE 23001, which Prisma surfaces asPrismaClientUnknownRequestError, notP2003. 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
deleted_at,anonymized_at, FK), §6.1.6docs/api/README.md(endpoint, semantics, errors, after-deletion behaviour)backend/src/jobs/README.md,.env.exampleTests
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):429/appointments/mineand booking all refused for the old sessionUser/PatientProfiledelete (exercising the changed FK and the existing ones)tests/unit/account-deletion.test.ts(5): tombstone address properties,isForeignKeyRefusalfor both error shapes and for unrelated errors.Mutation check: leaving
dateOfBirthun-anonymised makes the anonymisation test fail.These were run in Ubuntu (WSL) on Node 20 to match CI, with
--hookTimeoutraised locally (the existing suites'beforeAllexceeds 10s on this machine).Notes for reviewers
PatientExportrows are derived PHI copies and must be deleted inside the anonymisation transaction. Whichever of the two merges second needs that one-line wiring. It's recorded in the ADR table and the ROADMAP item. Both PRs also addapp.use('/patients', ...)and aserver.tsjob line, so expect a trivial conflict there.User.deactivatedAt.deletedAthere is deliberately a separate, irreversible marker.🤖 Generated with Claude Code