04-clinicalwave: W2filled16 citations

Waiting Room — Real-Time Queue Management

Audiences: doctor, clinical-buyer, developer

Waiting Room — Real-Time Queue Management

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 AuditService for 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 WAITING entry to CALLED, 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. Requires X-Clinic-ID header via organizationResolver. PATIENT role can check in their own appointment and view their own status at GET /waiting-room/patient/status.
  • Internal (ops/support): Waiting room state lives in the Prisma waitingRoom table (ddx_api_main). Each entry has appointmentId (unique), patientId, practitionerId, status, position, estimatedWaitTime, priority, checkInTime, calledAt, enteredRoomAt, completedAt, actualWaitTime. Audit rows emitted to ddx_api_log. If a waiting room entry is stuck in CALLED, check that the doctor confirmed enter-room — entries do not auto-advance.

Architecture

WaitingRoomController (/api/v1/waiting-room) → WaitingRoomService:

CollaboratorResponsibility
PrismaServicewaitingRoom + appointment tables
AuditServicestate transition logging
IdNormalizerServicepatient/practitioner ownership checks
OrganizationResolverServiceslug → UUID for DB queries

Prisma waitingRoom model (key fields):

FieldTypeNotes
idUUIDprimary key
appointmentIdUUIDunique — one entry per appointment
patientIdUUID
practitionerIdUUID
organizationIdUUID
statusenumWAITING | CALLED | IN_ROOM | COMPLETED
priorityintEMERGENCY=2, PROCEDURE/SURGERY=1, others=0
positionintqueue position at check-in time
estimatedWaitTimeintminutes = position × 15
actualWaitTimeint?minutes — set on enter-room transition
checkInTimeDateTime
calledAtDateTime?
enteredRoomAtDateTime?
completedAtDateTime?

State machine:

Rendering diagram…

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

LayerTechnologyRationale
APINestJS 11, @Controller('waiting-room')Plain REST; no WebSocket yet — SSE push for queue updates is a planned enhancement
StoragePrisma waitingRoom table (ddx_api_main)Operational data (not clinical) — Prisma fast-path avoids FHIR latency for queue reads
PriorityNumeric score per appointmentTypeConfigurable via calculatePriority() priority map; EMERGENCY=2, PROCEDURE=1, others=0
Estimated waitposition × 15 minutes (simplified)Placeholder — future enhancement: ML-based estimate from historical actualWaitTime data
Ownership checkIdNormalizerService.isUserPatient() + isUserPractitioner()Centralized identity normalization; prevents UUID/FHIR-ID mismatch in permission checks
AuditAuditService.logDataChange() on check-in and call-nextCREATE and UPDATE events with old/new status in ddx_api_log
Statsprisma.waitingRoom.groupBy(['status']) + last-100 completed averageEfficient 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:

  1. POST /api/v1/waiting-room/check-in/:appointmentId with caller's JWT.
  2. WaitingRoomController.checkInPatient() (waiting-room.controller.ts:60) delegates to WaitingRoomService.checkInPatient().
  3. Service fetches appointment from Prisma — throws NotFoundException if not found.
  4. For PATIENT role: idNormalizer.isUserPatient(user, appointment.patientId) — throws if wrong patient.
  5. Dedup check: prisma.waitingRoom.findUnique({ where: { appointmentId } }) — throws if already checked in.
  6. calculateQueuePosition(practitionerId, organizationId) — counts active WAITING/CALLED entries + 1.
  7. prisma.waitingRoom.create(...) with status: 'WAITING', position, priority, estimatedWaitTime.
  8. prisma.appointment.update({ status: 'ARRIVED', checkedInAt, checkedInBy }) — marks appointment as arrived.
  9. Audit log: auditService.logDataChange('CREATE', ...).

Doctor calls next patient

