Skip to main content

Scheduling Rule Engine + Doctor-Aware Day View + Unassigned Assignment

Date: 2026-05-05
Status: Approved

Problem

Business rules for scheduling are scattered across 1865 lines in appointments.ts, partial duplicates in AppointmentForm.tsx, and nowhere else. Consequences:
  • Updating a rule (e.g. 48-hour cancellation window) means hunting through a giant route file
  • Frontend has no pre-flight checks for doctor schedule conflicts — users get rejected at submit with no warning
  • The day view ignores doctor-specific hours when a doctor filter is applied
  • Appointments landing in the “Unassigned” column have no way to be assigned to a doctor from the calendar
  • No structured return type: rules throw raw errors or return 400/409 status codes with no machine-readable codes
This spec covers three coordinated changes:
  1. Server — SchedulingRulesService: extract all 13+ scheduling rules out of the route handler into a typed, testable service in server/src/lib/rules/
  2. UI — Doctor-aware day view: use doctorSchedules data to constrain the day grid time bounds when a doctor filter is active
  3. UI — Unassigned appointment assignment: inline “Assign to doctor” action on unassigned appointment cards in DoctorDayView

1. SchedulingRulesService

1.1 File structure

server/src/lib/rules/
  index.ts                       ← exports single SchedulingRulesService instance
  types.ts                       ← shared types (RuleResult, ConflictDetail, RuleContext)
  scheduling/
    canCreateAppointment.ts
    canUpdateAppointment.ts      ← same checks, excludes self from conflict scan
    canCancelAppointment.ts      ← 48h window + role permissions
    canChangeStatus.ts           ← state machine + role matrix
    detectConflicts.ts           ← doctor overlap + room overlap + buffer time
    calculateAvailableSlots.ts   ← extracted from /available-slots route
    appointmentStateMachine.ts   ← validStatusTransitions map + isFinalState helper
  billing/
    canCompleteAppointment.ts    ← invoice-required-to-complete rule
  access/
    canEditAppointment.ts        ← role-based write checks (patient can't PUT)

1.2 Core types (types.ts)

export interface ConflictDetail {
  type: 'doctor' | 'room' | 'patient_duplicate'
  appointmentId: string
  startTime: string
  endTime: string
  patientName?: string
}

export interface RuleResult {
  allowed: boolean
  severity: 'blocking' | 'warning'
  code: string          // e.g. 'DOCTOR_OFF', 'OUTSIDE_CLINIC_HOURS', 'CONFLICT'
  message: string       // human-readable, safe for API response
  conflicts?: ConflictDetail[]
  overrideAllowedForRoles?: string[]  // roles that may proceed anyway
}

export interface AppointmentRuleContext {
  clinicId: string
  doctorId?: string
  patientId: string
  appointmentDate: string       // YYYY-MM-DD
  appointmentTime: string       // HH:MM
  durationMinutes: number
  operatory?: string
  roomId?: string
  requestingRole: string        // user's effective clinic role
  existingAppointmentId?: string  // set on update to exclude self
}

1.3 Rules extracted and their new homes

RuleCurrent locationNew location
Past date blockappointments.ts:273canCreateAppointment.ts
Clinic operating hoursappointments.ts:280-330canCreateAppointment.ts
Doctor day-off blockappointments.ts:335-345canCreateAppointment.ts
Doctor outside hoursappointments.ts:347-366canCreateAppointment.ts
Patient duplicate slotappointments.ts:368-399canCreateAppointment.ts
Doctor time conflictappointments.ts:413-469detectConflicts.ts
Room time conflictappointments.ts:428-472detectConflicts.ts
Buffer time calcappointments.ts:411,419detectConflicts.ts
State machine transitionsappointments.ts:52-63appointmentStateMachine.ts
Valid transition checkappointments.ts:1334canChangeStatus.ts
Time-based status restrictionappointments.ts:1326-1332canChangeStatus.ts
Role→status permission matrixappointments.ts:1400-1417canChangeStatus.ts + access/
48h patient cancellationappointments.ts:1438-1451canCancelAppointment.ts
Invoice on completionappointments.ts:1457-1487canCompleteAppointment.ts
Patient owns appointmentappointments.ts:1420-1436access/canEditAppointment.ts
Available slot generationappointments.ts:1649-1783calculateAvailableSlots.ts

1.4 Migration strategy (zero breakage)

Phase 1 — Create rules alongside existing code
New files in lib/rules/ are pure TypeScript functions. Nothing in the route file changes yet. Rules are tested in isolation.
Phase 2 — Route handlers call rules, keep inline as fallback
Replace inline validation blocks with const result = await schedulingRules.canCreateAppointment(ctx). If !result.allowed, return the structured error. The inline fallback is removed file-by-file once each rule is confirmed working.
Phase 3 — Dead code removal
Once all callers use the rule service, remove the original inline blocks. appointments.ts becomes a thin HTTP adapter (parse request → call rule → return response).
The route file is never wholesale rewritten — blocks are replaced one at a time. Each replacement is a separate commit.

1.5 Structured error responses

Rules return RuleResult. The route handler maps this to HTTP:
const result = await schedulingRules.canCreateAppointment(ctx)
if (!result.allowed) {
  return c.json({ 
    message: result.message, 
    code: result.code,
    conflicts: result.conflicts 
  }, result.severity === 'blocking' ? 409 : 400)
}
This is backward compatible — existing message field in responses is preserved.

2. Doctor-Aware Day View

2.1 Problem

When a doctor is selected in the day view filter, the time grid still renders 09:00–18:00 (clinic hours). Dr. Ahmed who works 13:00–20:00 shows 9 empty hours at the top.

2.2 Data flow

  1. AppointmentCalendar already loads doctors. Add a useQuery for getDoctorSchedules() (already in serverComm.ts + queryKeys.ts). Stale time: 5 minutes.
  2. Pass doctorSchedules: DoctorScheduleRow[] down through SchedulerProvider as a new context value (alongside existing operatingHours).
  3. Modify the useEffect in AppointmentCalendar that calculates startHour/endHour (currently lines 539–591):
if activeView === 'day' AND filterDoctorId is set:
  find DoctorScheduleRow where doctorId === filterDoctorId AND dayOfWeek === currentDayOfWeek
  if row found:
    if row.isOff → set doctorOffToday = true (new state flag)
    else → startHour = parseHour(row.startTime), endHour = parseHour(row.endTime)
  if no row found → fall back to clinic hours
else:
  existing clinic hours logic unchanged
  1. Pass doctorOffToday into SchedulerProvider context. In DoctorDayView, if filterDoctorId is set and doctorOffToday is true, render a banner instead of the time grid: “Dr. [name] is off today”.
  2. No changes to week/month view — only day view is affected.

2.3 Day-of-week mapping

DoctorScheduleRow.dayOfWeek is lowercase ('monday'). Map from currentDate.getDay() (0=Sunday) using the same DAY_NAMES array already used in the existing codebase.

3. Unassigned Appointment Assignment

3.1 Problem

Appointments in the __unassigned__ column have no in-calendar assignment action. Staff must open the appointment, edit it, find a doctor, and save. Three extra clicks for a routine operation.

3.2 Implementation

In DoctorDayView (doctor-day-view.tsx):
  • In the event card render for columnId === '__unassigned__', show a small “Assign” button (person-plus icon, ghost variant, size="xs").
  • Clicking it opens a shadcn Popover anchored to the button.
  • Popover content: a Select populated from the doctors prop (already available in the component as it builds columns).
  • On doctor selection: call existing updateAppointment(appointmentId, { doctorId: selectedDoctorId }) mutation.
  • On success: invalidate qk.appointments query key → card moves to the correct doctor column automatically.
  • Popover closes on selection or click-away.
No new API endpoint. The existing PUT /appointments/:id already accepts doctorId. No modal. Inline popover keeps the user in context.

4. 400 on GET /doctor-schedules (already deployed fix)

The app.appointment_status schema prefix bug was fixed in commit 596f17844. The GET now returns [] when clinic context is absent instead of throwing 400. No further action needed.

5. Out of scope

  • Soft-warning rules (patient balance, treatment plan phase, lab case pending) — phase 2
  • Clinic-level timezone config — phase 2
  • Cancellation reason dialog in UI — phase 2
  • Frontend pre-flight conflict checking using /available-slots — phase 2
  • Rule config admin UI — phase 3

6. Affected files

Server (new files):
  • server/src/lib/rules/types.ts
  • server/src/lib/rules/index.ts
  • server/src/lib/rules/scheduling/appointmentStateMachine.ts
  • server/src/lib/rules/scheduling/detectConflicts.ts
  • server/src/lib/rules/scheduling/canCreateAppointment.ts
  • server/src/lib/rules/scheduling/canUpdateAppointment.ts
  • server/src/lib/rules/scheduling/canCancelAppointment.ts
  • server/src/lib/rules/scheduling/canChangeStatus.ts
  • server/src/lib/rules/scheduling/calculateAvailableSlots.ts
  • server/src/lib/rules/billing/canCompleteAppointment.ts
  • server/src/lib/rules/access/canEditAppointment.ts
Server (modified):
  • server/src/routes/appointments.ts — inline validation replaced rule-by-rule with service calls
UI (modified):
  • ui/src/components/appointments/AppointmentCalendar.tsx — add doctorSchedules query + doctorOffToday logic
  • ui/src/providers/schedular-provider.tsx — add doctorSchedules + doctorOffToday to context
  • ui/src/components/schedule/_components/view/day/doctor-day-view.tsx — doctorOffToday banner + unassigned assign action