Repository navigation
feat(patients): guardian and dependent accounts: booking on behalf of a minor - #157
zainabwahab-eth wants to merge 4 commits into
Conversation
… a minor ADR 0008 decides the model: a dependent is a guardian-owned PatientProfile with no login, linked through a real Guardianship relation. Create-only: an existing profile or account can never be linked. Access ends automatically at AGE_OF_MAJORITY_YEARS (default 18). - Migration: PatientProfile.userId nullable (still unique), new Guardianship table (FKs RESTRICT, unique per guardian/dependent pair, revokedAt), a CHECK requiring a DOB on login-less profiles, and the unenforced guardianUserId string dropped. The migration aborts if any legacy value exists rather than discarding it. - POST /patients/me/dependents (strict body, minor only, verified email) and GET /patients/me/dependents (own active dependents only). - POST /appointments accepts patientProfileId; GET /appointments/mine accepts ?patientProfileId=. Both are authorised by patientsService.resolveActingProfile in the service layer, before the booking transaction. The transaction itself is unchanged. - PRD §8.2.5 marked resolved/re-scoped. Closes DocForum#42 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@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. Integration tests fail (9): every test in your new Please push a fix and CI will re-run. Before pushing, run |
Brings in DocForum#158 (AuditEvent), DocForum#147 (access-control matrix) and DocForum#150. - bookSlot keeps both changes: bookSlot(actor, slotId, patientProfileId?). Guardian authorisation (resolveActingProfile) still runs before the transaction, and DocForum#43's audit write stays inside it. The event's metadata now names the patientProfileId booked for, so a guardian booking is attributable (actor = guardian), and an integration test asserts it. - app.ts mounts both /patients and /admin. - POST/GET /patients/me/dependents are added to DocForum#147's access-control matrix. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… resolution The web merge of main dropped the /patients router mount and DocForum#42's patientProfileId booking path, which is why every guardianship integration test failed. This branch's tree already contains main plus a correct resolution (bookSlot(actor, slotId, patientProfileId?)), so it is kept as-is.
afeezorobsco-cyber
left a comment
There was a problem hiding this comment.
Reviewed: resolveActingProfile is a single authorisation point — foreign/unknown profiles 404 (no enumeration), ended guardianships 403, dependents are create-only, and majority math is UTC with the 29 Feb rule unit-tested. LGTM.
This now conflicts with main after #155 was merged (shared schema.prisma, app.ts, config, patients routes/services). Please rebase onto main; it can merge once CI is green again.
|
@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. |
Closes #42
Decision record
docs/adr/0008-guardian-dependent-profiles.md, written before the code. The model and the age-of-majority behaviour were confirmed with the maintainer before implementation.PatientProfiles with no login, not separate accounts linked later. Children don't have email or passwords, and a "link an existing patient" operation is exactly how one user reaches another person's records.patientProfileId/userIdreturn400. Self-asserted guardianship (v1) therefore can't unlock anything that already exists.AGE_OF_MAJORITY_YEARS(default 18, UTC calendar date; a 29 Feb birthday reaches it on 1 Mar in non-leap years). It's checked at request time, so there's no job and no stale flag. The profile and its history are kept. The adult claiming their profile is a follow-up.Guardianship.revokedAt, honoured everywhere. It's a soft end, never a delete. The revoke endpoints are follow-ups.What changed
Migration
20261017102353_guardian_dependent_profiles:Guardianshiptable (guardianUserId → User,dependentProfileId → PatientProfile, both FKs RESTRICT), unique on(guardianUserId, dependentProfileId), plusrevokedAt.PatientProfile.userIdbecomes nullable (still unique). ACHECK ("userId" IS NOT NULL OR "dateOfBirth" IS NOT NULL)makes the majority rule always computable. Prisma can't express this, so it's hand-written in the migration.PatientProfile.guardianUserIdis dropped. No code ever wrote it. The migration aborts if any row still has a value rather than silently discarding or reinterpreting it. Both paths are tested on real databases built at the previous migration: existing profiles and appointments are preserved, and over legacy data it refuses and changes nothing.Endpoints
POST /patients/me/dependents{ fullName, dateOfBirth }returns201 { id, fullName, dateOfBirth, guardianSince }. A DOB at or over the age of majority returns400 DEPENDENT_NOT_A_MINOR; a future or impossible date returns400.GET /patients/me/dependentsreturns only the caller's active dependents.POST /appointmentsaccepts an optionalpatientProfileId.GET /appointments/mineaccepts?patientProfileId=.Booking authorisation sits in the service layer:
patientsService.resolveActingProfile(userId, patientProfileId).403 GUARDIANSHIP_INACTIVE404, no enumeration of other patients or other guardians' dependentsbookSlotdiff is one line:getMyPatientProfile(userId)becameresolveActingProfile(userId, patientProfileId), which runs before the transaction. The transaction (claimOpenSlotconditionalUPDATE, then theAppointmentinsert) is untouched.Docs
Guardianship, nullableuserId, CHECK)docs/api/README.md,.env.exampleTests
tests/integration/guardianship.test.mts(12, own embedded Postgres on port 5440, real HTTP and DB):patientProfileId/userId, adult/future/impossible DOB, doctor, unverified, anonymous; one day short of majority is accepted404with the slot stillopenand no appointment row, proving rejection happens before bookingpatientProfileId403 GUARDIANSHIP_INACTIVE, drop out of the list, and keep the history201and six409, one appointment, slotbookedThe existing
booking.test.mts(8-way concurrent proof) passes unchanged.tests/unit/guardianship.test.ts(9): majority boundaries (the exact instant, 29 Feb, configured age, newborn), strict schema rejecting linking fields and impossible dates,bookSlotSchemabackwards compatibility.Mutation check: making the guardianship lookup ignore which guardian owns it makes the cross-guardian 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
PatientProfile.guardianUserId, which this PR drops. Whichever merges second removes that one assignment and instead revokes the deleting user's guardianships in the same transaction (ADR 0008, open decisions). Both PRs also addapp.use('/patients', ...), so expect a trivial conflict.resolveActingProfile. It's not wired here.🤖 Generated with Claude Code