Visits — Clinical Encounter Management
Audiences: doctor, nurse, receptionist, clinical-buyer, developer
The Visit (FHIR Encounter) is the central clinical object in Dudoxx HMS — every medication, vital, diagnosis, recording, and document attaches to a visit, making it the transactional anchor for all patient-facing clinical work.
Business Purpose
A "visit" represents a discrete clinical encounter between a patient and a practitioner — in person, virtual, or emergency. Clinics need a reliable, auditable record of every consultation: who saw whom, when, why, what happened, and what follow-up is required.
Dudoxx HMS models each visit as a FHIR R4 Encounter resource on HAPI FHIR, enriched with Prisma-side metadata (recordings, links to appointments, treatment plans). This dual-store design means:
- Interoperability with any FHIR-aware system out of the box (labs, insurance, referral networks).
- Rich operational data (audio recordings, AI agent context) in Prisma for fast access.
- A single UUID is the public API surface; internal FHIR Encounter IDs are resolved transparently.
Clinical buyers (practice managers, compliance officers) gain an auditable encounter log with status history; doctors get an AI-enriched context endpoint (/agent-context) that feeds the TUCAN AI with patient recordings and transcriptions.
Audiences
- Investor: Every consultation generates a visit record — the volume of visits is a leading indicator of platform adoption. Encounters are stored as FHIR, satisfying regulators and enabling future payer integrations.
- Clinical buyer (doctor/nurse/receptionist): Doctors open a visit when a patient arrives; nurses record vitals against it; receptionists link it to an appointment. A visit cannot close until status transitions to
finished. - Developer/partner: REST CRUD at
GET|POST|PATCH|DELETE /api/v1/visits. Encounters keyed by UUID (not numeric FHIR IDs). Agent-context endpoint (GET /visits/:id/agent-context) returns structured text for LLM consumption. - Internal (ops/support): All create/update/delete operations emit audit log rows via
AuditService. Status changes are tracked in FHIRstatusHistory. Recordings and attachments are counted on active-visit queries.
Architecture
The Visit subsystem sits in ddx-api/src/clinical/visits/ and follows the Orchestrator Pattern: VisitsController → VisitsService (orchestrator) → five specialized sub-services:
VisitsController
└── VisitsService (orchestrator)
├── VisitCrudService — CRUD facade (delegates create to VisitLifecycleService)
├── VisitFhirService — FHIR Encounter create/search/delete via facade
├── VisitValidationService — create-time business-rule guards (conflict, availability)
├── VisitResolverService — UUID → FHIR Encounter ID resolution (also resolves Appointment UUID → visit)
├── VisitLifecycleService — createVisit: FHIR Encounter + Prisma visit row + patient-activity bump
├── VisitDischargeService — discharge: FHIR Condition + MedicationStatement from treatment text (best-effort)
└── VisitTreatmentPlanFhirService — DocumentReference for generated treatment-plan PDFs
VisitsService.create() is get-or-create: it enforces "one open visit per patient per practitioner" (open = status ∈ {planned, arrived, in-progress}) — an existing open visit is RESUMED rather than duplicated. The response data is a discriminated union on resolution ('created' | 'resumed'). A VisitRaceError from a concurrent create triggers an orphan-Encounter cleanup (deleteFhirEncounter).
The EncounterResource interface (NF2.4 facade) lives in encounter.descriptor.ts and declares the complete FHIR R4 Encounter field set. All encounter operations go through the ResourceFacade<EncounterResource>, which upserts a fhir_resource_link Prisma row on each write — providing a cross-store audit trail without duplicating the payload.
Visit-appointment linking is a side-car service (VisitAppointmentLinkingService) that maintains a bidirectional association between FHIR Encounters and appointment records.
Cross-cutting concerns: AuditService logs every CREATE/UPDATE/DELETE; TenantIsolationInterceptor enforces organizationId scoping on every request; RbacEnforcementInterceptor enforces role gates declared via @RequireRole().
See coding_context/ddx-hms-context.md §Clinical for the high-level module map.
Tech Stack & Choices
| Layer | Technology | Rationale |
|---|---|---|
| API | NestJS 11, @Controller('visits') | Uniform REST surface with Swagger auto-docs |
| Auth | @RequireRole(UserRole.*) + GatewayAuthGuard | Trusted Gateway v2.1 — roles resolved from JWT at Next.js, forwarded as headers |
| FHIR storage | HAPI FHIR R4 (port 18080) via IFhirClient | Encounters are authoritative in FHIR; fhir_resource_link Prisma row provides fast UUID lookup |
| Prisma | PrismaService (ddx_api_main) | Stores Recording, VisitAppointmentLink, and fhir_resource_link rows |
| Descriptor | makeEncounterDescriptor() + ResourceFacade | NF2.4 pattern — Tier-1 FHIR-only resource; hooks upsert fhir_resource_link after writes |
| Audit | AuditService.log() | Structured JSON rows in audit log DB |
| Rate-limiting | ThrottlerModule 3-tier | Protects batch visit listing in large clinics |
Key design decision: Visit UUIDs (Prisma) are the stable public API identifiers. Numeric FHIR Encounter IDs are an internal implementation detail — VisitResolverService.resolveFhirId() enforces this boundary. Passing a non-UUID to any visit endpoint returns HTTP 400, preventing FHIR ID leakage.
Data Flow
Doctor opens a visit (happy path)
Business outcome: A doctor clicks "Start Visit" for an arriving patient; within 1 second the visit appears as in-progress on all clinical staff dashboards.
Technical mechanism:
ddx-websendsPOST /api/v1/visitswith{ patientId, practitionerId, encounterClass: "AMB" }via Next.js proxy (ddx-web/src/app/api/proxy/[...path]/route.ts).VisitsController.create()(ddx-api/src/clinical/visits/visits.controller.ts:96) resolveseffectiveOrgIdfromX-Clinic-IDheader, auto-populatespractitionerIdfrom gateway user if empty.VisitsService.create()callsVisitValidationService.validateCreateVisit(), then checks for an existing OPEN visit (get-or-create); if none, delegates toVisitCrudService.createVisit()→VisitLifecycleService.createVisit().VisitLifecycleServiceissues a FHIRPOST /Encounter(viaVisitFhirService.createFhirEncounter()→ facade) to HAPI FHIR 18080, then writes the Prismavisitrow and bumps patient-activity. On success the facade upserts afhir_resource_linkPrisma row mapping UUID → FHIR Encounter ID.AuditService.log()recordsaction: 'CREATE', resource: 'Visit'with patient/practitioner context (visits.service.ts:72).- Response is wrapped by
ResponseTransformInterceptorinto{ data, meta }envelope.
Doctor checks for active visit (TUCAN integration)
GET /api/v1/visits/active?patientId=xxx (visits.controller.ts:369) returns { hasActiveVisit: true, visit: { visitId, status, recordingCount, ... } }. Used by TUCAN AI agent to determine if a consultation is live before running clinical tools.
Patient views own visit history
GET /api/v1/visits/me (visits.controller.ts:163) — PATIENT role only; auto-creates FHIR Patient record if missing via PatientsService.ensureFhirPatient(). Returns paginated bundle.
AI agent context
GET /api/v1/visits/:id/agent-context (visits.controller.ts:688) assembles a structured text block with visit metadata + per-recording transcription timelines. Used by TUCAN to inject clinical context into LLM prompts.
Implicated Code
ddx-api/src/clinical/visits/visits.controller.ts:96—POST /visits— creates encounter, resolves effective org, auto-fills practitionerIdddx-api/src/clinical/visits/visits.controller.ts:163—GET /visits/me— patient self-service visit list with FHIR auto-syncddx-api/src/clinical/visits/visits.controller.ts:369—GET /visits/active— TUCAN agent check for in-progress consultationddx-api/src/clinical/visits/visits.controller.ts:688—GET /visits/:id/agent-context— structured LLM context with transcription timelinesddx-api/src/clinical/visits/visits.controller.ts:803—POST /visits/:visitId/link-appointment/:appointmentId— bidirectional appointment linkddx-api/src/clinical/visits/visits.service.ts—VisitsService— orchestrator; delegates to sub-servicesddx-api/src/clinical/visits/visits.service.ts—create()— get-or-create; validate → find open visit (resume) OR FHIR create → audit; discriminatedresolutionunionddx-api/src/clinical/visits/visits.service.ts—resolveVisitId()— strict UUID-only; rejects numeric FHIR IDs with HTTP 400ddx-api/src/clinical/visits/visits.service.ts—findActiveVisit()— FHIR search forstatus=in-progress, returns recording/attachment counts from Prismaddx-api/src/clinical/visits/services/visit-lifecycle.service.ts—createVisit()— FHIR Encounter + Prisma visit row + patient-activity bumpddx-api/src/clinical/visits/services/visit-fhir.service.ts—createFhirEncounter()— builds FHIR R4 Encounter (participants, patient/practitioner display) via facadeddx-api/src/clinical/visits/encounter.descriptor.ts—EncounterResourceinterface +makeEncounterDescriptor(); hooks upsertfhir_resource_linkafter FHIR writes (NF2.4)ddx-api/src/clinical/visits/visit-appointment-linking.service.ts—getOrCreateVisitForAppointment()— unified visit resolution for appointment start / check-in / video-joinddx-api/src/clinical/visits/services/visit-resolver.service.ts—resolveVisitId()/getOrCreateVisit()— resolves Visit UUID or Appointment UUID → realvisits.id
Operational Notes
- UUID boundary (critical): Passing a numeric FHIR Encounter ID to any
/visits/:idendpoint returns HTTP 400. TheresolveVisitId()method enforces this (visits.service.ts:110). Older code paths that passed numeric IDs directly are a known source of silent write misses. - PATIENT role auto-filter:
GET /visitswith a PATIENT-role JWT automatically scopes results to the authenticated patient's FHIR ID; thepatientIdquery param is ignored. No explicit guard needed — implemented in the controller at line 323. - FHIR soft-delete:
DELETE /visits/:iddoes NOT delete the FHIR Encounter — it sets status tocancelled. Encounters are never destroyed, preserving audit integrity. - TUCAN save_treatment_plan: Visit UUID is the authoritative identifier for treatment plan saves. Passing a FHIR Encounter numeric ID to the TUCAN tool caused silent failures (reference: plans/visit-uuid-boundary).
- practitionerId auto-population: If
practitionerIdis empty in the create DTO, it is auto-populated fromuser.fhirPractitionerId || user.id(visits.controller.ts:136). Warning is logged. - Response envelope: All responses go through
ResponseTransformInterceptor→{ data, meta }. API consumers must unwrap.data. - Rate limits: ThrottlerModule applies 10/1s burst, 100/60s medium, 1000/3600s hourly — large visit list calls can hit medium tier.
Related Topics
- Clinical Data — Vitals — Observations attached to visits
- Clinical Data — Conditions — Diagnoses recorded during visits
- Clinical Data — Prescriptions — Medications ordered per visit
- Clinical Forms — Forms submitted during or after a visit
- Intake Engine — Pre-visit intake process that links to visits
- Diagnosis Engine — AI-assisted diagnosis generation per visit
- Medical Cards — Patient summary cards derived from visit history
- TUCAN AI Assistant — AI agent that uses visit agent-context