POST /waiting-room/practitioner/:practitionerId/call-nextWaitingRoomService.callNextPatient():

  1. prisma.waitingRoom.findFirst({ where: { practitionerId, status: 'WAITING' }, orderBy: [{ priority: 'desc' }, { checkInTime: 'asc' }] }) — finds highest-priority, earliest arrival.
  2. prisma.waitingRoom.update({ status: 'CALLED', calledAt, calledBy }).
  3. Audit log emitted.

Patient enters room

POST /waiting-room/:waitingRoomId/enter-roomWaitingRoomService.patientEnteredRoom():

  1. idNormalizer.isUserPractitioner(user, entry.practitionerId) — guards against wrong practitioner.
  2. prisma.waitingRoom.update({ status: 'IN_ROOM', enteredRoomAt, actualWaitTime: elapsed minutes }).
  3. actualWaitTime = (now - checkInTime) / 60000 seconds — 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:43WaitingRoomController — 7 endpoints covering the full queue lifecycle
  • ddx-api/src/clinical/waiting-room/waiting-room.controller.ts:60checkInPatient()POST /check-in/:appointmentId; PATIENT role allowed for self-check-in
  • ddx-api/src/clinical/waiting-room/waiting-room.controller.ts:110getPractitionerWaitingRoom()GET /practitioner/:practitionerId; doctors see only own queue
  • ddx-api/src/clinical/waiting-room/waiting-room.controller.ts:152callNextPatient()POST /practitioner/:practitionerId/call-next; WAITING → CALLED transition
  • ddx-api/src/clinical/waiting-room/waiting-room.controller.ts:196patientEnteredRoom()POST /:waitingRoomId/enter-room; CALLED → IN_ROOM transition
  • ddx-api/src/clinical/waiting-room/waiting-room.controller.ts:234completeAppointment()POST /appointment/:appointmentId/complete; IN_ROOM → COMPLETED
  • ddx-api/src/clinical/waiting-room/waiting-room.controller.ts:271getWaitingRoomStats()GET /stats; by-status counts + average wait time
  • ddx-api/src/clinical/waiting-room/waiting-room.controller.ts:317getPatientWaitingStatus()GET /patient/status; PATIENT role only — own queue position
  • ddx-api/src/clinical/waiting-room/waiting-room.service.ts:33WaitingRoomService — Prisma + AuditService + IdNormalizerService + OrganizationResolverService
  • ddx-api/src/clinical/waiting-room/waiting-room.service.ts:46checkInPatient() — dedup guard + queue position + appointment status update + audit
  • ddx-api/src/clinical/waiting-room/waiting-room.service.ts:170getPractitionerWaitingRoom() — ordered by priority DESC, checkInTime ASC; nests patient inside appointment for frontend DTO shape
  • ddx-api/src/clinical/waiting-room/waiting-room.service.ts:315calculatePriority() — priority map: EMERGENCY=2, PROCEDURE/SURGERY=1, others=0
  • ddx-api/src/clinical/waiting-room/waiting-room.service.ts:385getWaitingRoomStats()groupBy status + last-100 average wait time

Operational Notes

  • One entry per appointment: waitingRoom.appointmentId has a unique constraint. Checking in the same appointment twice throws BadRequestException. 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: position is assigned at check-in and not updated as patients ahead complete. The currentPosition in API responses is computed dynamically (array index + 1) in getPractitionerWaitingRoom(). Front-end must use currentPosition, not position, for display.
  • 15-minute estimate is a placeholder: estimatedWaitTime = position × 15. This does not account for appointment duration or actual historical performance. The actualWaitTime field captures real wait data; a future ML model can use this for better estimates.
  • organizationId slug→UUID: WaitingRoomService calls organizationResolver.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) checks user.role === 'DOCTOR' && user.fhirPractitionerId !== practitionerId and throws if the IDs mismatch. Admins and receptionists can pass any practitionerId.
  • No SSE push yet: State changes do not emit SSE events. The front-end must poll GET /waiting-room/practitioner/:id to see queue updates. SSE integration via SSEChannels is planned but not yet implemented.
    Waiting Room — Real-Time Queue Management — Dudoxx Docs | Dudoxx