Waiting Room — Real-Time Queue Management
Audiences: doctor, clinical-buyer, developer
The Waiting Room module tracks patient check-in status from appointment arrival through consultation completion, maintaining an ordered Prisma-backed queue with priority scoring by appointment type and audit-logged state transitions: WAITING → CALLED → IN_ROOM → COMPLETED.
Business Purpose
Without a digital waiting room, clinic staff rely on manual name-calling or paper lists — both error-prone and invisible to practitioners in other rooms. Patients wait without knowing their position in queue. Doctors cannot see how many patients are waiting before their next call.
Dudoxx HMS's Waiting Room module provides:
- Digital check-in: Patients (or receptionists) check in for an appointment; the system assigns a queue position and estimated wait time automatically.
- Priority queue: Emergency and procedure appointments are automatically elevated over routine consultations via a numeric priority score.
- Practitioner view: Doctors see their own ordered queue in real time; one tap calls the next patient.
- Full audit trail: Every state transition (check-in, call, enter room, complete) is recorded via
AuditServicefor compliance and wait-time analytics. - Wait time statistics:
getWaitingRoomStats()aggregates by-status counts and historical average wait times, feeding the receptionist dashboard.
Audiences
- Investor: Waiting room management is the operational glue between scheduling and consultation. Eliminating paper-based queuing is a quick ROI win for any clinic transitioning to digital — it reduces no-shows, front-desk workload, and patient dissatisfaction from perceived unfair queuing.
- Clinical buyer (doctor/nurse/receptionist): Receptionists see all patients checked in for the day and their queue position. Doctors see only their own queue. One call to "next patient" transitions the first
WAITINGentry toCALLED, notifying the patient (in future: via SSE push). The receptionist can also manually advance entries. - Developer/partner: REST at
POST /waiting-room/check-in/:appointmentId,GET /waiting-room/practitioner/:practitionerId,POST /waiting-room/practitioner/:practitionerId/call-next,POST /waiting-room/:waitingRoomId/enter-room,POST /waiting-room/appointment/:appointmentId/complete,GET /waiting-room/stats. RequiresX-Clinic-IDheader viaorganizationResolver. PATIENT role can check in their own appointment and view their own status atGET /waiting-room/patient/status. - Internal (ops/support): Waiting room state lives in the Prisma
waitingRoomtable (ddx_api_main). Each entry hasappointmentId(unique),patientId,practitionerId,status,position,estimatedWaitTime,priority,checkInTime,calledAt,enteredRoomAt,completedAt,actualWaitTime. Audit rows emitted toddx_api_log. If a waiting room entry is stuck inCALLED, check that the doctor confirmedenter-room— entries do not auto-advance.
Architecture
WaitingRoomController (/api/v1/waiting-room) → WaitingRoomService:
| Collaborator | Responsibility |
|---|---|
PrismaService | waitingRoom + appointment tables |
AuditService | state transition logging |
IdNormalizerService | patient/practitioner ownership checks |
OrganizationResolverService | slug → UUID for DB queries |
Prisma waitingRoom model (key fields):
| Field | Type | Notes |
|---|---|---|
id | UUID | primary key |
appointmentId | UUID | unique — one entry per appointment |
patientId | UUID | — |
practitionerId | UUID | — |
organizationId | UUID | — |
status | enum | WAITING | CALLED | IN_ROOM | COMPLETED |
priority | int | EMERGENCY=2, PROCEDURE/SURGERY=1, others=0 |
position | int | queue position at check-in time |
estimatedWaitTime | int | minutes = position × 15 |
actualWaitTime | int? | minutes — set on enter-room transition |
checkInTime | DateTime | — |
calledAt | DateTime? | — |
enteredRoomAt | DateTime? | — |
completedAt | DateTime? | — |
State machine:
The getPractitionerWaitingRoom() method returns entries ordered by priority DESC, checkInTime ASC — EMERGENCY appointments always surface first; within the same priority tier, earlier arrivals come first.
See coding_context/ddx-hms-context.md §Clinical for the module map.
Tech Stack & Choices
| Layer | Technology | Rationale |
|---|---|---|
| API | NestJS 11, @Controller('waiting-room') | Plain REST; no WebSocket yet — SSE push for queue updates is a planned enhancement |
| Storage | Prisma waitingRoom table (ddx_api_main) | Operational data (not clinical) — Prisma fast-path avoids FHIR latency for queue reads |
| Priority | Numeric score per appointmentType | Configurable via calculatePriority() priority map; EMERGENCY=2, PROCEDURE=1, others=0 |
| Estimated wait | position × 15 minutes (simplified) | Placeholder — future enhancement: ML-based estimate from historical actualWaitTime data |
| Ownership check | IdNormalizerService.isUserPatient() + isUserPractitioner() | Centralized identity normalization; prevents UUID/FHIR-ID mismatch in permission checks |
| Audit | AuditService.logDataChange() on check-in and call-next | CREATE and UPDATE events with old/new status in ddx_api_log |
| Stats | prisma.waitingRoom.groupBy(['status']) + last-100 completed average | Efficient aggregation without full table scan |
Key design decision: Waiting room state is Prisma-only — no FHIR resource for queue management. This is intentional: queue position, estimated wait time, and operational status are transient operational data, not clinical records. FHIR is used only for the clinical output of the encounter (the Visit/Encounter resource), not the logistics of getting the patient into the room.
Data Flow
Patient check-in (receptionist or self-service)
Business outcome: A patient arrives for their 10am appointment; the receptionist clicks "Check In" and the patient appears in the doctor's queue as #3.
Technical mechanism:
POST /api/v1/waiting-room/check-in/:appointmentIdwith caller's JWT.WaitingRoomController.checkInPatient()(waiting-room.controller.ts:60) delegates toWaitingRoomService.checkInPatient().- Service fetches
appointmentfrom Prisma — throwsNotFoundExceptionif not found. - For PATIENT role:
idNormalizer.isUserPatient(user, appointment.patientId)— throws if wrong patient. - Dedup check:
prisma.waitingRoom.findUnique({ where: { appointmentId } })— throws if already checked in. calculateQueuePosition(practitionerId, organizationId)— counts active WAITING/CALLED entries + 1.prisma.waitingRoom.create(...)withstatus: 'WAITING',position,priority,estimatedWaitTime.prisma.appointment.update({ status: 'ARRIVED', checkedInAt, checkedInBy })— marks appointment as arrived.- Audit log:
auditService.logDataChange('CREATE', ...).
Doctor calls next patient
POST /waiting-room/practitioner/:practitionerId/call-next → WaitingRoomService.callNextPatient():
prisma.waitingRoom.findFirst({ where: { practitionerId, status: 'WAITING' }, orderBy: [{ priority: 'desc' }, { checkInTime: 'asc' }] })— finds highest-priority, earliest arrival.prisma.waitingRoom.update({ status: 'CALLED', calledAt, calledBy }).- Audit log emitted.
Patient enters room
POST /waiting-room/:waitingRoomId/enter-room → WaitingRoomService.patientEnteredRoom():
idNormalizer.isUserPractitioner(user, entry.practitionerId)— guards against wrong practitioner.prisma.waitingRoom.update({ status: 'IN_ROOM', enteredRoomAt, actualWaitTime: elapsed minutes }).actualWaitTime=(now - checkInTime) / 60000seconds — feeds wait-time analytics.
Get waiting room stats
GET /waiting-room/stats?practitionerId= → WaitingRoomService.getWaitingRoomStats() (waiting-room.service.ts:385):
prisma.waitingRoom.groupBy(['status'], { where: { status: { in: ['WAITING','CALLED','IN_ROOM'] } } })→ by-status counts.- Last 100 COMPLETED entries → average
actualWaitTime.
Implicated Code
ddx-api/src/clinical/waiting-room/waiting-room.controller.ts:43—WaitingRoomController— 7 endpoints covering the full queue lifecycleddx-api/src/clinical/waiting-room/waiting-room.controller.ts:60—checkInPatient()—POST /check-in/:appointmentId; PATIENT role allowed for self-check-inddx-api/src/clinical/waiting-room/waiting-room.controller.ts:110—getPractitionerWaitingRoom()—GET /practitioner/:practitionerId; doctors see only own queueddx-api/src/clinical/waiting-room/waiting-room.controller.ts:152—callNextPatient()—POST /practitioner/:practitionerId/call-next; WAITING → CALLED transitionddx-api/src/clinical/waiting-room/waiting-room.controller.ts:196—patientEnteredRoom()—POST /:waitingRoomId/enter-room; CALLED → IN_ROOM transitionddx-api/src/clinical/waiting-room/waiting-room.controller.ts:234—completeAppointment()—POST /appointment/:appointmentId/complete; IN_ROOM → COMPLETEDddx-api/src/clinical/waiting-room/waiting-room.controller.ts:271—getWaitingRoomStats()—GET /stats; by-status counts + average wait timeddx-api/src/clinical/waiting-room/waiting-room.controller.ts:317—getPatientWaitingStatus()—GET /patient/status; PATIENT role only — own queue positionddx-api/src/clinical/waiting-room/waiting-room.service.ts:33—WaitingRoomService— Prisma + AuditService + IdNormalizerService + OrganizationResolverServiceddx-api/src/clinical/waiting-room/waiting-room.service.ts:46—checkInPatient()— dedup guard + queue position + appointment status update + auditddx-api/src/clinical/waiting-room/waiting-room.service.ts:170—getPractitionerWaitingRoom()— ordered bypriority DESC, checkInTime ASC; nestspatientinsideappointmentfor frontend DTO shapeddx-api/src/clinical/waiting-room/waiting-room.service.ts:315—calculatePriority()— priority map: EMERGENCY=2, PROCEDURE/SURGERY=1, others=0ddx-api/src/clinical/waiting-room/waiting-room.service.ts:385—getWaitingRoomStats()—groupBy status+ last-100 average wait time
Operational Notes
- One entry per appointment:
waitingRoom.appointmentIdhas a unique constraint. Checking in the same appointment twice throwsBadRequestException. If a receptionist needs to re-check in (e.g., patient left and returned), the existing entry must be deleted first via a direct DB operation (no API endpoint for re-check-in yet). - Position is not recalculated on state changes:
positionis assigned at check-in and not updated as patients ahead complete. ThecurrentPositionin API responses is computed dynamically (array index + 1) ingetPractitionerWaitingRoom(). Front-end must usecurrentPosition, notposition, for display. - 15-minute estimate is a placeholder:
estimatedWaitTime = position × 15. This does not account for appointment duration or actual historical performance. TheactualWaitTimefield captures real wait data; a future ML model can use this for better estimates. - organizationId slug→UUID:
WaitingRoomServicecallsorganizationResolver.resolveToUuid(organizationId)on every write path. Passing a slug (e.g.,'berlin-clinic') to the Prisma query without resolving first matches no rows, resulting in silent misses. - Doctors cannot see other practitioners' queues:
WaitingRoomController.getPractitionerWaitingRoom()(waiting-room.controller.ts:122) checksuser.role === 'DOCTOR' && user.fhirPractitionerId !== practitionerIdand throws if the IDs mismatch. Admins and receptionists can pass anypractitionerId. - No SSE push yet: State changes do not emit SSE events. The front-end must poll
GET /waiting-room/practitioner/:idto see queue updates. SSE integration viaSSEChannelsis planned but not yet implemented.
Related Topics
- Clinical Visits — A visit (Encounter) is typically opened after the patient transitions to IN_ROOM
- Patients — Registration and Demographics — Patient must be registered before checking in
- HAPI FHIR R4 Server — FHIR is NOT used for waiting room state; this module is Prisma-only