Emergency Contacts and Patient Relations
Audiences: developer
Emergency contacts in Dudoxx HMS are Prisma-backed records (
emergencyContacttable inddx_api_main) linked to a patient byuserId, with a primary-contact flag, full audit trail, and a REST API scoped topatients/:patientId/emergency-contactswith organization-membership validation on every access.
Business Purpose
Emergency contacts serve two clinical purposes:
- 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.
- 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 endpointGET /primaryreturns 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.emergencyContacttable (Prisma schemaddx_api_main). Each row referencesuserId(the patient's Prisma User UUID). TheisPrimaryflag has a soft-unique constraint: setting a new primary contact unsets all other primaries for that patient within the same write. Audit rows emitted toddx_api_logon every create/update/delete.
Architecture
EmergencyContactsController (/api/v1/patients/:patientId/emergency-contacts)
→ EmergencyContactsService:
| Collaborator | Responsibility |
|---|---|
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):
| Field | Type | Notes |
|---|---|---|
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):
| Field | Value |
|---|---|
resourceType | RelatedPerson |
patient | Patient/{fhirPatientId} |
relationship | mapped via mapRelationship() |
name | HumanName |
telecom | phone + email |
The validatePatientAccess() guard checks:
- PATIENT role:
user.id === patientId— patients can only access their own contacts. - 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
| Layer | Technology | Rationale |
|---|---|---|
| API | NestJS 11, @Controller('patients/:patientId/emergency-contacts') | Nested resource route — contact is always accessed in context of a patient |
| Auth | All clinical roles + PATIENT role; self-access only for PATIENT | Broad access for clinical staff; PATIENT can manage their own |
| Storage | Prisma emergencyContact table | Operational contact data — not clinical; fast CRUD without FHIR overhead |
| FHIR mirror | RelatedPerson at patient creation (optional) | Created by PatientCreationService for FHIR completeness; not kept in sync on post-creation updates |
| Primary contact | Soft-unique via updateMany({ isPrimary: false }) before setting new primary | Ensures exactly one primary per patient without a DB constraint race |
| Pagination | PaginatedResponse<EmergencyContactResponseDto> — page-based | Default 10 per page, max 50; ordered isPrimary DESC, createdAt DESC |
| Audit | AuditService.logDataChange() on create/update/delete | Logs 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:
POST /api/v1/patients/{patientId}/emergency-contactswith{ firstName, lastName, relationship, phone, isPrimary: true }andX-Clinic-ID.EmergencyContactsController.create()(emergency-contacts.controller.ts:78) delegates toEmergencyContactsService.create(patientId, user, dto).validatePatientAccess(patientId, user):- Resolves
user.organizationIdslug → UUID viaorganizationResolver.resolveToUuid(). - Queries
prisma.organizationMember.findFirst({ userId: patientId, organizationId: orgUuid })— throwsNotFoundExceptionif not found.
- Resolves
- If
dto.isPrimary === true:prisma.emergencyContact.updateMany({ where: { userId: patientId, isPrimary: true }, data: { isPrimary: false } })— clears existing primary. prisma.emergencyContact.create({ ...dto, userId: patientId }).auditService.logDataChange('CREATE', userId, 'EmergencyContact', contactId, { newValue: { patientId, firstName, lastName, relationship, isPrimary } }).
Get primary emergency contact
GET /api/v1/patients/:patientId/emergency-contacts/primary → EmergencyContactsService.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=10 → EmergencyContactsService.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:34—EmergencyContactsController—@ResourceMetawith fields: name, relationship, phone, isPrimary; operations: list/get/create/update/delete/countddx-api/src/clinical-data/emergency-contacts/emergency-contacts.controller.ts:78—create()—POST /patients/:patientId/emergency-contactsddx-api/src/clinical-data/emergency-contacts/emergency-contacts.controller.ts:104—findAll()— paginated list withQueryEmergencyContactDtoddx-api/src/clinical-data/emergency-contacts/emergency-contacts.controller.ts:130—getPrimary()—GET /primary— returns primary contact or nullddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:18—EmergencyContactsService— injectsPrismaService,AuditService,OrganizationResolverService,PatientIdResolverServiceddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:35—resolvePatientUuid()— normalizes FHIR ID → UUID at read boundaryddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:51—validatePatientAccess()— PATIENT self-access + org membership check via OrganizationMember tableddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:107—create()— primary flag management + Prisma create + auditddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:154—findAll()— paginated with isPrimary-first orderingddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:195—update()— primary flag re-assignment + Prisma update + auditddx-api/src/clinical-data/emergency-contacts/emergency-contacts.service.ts:310—getPrimary()— direct Prisma query forisPrimary: trueddx-api/src/clinical/patients/services/patient-creation.service.ts:1—PatientCreationService— creates initialRelatedPersonFHIR resources andEmergencyContactPrisma rows at registration (phases 7-8)
Operational Notes
- Org membership gate:
validatePatientAccess()queriesorganizationMemberwith a UUID, not a slug. IforganizationResolver.resolveToUuid()fails (unknown slug), it falls back to the raw value — which then fails the membership check with a 404. EnsureX-Clinic-IDis 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 throwsForbiddenException. The client must pass the Prismauser.idUUID. - 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
RelatedPersonresource. The FHIR$everythingexport will show stale relationship data for patients whose contacts were updated post-registration. resolvePatientUuid()is a boundary normalizer: Theemergency-contacts.service.tsfindAll()callsresolvePatientUuid(patientId)because the URL parampatientIdmay arrive as a FHIR ID in some seeder/test paths, even though the columnemergencyContact.userIdstores User UUIDs. This prevents empty list returns in non-standard call paths.
Related Topics
- Patients — Registration and Demographics — PatientCreationService creates RelatedPerson + EmergencyContact at registration time
- Medical Cards — Patient Health Summary — Emergency contacts are not currently surfaced in the medical card summary DTO
- Clinical Visits — Emergency contacts may be referenced when opening a visit for an incapacitated patient