Skip to content

feat(patients): guardian and dependent accounts: booking on behalf of a minor - #157

Open
zainabwahab-eth wants to merge 4 commits into
DocForum:mainfrom
zainabwahab-eth:feat/42-guardian-dependent-accounts
Open

zainabwahab-eth wants to merge 4 commits into
DocForum:mainfrom
zainabwahab-eth:feat/42-guardian-dependent-accounts

Conversation

@zainabwahab-eth

Copy link
Copy Markdown
Contributor

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.

  • Dependents are guardian-owned 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.
  • Create-only. A guardian can only create a new dependent profile. No API links an existing profile or account, and the request body is strict, so patientProfileId/userId return 400. Self-asserted guardianship (v1) therefore can't unlock anything that already exists.
  • Only patients can be guardians, and they need a verified email, the same bar as booking.
  • Access ends automatically at 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.
  • Revocation is 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:

  • New Guardianship table (guardianUserId → User, dependentProfileId → PatientProfile, both FKs RESTRICT), unique on (guardianUserId, dependentProfileId), plus revokedAt.
  • PatientProfile.userId becomes nullable (still unique). A CHECK ("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.
  • The unenforced PatientProfile.guardianUserId is 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 } returns 201 { id, fullName, dateOfBirth, guardianSince }. A DOB at or over the age of majority returns 400 DEPENDENT_NOT_A_MINOR; a future or impossible date returns 400.
  • GET /patients/me/dependents returns only the caller's active dependents.
  • POST /appointments accepts an optional patientProfileId. GET /appointments/mine accepts ?patientProfileId=.

Booking authorisation sits in the service layer: patientsService.resolveActingProfile(userId, patientProfileId).

Target Result
Omitted, or the caller's own profile own profile (self-booking unchanged)
An active dependent of the caller that dependent
Revoked, or reached majority 403 GUARDIANSHIP_INACTIVE
Anything else 404, no enumeration of other patients or other guardians' dependents

bookSlot diff is one line: getMyPatientProfile(userId) became resolveActingProfile(userId, patientProfileId), which runs before the transaction. The transaction (claimOpenSlot conditional UPDATE, then the Appointment insert) is untouched.

Docs

  • ADR 0008
  • PRD §8.2.5 (resolved/re-scoped, open items listed)
  • ARCHITECTURE.md §4 intro and §4.1 (Guardianship, nullable userId, CHECK)
  • ARCHITECTURE_ESSENTIALS.md
  • docs/api/README.md, .env.example
  • ROADMAP.md (Phase 8 item and changelog)

Tests

tests/integration/guardianship.test.mts (12, own embedded Postgres on port 5440, real HTTP and DB):

  • creating a dependent (login-less profile plus guardianship row)
  • refused creation: linking attempts via patientProfileId/userId, adult/future/impossible DOB, doctor, unverified, anonymous; one day short of majority is accepted
  • listing only your own active dependents
  • guardian books for their dependent and lists their appointments
  • another guardian's dependent, or another adult patient: 404 with the slot still open and no appointment row, proving rejection happens before booking
  • self-booking with and without your own id
  • empty / unknown / missing patientProfileId
  • revocation and reaching majority both give 403 GUARDIANSHIP_INACTIVE, drop out of the list, and keep the history
  • DB constraints: unique pair, dependent DOB CHECK, RESTRICT
  • concurrency guarantee re-proven: a guardian-for-dependent booking racing six self-bookings on one slot, exactly one 201 and six 409, one appointment, slot booked
  • the migration-preservation and legacy-refusal tests described above

The 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, bookSlotSchema backwards compatibility.

Mutation check: making the guardianship lookup ignore which guardian owns it makes the cross-guardian test fail.

npm run typecheck        → clean
npm test                 → Test Files 7 passed (7) · Tests 39 passed (39)
npm run test:integration → Test Files 5 passed (5) · Tests 34 passed (34)
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

… 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>
@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:

Integration tests fail (9): every test in your new tests/integration/guardianship.test.mts gets 404 where it expects 201/200/400. That suggests the dependents/guardianship routes aren't mounted in app.ts, possibly lost when you merged main in. The booking tests also show bookings for another guardian's dependent getting 201 instead of 404/403, so the guardianship check isn't applied on the booking path either. The DB-constraint test gets a PrismaClientValidationError instead of P2002. Check that the migration and schema agree.

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/.

zainabwahab-eth and others added 2 commits October 19, 2026 01:39
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 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: 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.

@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

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] Guardian and dependent accounts: booking on behalf of a minor

2 participants