04-clinicalwave: W2filled14 citations

Emergency Contacts and Patient Relations

Audiences: developer

Emergency Contacts and Patient Relations

Emergency contacts in Dudoxx HMS are Prisma-backed records (emergencyContact table in ddx_api_main) linked to a patient by userId, with a primary-contact flag, full audit trail, and a REST API scoped to patients/:patientId/emergency-contacts with organization-membership validation on every access.

Business Purpose

Emergency contacts serve two clinical purposes:

  1. Emergency notification: When a patient is incapacitated, clinicians need to reach the designated caregiver or family member quickly. A structured, searchable record with verified phone and relationship replaces relying on paper forms or memory.
  2. Consent and proxy access: The primary emergency contact may hold consent authority (e.g., for minors, elderly patients with guardians). Knowing who to call and in what order supports clinical and legal compliance.

Dudoxx HMS stores emergency contacts as Prisma rows — not FHIR — because contact information is operational data (fast lookup, mutable, not part of the clinical record set) rather than clinical data that needs FHIR interoperability. However, at patient registration time, emergency contacts with a defined relationship are also mirrored as FHIR RelatedPerson resources in HAPI to support FHIR $everything exports and referral network sharing.

Audiences

  • Investor: Emergency contact capture at registration time demonstrates clinical completeness — a metric that matters in DSGVO audit contexts and insurance pre-authorization workflows where a guardian's contact is required.
  • Clinical buyer (doctor/nurse/receptionist): Receptionists can add or update emergency contacts during patient intake or at any subsequent appointment. The primary contact is clearly flagged and always appears first in the list. Patients can manage their own emergency contacts via the patient portal.
  • Developer/partner: REST CRUD at GET|POST|PATCH|DELETE /api/v1/patients/:patientId/emergency-contacts. Short-circuit endpoint GET /primary returns the primary contact directly. Requires patient membership in the caller's organization. PATIENT role can CRUD their own contacts. All other clinical roles (DOCTOR, NURSE, RECEPTIONIST, CLINIC_ADMIN, SUPER_ADMIN) can access any patient's contacts within their organization.
  • Internal (ops/support): Emergency contacts live in prisma.emergencyContact table (Prisma schema ddx_api_main). Each row references userId (the patient's Prisma User UUID). The isPrimary flag has a soft-unique constraint: setting a new primary contact unsets all other primaries for that patient within the same write. Audit rows emitted to ddx_api_log on every create/update/delete.

Architecture

diagram
EmergencyContactsController  (/api/v1/patients/:patientId/emergency-contacts)
  └── EmergencyContactsService
        ├── PrismaService                 ← emergencyContact + organizationMember tables
        ├── AuditService                  ← data-change audit rows
        ├── OrganizationResolverService   ← slug → UUID for membership check
        └── PatientIdResolverService      ← FHIR ID → UUID normalization

Prisma emergencyContact model (key fields):
  id            UUID (primary key)
  userId        UUID (patient's User.id)
  firstName     String
  lastName      String
  relationship  String? (e.g. 'spouse', 'parent', 'guardian')
  phone         String?
  email         String?
  address       String?
  isPrimary     Boolean (default false)
  notes         String?
  createdAt     DateTime
  updatedAt     DateTime

FHIR RelatedPerson (created at registration):
  resourceType  RelatedPerson
  patient       Patient/{fhirPatientId}
  relationship  mapped via mapRelationship()
  name          HumanName
  telecom       phone + email

The validatePatientAccess() guard checks:

  1. PATIENT role: user.id === patientId — patients can only access their own contacts.
  2. All other roles: prisma.organizationMember.findFirst({ userId: patientId, organizationId: orgUuid }) — patient must be in the caller's organization.

See coding_context/ddx-hms-context.md §Clinical Data for the module map.

Tech Stack & Choices

LayerTechnologyRationale
APINestJS 11, @Controller('patients/:patientId/emergency-contacts')Nested resource route — contact is always accessed in context of a patient
AuthAll clinical roles + PATIENT role; self-access only for PATIENTBroad access for clinical staff; PATIENT can manage their own
StoragePrisma emergencyContact tableOperational contact data — not clinical; fast CRUD without FHIR overhead
FHIR mirrorRelatedPerson at patient creation (optional)Created by PatientCreationService for FHIR completeness; not kept in sync on post-creation updates
Primary contactSoft-unique via updateMany({ isPrimary: false }) before setting new primaryEnsures exactly one primary per patient without a DB constraint race
PaginationPaginatedResponse<EmergencyContactResponseDto> — page-basedDefault 10 per page, max 50; ordered isPrimary DESC, createdAt DESC
AuditAuditService.logDataChange() on create/update/deleteLogs firstName, lastName, relationship, isPrimary in oldValue/newValue

Key design decision: Emergency contacts are NOT kept in sync with FHIR RelatedPerson after the initial creation at registration. Post-registration updates (e.g., phone number change) update only the Prisma row. The FHIR RelatedPerson represents the state at the time of registration. If full FHIR sync is needed in the future, a reconciliation job would be required.

Data Flow

Receptionist adds an emergency contact

Business outcome: A receptionist updates a patient's file with the spouse's phone number; within 1 second, the contact appears as primary with an audit log entry.

Technical mechanism:

  1. POST /api/v1/patients/{patientId}/emergency-contacts with { firstName, lastName, relationship, phone, isPrimary: true } and X-Clinic-ID.
  2. EmergencyContactsController.create() (emergency-contacts.controller.ts:78) delegates to EmergencyContactsService.create(patientId, user, dto).
  3. validatePatientAccess(patientId, user):
    • Resolves user.organizationId slug → UUID via organizationResolver.resolveToUuid().
    • Queries prisma.organizationMember.findFirst({ userId: patientId, organizationId: orgUuid }) — throws NotFoundException if not found.
  4. If dto.isPrimary === true: prisma.emergencyContact.updateMany({ where: { userId: patientId, isPrimary: true }, data: { isPrimary: false } }) — clears existing primary.
  5. prisma.emergencyContact.create({ ...dto, userId: patientId }).
  6. auditService.logDataChange('CREATE', userId, 'EmergencyContact', contactId, { newValue: { patientId, firstName, lastName, relationship, isPrimary } }).

Get primary emergency contact

GET /api/v1/patients/:patientId/emergency-contacts/primaryEmergencyContactsService.getPrimary(patientId, user)validatePatientAccess()prisma.emergencyContact.findFirst({ where: { userId: patientId, isPrimary: true } }) → returns EmergencyContactResponseDto | null.

Patient self-manages emergency contacts

PATIENT role JWT → validatePatientAccess() checks user.id === patientId; if mismatch → ForbiddenException('Patients can only access their own emergency contacts'). Passes all 5 endpoints (create/list/findOne/update/delete) with the same self-access guard.

List all contacts (paginated)

GET /api/v1/patients/:patientId/emergency-contacts?page=1&limit=10EmergencyContactsService.findAll() (emergency-contacts.service.ts:154):

  • resolvePatientUuid(patientId) — normalizes FHIR ID → User UUID if needed.
  • prisma.emergencyContact.findMany({ where: { userId }, orderBy: [{ isPrimary: 'desc' }, { createdAt: 'desc' }] }).
  • Returns PaginatedResponse<EmergencyContactResponseDto>.

Implicated Code

  • ddx-api/src/clinical-data/emergency-contacts/emergency-contacts.controller.ts:34EmergencyContactsController@ResourceMeta with fields: name, relationship, phone, isPrimary; operations: list/get/create/update/delete/count
  • ddx-api/src/clinical-data/emergency-contacts/emergency-contacts.controller.ts:78create()POST /patients/:patientId/emergency-contacts
  • ddx-api/src/clinical-data/emergency-contacts/emergency-contacts.controller.ts:104findAll() — paginated list with QueryEmergencyContactDto
  • ddx-api/src/clinical-data/emergency-contacts/emergency-contacts.controller.ts:130getPrimary()GET /primary — returns primary contact or null
  • ddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:18EmergencyContactsService — injects PrismaService, AuditService, OrganizationResolverService, PatientIdResolverService
  • ddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:35resolvePatientUuid() — normalizes FHIR ID → UUID at read boundary
  • ddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:51validatePatientAccess() — PATIENT self-access + org membership check via OrganizationMember table
  • ddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:107create() — primary flag management + Prisma create + audit
  • ddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:154findAll() — paginated with isPrimary-first ordering
  • ddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:195update() — primary flag re-assignment + Prisma update + audit
  • ddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:310getPrimary() — direct Prisma query for isPrimary: true
  • ddx-api/src/clinical/patients/services/patient-creation.service.ts:1PatientCreationService — creates initial RelatedPerson FHIR resources and EmergencyContact Prisma rows at registration (phases 7-8)

Operational Notes

  • Org membership gate: validatePatientAccess() queries organizationMember with a UUID, not a slug. If organizationResolver.resolveToUuid() fails (unknown slug), it falls back to the raw value — which then fails the membership check with a 404. Ensure X-Clinic-ID is a valid org slug, not a UUID, on all calls.
  • PATIENT access is strictly self-only: The PATIENT role check is user.id === patientId — raw UUID comparison. If the frontend passes a FHIR Patient numeric ID instead of the User UUID, this check fails and throws ForbiddenException. The client must pass the Prisma user.id UUID.
  • Primary flag race: Setting a new primary contact involves two separate Prisma writes (updateMany to clear, then create/update to set). If the request fails between these two writes, no contact is marked as primary. A background reconciliation or a DB-level partial unique index would be needed for full atomicity.
  • FHIR RelatedPerson not synced post-creation: Changes to emergency contacts after registration (phone update, new contact, deletion) are NOT reflected in the FHIR RelatedPerson resource. The FHIR $everything export will show stale relationship data for patients whose contacts were updated post-registration.
  • resolvePatientUuid() is a boundary normalizer: The emergency-contacts.service.ts findAll() calls resolvePatientUuid(patientId) because the URL param patientId may arrive as a FHIR ID in some seeder/test paths, even though the column emergencyContact.userId stores User UUIDs. This prevents empty list returns in non-standard call paths.
    Emergency Contacts and Patient Relations — Dudoxx Docs | Dudoxx