Skip to main content

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

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Extract all scheduling business rules from appointments.ts into a typed SchedulingRulesService, constrain the DoctorDayView time grid to the selected doctor’s working hours, and add an inline “Assign to doctor” action on unassigned appointment cards. Architecture: Three coordinated changes: (1) 11 new server-side rule files in server/src/lib/rules/ that return structured RuleResult objects, wired into appointments.ts one validation block at a time; (2) a useEffect inside SchedulerProvider that computes doctor-effective hours from filterDoctorId + doctorSchedules + currentDate and exposes them via context; (3) a Popover + Select component in DoctorDayView for the __unassigned__ column. Tech Stack: TypeScript, Drizzle ORM (Neon HTTP), Hono, React 18, TanStack Query v5, shadcn/ui (Popover, Select), Framer Motion, lucide-react.

File Map

New server files

FileResponsibility
server/src/lib/rules/types.tsRuleResult, ConflictDetail, AppointmentRuleContext
server/src/lib/rules/index.tscreateSchedulingRules(db) factory
server/src/lib/rules/scheduling/appointmentStateMachine.tsvalidStatusTransitions, isValidTransition, isFinalState
server/src/lib/rules/scheduling/detectConflicts.tsPure conflict checker over pre-fetched appointments
server/src/lib/rules/scheduling/canCreateAppointment.tsAll creation validators (past date, clinic hours, doctor schedule, duplicate, conflict)
server/src/lib/rules/scheduling/canUpdateAppointment.tsSame as canCreate but excludes self from conflict scan
server/src/lib/rules/scheduling/canCancelAppointment.ts48-hour window + role ownership
server/src/lib/rules/scheduling/canChangeStatus.tsState machine + time restriction + role matrix
server/src/lib/rules/scheduling/calculateAvailableSlots.tsExtracted available-slot generation
server/src/lib/rules/billing/canCompleteAppointment.tsInvoice-required-to-complete rule
server/src/lib/rules/access/canEditAppointment.tsRole-based write guard

Modified files

FileChange
server/src/routes/appointments.tsInline validation replaced rule-by-rule with service calls
ui/src/types/index.tsAdd DoctorEffectiveHours type + extend SchedulerContextType
ui/src/providers/schedular-provider.tsxAccept doctorSchedules, currentDate props; compute + expose doctor-aware hours
ui/src/components/appointments/AppointmentCalendar.tsxAdd getDoctorSchedules query; pass doctorSchedules + currentDate to SchedulerProvider
ui/src/components/schedule/_components/view/day/doctor-day-view.tsxDoctor-off banner + unassigned assign action

Task 1: Core types

Files:
  • Create: server/src/lib/rules/types.ts
  • Step 1: Create the directories
mkdir -p server/src/lib/rules/scheduling server/src/lib/rules/billing server/src/lib/rules/access
Expected: no output (directories created)
  • Step 2: Write types.ts
// server/src/lib/rules/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;
  message: string;
  conflicts?: ConflictDetail[];
  overrideAllowedForRoles?: string[];
}

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;
  existingAppointmentId?: string;  // set on update to exclude self
  bufferMinutes?: number;
}
  • Step 3: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors involving rules/types.ts
  • Step 4: Commit
git add server/src/lib/rules/types.ts
git commit -m "feat(rules): add RuleResult/ConflictDetail/AppointmentRuleContext types"

Task 2: State machine

Files:
  • Create: server/src/lib/rules/scheduling/appointmentStateMachine.ts
  • Step 1: Write the file (extracted from appointments.ts:52-63)
// server/src/lib/rules/scheduling/appointmentStateMachine.ts
export const validStatusTransitions: Record<string, string[]> = {
  requested: ['scheduled', 'confirmed', 'cancelled', 'completed'],
  scheduled: ['confirmed', 'cancelled', 'in_progress', 'completed'],
  confirmed: ['in_progress', 'cancelled', 'no_show', 'scheduled', 'completed'],
  in_progress: ['completed', 'cancelled', 'no_show', 'confirmed'],
  completed: ['in_progress', 'confirmed'],
  cancelled: ['scheduled', 'no_show', 'confirmed'],
  no_show: ['scheduled', 'cancelled', 'confirmed'],
};

export function isValidTransition(from: string, to: string): boolean {
  return (validStatusTransitions[from] ?? []).includes(to);
}

export function isFinalState(status: string): boolean {
  return (validStatusTransitions[status] ?? []).length === 0;
}
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors
  • Step 3: Commit
git add server/src/lib/rules/scheduling/appointmentStateMachine.ts
git commit -m "feat(rules): add appointment state machine"

Task 3: Conflict detector

Files:
  • Create: server/src/lib/rules/scheduling/detectConflicts.ts
  • Step 1: Write the file (pure function — caller pre-fetches the appointments)
// server/src/lib/rules/scheduling/detectConflicts.ts
import type { AppointmentRuleContext, ConflictDetail, RuleResult } from '../types';

interface DbAppointment {
  id: string;
  doctorId: string | null;
  operatory: string | null;
  appointmentTime: string;
  durationMinutes: number | null;
}

export function detectConflicts(
  existing: DbAppointment[],
  ctx: AppointmentRuleContext,
): RuleResult {
  const { appointmentTime, durationMinutes, doctorId, operatory, existingAppointmentId, bufferMinutes = 0 } = ctx;

  const toMin = (t: string): number => {
    const [h, m] = t.split(':').map(Number);
    return (h || 0) * 60 + (m || 0);
  };
  const fmt = (m: number) =>
    `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;

  const reqStart = toMin(appointmentTime);
  const reqEnd = reqStart + durationMinutes + bufferMinutes;
  const conflicts: ConflictDetail[] = [];

  for (const appt of existing) {
    if (existingAppointmentId && appt.id === existingAppointmentId) continue;

    const existStart = toMin(appt.appointmentTime);
    const existEnd = existStart + (appt.durationMinutes ?? 30);

    if (reqStart >= existEnd || reqEnd <= existStart) continue;

    const endTimeStr = fmt(existEnd);

    if (doctorId && appt.doctorId === doctorId) {
      conflicts.push({ type: 'doctor', appointmentId: appt.id, startTime: appt.appointmentTime, endTime: endTimeStr });
    } else if (operatory && appt.operatory === operatory) {
      conflicts.push({ type: 'room', appointmentId: appt.id, startTime: appt.appointmentTime, endTime: endTimeStr });
    }
  }

  if (conflicts.length === 0) {
    return { allowed: true, severity: 'blocking', code: 'OK', message: 'No conflicts' };
  }

  const first = conflicts[0];
  const message = first.type === 'doctor'
    ? `Time slot conflict: Doctor already has an appointment from ${first.startTime} to ${first.endTime}`
    : `Room conflict: ${operatory} is already booked from ${first.startTime} to ${first.endTime}`;

  return { allowed: false, severity: 'blocking', code: 'CONFLICT', message, conflicts };
}
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors
  • Step 3: Commit
git add server/src/lib/rules/scheduling/detectConflicts.ts
git commit -m "feat(rules): add pure conflict detector"

Task 4: canCreateAppointment

Files:
  • Create: server/src/lib/rules/scheduling/canCreateAppointment.ts
  • Step 1: Write the file (extracted from appointments.ts:273-472)
// server/src/lib/rules/scheduling/canCreateAppointment.ts
import { eq, and, or } from 'drizzle-orm';
import { appointments, clinicOperatingHours, doctorSchedules, clinics } from '../../../schema';
import { detectConflicts } from './detectConflicts';
import type { AppointmentRuleContext, RuleResult } from '../types';
import { getReadDb } from '../../db';

type Db = ReturnType<typeof getReadDb>;

const DAY_NAMES = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'] as const;

const toMin = (t: string): number => {
  const [h, m] = t.split(':').map(Number);
  return (h || 0) * 60 + (m || 0);
};

const fmt = (m: number) =>
  `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;

export async function canCreateAppointment(db: Db, ctx: AppointmentRuleContext): Promise<RuleResult> {
  const { clinicId, doctorId, patientId, appointmentDate, appointmentTime, durationMinutes, operatory, existingAppointmentId } = ctx;

  // Past date check
  if (new Date(`${appointmentDate}T${appointmentTime}`) < new Date()) {
    return { allowed: false, severity: 'blocking', code: 'PAST_DATE', message: 'Cannot create appointments in the past' };
  }

  const dayOfWeekKey = DAY_NAMES[new Date(`${appointmentDate}T00:00:00`).getDay()];
  const dayName = dayOfWeekKey.charAt(0).toUpperCase() + dayOfWeekKey.slice(1);

  // Clinic operating hours
  const [hoursRow] = await db.select({
    openTime: clinicOperatingHours.openTime,
    closeTime: clinicOperatingHours.closeTime,
    isClosed: clinicOperatingHours.isClosed,
  })
    .from(clinicOperatingHours)
    .where(and(
      eq(clinicOperatingHours.clinicId, clinicId),
      eq(clinicOperatingHours.dayOfWeek, dayOfWeekKey as any),
    ))
    .limit(1);

  if (hoursRow?.isClosed) {
    return { allowed: false, severity: 'blocking', code: 'CLINIC_CLOSED', message: `The clinic is closed on ${dayName}s. Please pick another day.` };
  }
  if (hoursRow?.openTime && hoursRow?.closeTime) {
    const reqStart = toMin(appointmentTime);
    const reqEnd = reqStart + durationMinutes;
    const openMin = toMin(hoursRow.openTime);
    const closeMin = toMin(hoursRow.closeTime);
    if (reqStart < openMin || reqEnd > closeMin) {
      return {
        allowed: false, severity: 'blocking', code: 'OUTSIDE_CLINIC_HOURS',
        message: `Your ${durationMinutes}-min appointment at ${fmt(reqStart)} would end at ${fmt(reqEnd)}, which is outside clinic hours on ${dayName}s (open ${fmt(openMin)}${fmt(closeMin)}).`,
      };
    }
  }

  // Doctor schedule check
  if (doctorId) {
    const [docSchedule] = await db.select()
      .from(doctorSchedules)
      .where(and(
        eq(doctorSchedules.clinicId, clinicId),
        eq(doctorSchedules.doctorId, doctorId),
        eq(doctorSchedules.dayOfWeek, dayOfWeekKey as any),
      ))
      .limit(1);

    if (docSchedule?.isOff) {
      return {
        allowed: false, severity: 'blocking', code: 'DOCTOR_OFF',
        message: `The selected doctor is not available on ${dayName}s. Please choose a different date or doctor.`,
      };
    }
    if (docSchedule?.startTime && docSchedule?.endTime) {
      const reqStart = toMin(appointmentTime);
      const reqEnd = reqStart + durationMinutes;
      const docStart = toMin(docSchedule.startTime);
      const docEnd = toMin(docSchedule.endTime);
      if (reqStart < docStart || reqEnd > docEnd) {
        return {
          allowed: false, severity: 'blocking', code: 'OUTSIDE_DOCTOR_HOURS',
          message: `This appointment falls outside the doctor's working hours on ${dayName}s (${fmt(docStart)}${fmt(docEnd)}).`,
        };
      }
    }
  }

  // Patient duplicate slot check
  if (patientId) {
    const [duplicate] = await db
      .select({ id: appointments.id })
      .from(appointments)
      .where(and(
        eq(appointments.clinicId, clinicId),
        eq(appointments.patientId, patientId),
        eq(appointments.appointmentDate, appointmentDate),
        eq(appointments.appointmentTime, appointmentTime),
        or(
          eq(appointments.status, 'requested'),
          eq(appointments.status, 'scheduled'),
          eq(appointments.status, 'confirmed'),
          eq(appointments.status, 'in_progress'),
        ),
      ))
      .limit(1);
    if (duplicate) {
      return {
        allowed: false, severity: 'blocking', code: 'PATIENT_DUPLICATE',
        message: `This patient already has an appointment on ${appointmentDate} at ${appointmentTime}.`,
      };
    }
  }

  // Doctor/room conflict detection
  if (doctorId) {
    const [clinicRow] = await db.select({ operatingHours: clinics.operatingHours })
      .from(clinics)
      .where(eq(clinics.id, clinicId))
      .limit(1);

    const clinicHours = clinicRow?.operatingHours
      ? (typeof clinicRow.operatingHours === 'string' ? JSON.parse(clinicRow.operatingHours) : clinicRow.operatingHours)
      : null;
    const bufferMinutes: number = clinicHours?.bufferMinutes ?? 0;

    const orClauses: any[] = [eq(appointments.doctorId, doctorId)];
    if (operatory) orClauses.push(eq(appointments.operatory, operatory));

    const existing = await db
      .select({
        id: appointments.id,
        doctorId: appointments.doctorId,
        operatory: appointments.operatory,
        appointmentTime: appointments.appointmentTime,
        durationMinutes: appointments.durationMinutes,
        status: appointments.status,
      })
      .from(appointments)
      .where(and(
        eq(appointments.clinicId, clinicId),
        or(...orClauses),
        eq(appointments.appointmentDate, appointmentDate),
        or(
          eq(appointments.status, 'scheduled'),
          eq(appointments.status, 'confirmed'),
          eq(appointments.status, 'in_progress'),
        ),
      ));

    const conflictResult = detectConflicts(existing, { ...ctx, bufferMinutes, existingAppointmentId });
    if (!conflictResult.allowed) return conflictResult;
  }

  return { allowed: true, severity: 'blocking', code: 'OK', message: 'Appointment can be created' };
}
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -30
Expected: no errors from rules/scheduling/canCreateAppointment.ts
  • Step 3: Commit
git add server/src/lib/rules/scheduling/canCreateAppointment.ts
git commit -m "feat(rules): add canCreateAppointment rule"

Task 5: canUpdateAppointment

Files:
  • Create: server/src/lib/rules/scheduling/canUpdateAppointment.ts
  • Step 1: Write the file
// server/src/lib/rules/scheduling/canUpdateAppointment.ts
import { canCreateAppointment } from './canCreateAppointment';
import type { AppointmentRuleContext, RuleResult } from '../types';
import { getReadDb } from '../../db';

type Db = ReturnType<typeof getReadDb>;

export async function canUpdateAppointment(db: Db, ctx: AppointmentRuleContext): Promise<RuleResult> {
  if (!ctx.existingAppointmentId) {
    return { allowed: false, severity: 'blocking', code: 'MISSING_ID', message: 'existingAppointmentId required for update' };
  }
  return canCreateAppointment(db, ctx);
}
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors
  • Step 3: Commit
git add server/src/lib/rules/scheduling/canUpdateAppointment.ts
git commit -m "feat(rules): add canUpdateAppointment rule"

Task 6: canCancelAppointment

Files:
  • Create: server/src/lib/rules/scheduling/canCancelAppointment.ts
  • Step 1: Write the file (extracted from appointments.ts:1420-1451)
// server/src/lib/rules/scheduling/canCancelAppointment.ts
import type { RuleResult } from '../types';

export function canCancelAppointment(ctx: {
  requestingRole: string;
  appointmentDate: string;
  appointmentTime: string;
  patientOwnsAppointment: boolean;
}): RuleResult {
  const { requestingRole, appointmentDate, appointmentTime, patientOwnsAppointment } = ctx;

  if (requestingRole === 'patient') {
    if (!patientOwnsAppointment) {
      return {
        allowed: false, severity: 'blocking', code: 'NOT_OWN_APPOINTMENT',
        message: 'You can only cancel your own appointments',
      };
    }
    const apptDateTime = new Date(`${appointmentDate}T${appointmentTime}`);
    const hoursUntil = (apptDateTime.getTime() - Date.now()) / (1000 * 60 * 60);
    if (hoursUntil < 48) {
      return {
        allowed: false, severity: 'blocking', code: 'CANCELLATION_WINDOW',
        message: 'Appointments can only be cancelled at least 48 hours in advance. Please contact the clinic directly for last-minute cancellations.',
      };
    }
  }

  return { allowed: true, severity: 'blocking', code: 'OK', message: 'Appointment can be cancelled' };
}
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors
  • Step 3: Commit
git add server/src/lib/rules/scheduling/canCancelAppointment.ts
git commit -m "feat(rules): add canCancelAppointment rule"

Task 7: canChangeStatus

Files:
  • Create: server/src/lib/rules/scheduling/canChangeStatus.ts
  • Step 1: Write the file (extracted from appointments.ts:1326-1417)
// server/src/lib/rules/scheduling/canChangeStatus.ts
import { isValidTransition, validStatusTransitions } from './appointmentStateMachine';
import type { RuleResult } from '../types';

const rolePermissions: Record<string, string[]> = {
  admin: ['requested', 'scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show'],
  receptionist: ['scheduled', 'confirmed', 'cancelled', 'no_show', 'completed'],
  doctor: ['confirmed', 'in_progress', 'completed', 'no_show'],
  patient: ['cancelled'],
};

export function canChangeStatus(ctx: {
  currentStatus: string;
  newStatus: string;
  requestingRole: string;
  appointmentDate?: string;
  appointmentTime?: string;
}): RuleResult {
  const { currentStatus, newStatus, requestingRole, appointmentDate, appointmentTime } = ctx;

  if (!isValidTransition(currentStatus, newStatus)) {
    const allowed = (validStatusTransitions[currentStatus] ?? []).join(', ');
    return {
      allowed: false, severity: 'blocking', code: 'INVALID_TRANSITION',
      message: `Cannot change status from '${currentStatus}' to '${newStatus}'. Allowed transitions: ${allowed || 'none (terminal state)'}`,
    };
  }

  if (['in_progress', 'completed', 'no_show'].includes(newStatus) && appointmentDate && appointmentTime) {
    const apptTime = new Date(`${appointmentDate}T${appointmentTime}`);
    if (apptTime.getTime() > Date.now()) {
      return {
        allowed: false, severity: 'blocking', code: 'FUTURE_APPOINTMENT',
        message: 'Cannot change status before the scheduled appointment time.',
      };
    }
  }

  const allowedStatuses = rolePermissions[requestingRole] ?? [];
  if (!allowedStatuses.includes(newStatus)) {
    return {
      allowed: false, severity: 'blocking', code: 'ROLE_PERMISSION',
      message: `Your role (${requestingRole}) does not have permission to set status to '${newStatus}'`,
    };
  }

  return { allowed: true, severity: 'blocking', code: 'OK', message: 'Status change allowed' };
}
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors
  • Step 3: Commit
git add server/src/lib/rules/scheduling/canChangeStatus.ts
git commit -m "feat(rules): add canChangeStatus rule"

Task 8: calculateAvailableSlots

Files:
  • Create: server/src/lib/rules/scheduling/calculateAvailableSlots.ts
  • Step 1: Write the file (extracted from appointments.ts:1649-1783)
// server/src/lib/rules/scheduling/calculateAvailableSlots.ts
import { eq, and, sql } from 'drizzle-orm';
import { appointments, clinicOperatingHours, clinics, doctorSchedules, users } from '../../../schema';
import { nowPKTDateString, nowPKTTimeString } from '../../pkt';
import { AppError } from '../../errors';
import { getReadDb } from '../../db';

type Db = ReturnType<typeof getReadDb>;

const DAY_NAMES = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'] as const;

const toMinutes = (t: string): number => {
  const [h, m] = t.split(':').map(Number);
  return (h || 0) * 60 + (m || 0);
};

export interface AvailableSlotsResult {
  slots: string[];
  clinicClosed: boolean;
  doctorOff: boolean;
  clinicPhone: string | null;
}

export async function calculateAvailableSlots(
  db: Db,
  clinicId: string,
  dateParam: string,
  doctorIdParam: string | null,
): Promise<AvailableSlotsResult> {
  const dayOfWeekKey = DAY_NAMES[new Date(`${dateParam}T00:00:00`).getDay()];

  const [clinicHours] = await db.select({
    openTime: clinicOperatingHours.openTime,
    closeTime: clinicOperatingHours.closeTime,
    isClosed: clinicOperatingHours.isClosed,
  })
    .from(clinicOperatingHours)
    .where(and(
      eq(clinicOperatingHours.clinicId, clinicId),
      eq(clinicOperatingHours.dayOfWeek, dayOfWeekKey as any),
    ))
    .limit(1);

  const [clinicRow] = await db.select({ phone: clinics.phone })
    .from(clinics)
    .where(eq(clinics.id, clinicId))
    .limit(1);
  const clinicPhone = clinicRow?.phone ?? null;

  if (clinicHours?.isClosed) {
    return { slots: [], clinicClosed: true, doctorOff: false, clinicPhone };
  }

  const clinicOpen = clinicHours?.openTime ? toMinutes(clinicHours.openTime) : 0;
  const clinicClose = clinicHours?.closeTime ? toMinutes(clinicHours.closeTime) : 24 * 60;

  let effectiveOpen = clinicOpen;
  let effectiveClose = clinicClose;

  if (doctorIdParam) {
    const [doctorUser] = await db.select({ id: users.id })
      .from(users)
      .where(and(eq(users.id, doctorIdParam), eq(users.primaryClinicId, clinicId)))
      .limit(1);
    if (!doctorUser) throw new AppError('Doctor not found in this clinic', 403);

    const [docSchedule] = await db.select()
      .from(doctorSchedules)
      .where(and(
        eq(doctorSchedules.clinicId, clinicId),
        eq(doctorSchedules.doctorId, doctorIdParam),
        eq(doctorSchedules.dayOfWeek, dayOfWeekKey),
      ))
      .limit(1);

    if (docSchedule) {
      if (docSchedule.isOff) {
        return { slots: [], clinicClosed: false, doctorOff: true, clinicPhone };
      }
      effectiveOpen = Math.max(clinicOpen, toMinutes(docSchedule.startTime));
      effectiveClose = Math.min(clinicClose, toMinutes(docSchedule.endTime));
    }
  }

  if (effectiveOpen >= effectiveClose) {
    return { slots: [], clinicClosed: false, doctorOff: false, clinicPhone };
  }

  const allSlots: string[] = [];
  for (let m = effectiveOpen; m + 30 <= effectiveClose; m += 30) {
    allSlots.push(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`);
  }

  const bookedFilter: any[] = [
    eq(appointments.clinicId, clinicId),
    eq(appointments.appointmentDate, dateParam),
    sql`${appointments.status} NOT IN ('cancelled', 'rejected')`,
  ];
  if (doctorIdParam) bookedFilter.push(eq(appointments.doctorId, doctorIdParam));

  const booked = await db.select({ time: appointments.appointmentTime, duration: appointments.durationMinutes })
    .from(appointments)
    .where(and(...bookedFilter));

  const bookedMinutes = new Set<number>();
  for (const b of booked) {
    if (!b.time) continue;
    const start = toMinutes(b.time);
    const dur = Number(b.duration || 30);
    for (let m = start; m < start + dur; m += 30) bookedMinutes.add(m);
  }

  const todayPKT = nowPKTDateString();
  const nowMin = toMinutes(nowPKTTimeString());

  const availableSlots = allSlots.filter(slot => {
    const slotMin = toMinutes(slot);
    if (bookedMinutes.has(slotMin)) return false;
    if (dateParam === todayPKT && slotMin <= nowMin) return false;
    return true;
  });

  return { slots: availableSlots, clinicClosed: false, doctorOff: false, clinicPhone };
}
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors
  • Step 3: Commit
git add server/src/lib/rules/scheduling/calculateAvailableSlots.ts
git commit -m "feat(rules): add calculateAvailableSlots"

Task 9: canCompleteAppointment

Files:
  • Create: server/src/lib/rules/billing/canCompleteAppointment.ts
  • Step 1: Write the file (extracted from appointments.ts:1457-1487)
// server/src/lib/rules/billing/canCompleteAppointment.ts
import { eq, and } from 'drizzle-orm';
import { invoices } from '../../../schema';
import type { RuleResult } from '../types';
import { getReadDb } from '../../db';

type Db = ReturnType<typeof getReadDb>;

export async function canCompleteAppointment(
  db: Db,
  appointmentId: string,
  clinicId: string,
  hasInvoicePayload: boolean,
): Promise<RuleResult> {
  const [existingInvoice] = await db.select({ id: invoices.id })
    .from(invoices)
    .where(and(eq(invoices.appointmentId, appointmentId), eq(invoices.clinicId, clinicId)))
    .limit(1);

  if (existingInvoice || hasInvoicePayload) {
    return { allowed: true, severity: 'blocking', code: 'OK', message: 'Invoice available' };
  }

  return {
    allowed: false, severity: 'blocking', code: 'INVOICE_REQUIRED',
    message: 'Invoice details are required to complete the appointment',
  };
}
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors
  • Step 3: Commit
git add server/src/lib/rules/billing/canCompleteAppointment.ts
git commit -m "feat(rules): add canCompleteAppointment billing rule"

Task 10: canEditAppointment

Files:
  • Create: server/src/lib/rules/access/canEditAppointment.ts
  • Step 1: Write the file (from appointments.ts:1420-1436)
// server/src/lib/rules/access/canEditAppointment.ts
import type { RuleResult } from '../types';

export function canEditAppointment(ctx: { requestingRole: string }): RuleResult {
  if (ctx.requestingRole === 'patient') {
    return {
      allowed: false, severity: 'blocking', code: 'PATIENT_WRITE',
      message: 'Patients cannot modify appointment details directly.',
    };
  }
  return { allowed: true, severity: 'blocking', code: 'OK', message: 'Write access allowed' };
}
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20
Expected: no errors
  • Step 3: Commit
git add server/src/lib/rules/access/canEditAppointment.ts
git commit -m "feat(rules): add canEditAppointment access rule"

Task 11: Rules service factory

Files:
  • Create: server/src/lib/rules/index.ts
  • Step 1: Write the file
// server/src/lib/rules/index.ts
import { canCreateAppointment } from './scheduling/canCreateAppointment';
import { canUpdateAppointment } from './scheduling/canUpdateAppointment';
import { canCancelAppointment } from './scheduling/canCancelAppointment';
import { canChangeStatus } from './scheduling/canChangeStatus';
import { detectConflicts } from './scheduling/detectConflicts';
import { calculateAvailableSlots } from './scheduling/calculateAvailableSlots';
import { canCompleteAppointment } from './billing/canCompleteAppointment';
import { canEditAppointment } from './access/canEditAppointment';
import { isValidTransition, isFinalState } from './scheduling/appointmentStateMachine';
import { getReadDb } from '../db';

type Db = ReturnType<typeof getReadDb>;

export function createSchedulingRules(db: Db) {
  return {
    canCreateAppointment: (ctx: Parameters<typeof canCreateAppointment>[1]) =>
      canCreateAppointment(db, ctx),
    canUpdateAppointment: (ctx: Parameters<typeof canUpdateAppointment>[1]) =>
      canUpdateAppointment(db, ctx),
    canCancelAppointment,
    canChangeStatus,
    detectConflicts,
    calculateAvailableSlots: (clinicId: string, dateParam: string, doctorIdParam: string | null) =>
      calculateAvailableSlots(db, clinicId, dateParam, doctorIdParam),
    canCompleteAppointment: (appointmentId: string, clinicId: string, hasInvoicePayload: boolean) =>
      canCompleteAppointment(db, appointmentId, clinicId, hasInvoicePayload),
    canEditAppointment,
    isValidTransition,
    isFinalState,
  };
}

export type SchedulingRules = ReturnType<typeof createSchedulingRules>;
  • Step 2: Verify TypeScript compiles cleanly
cd server && npx tsc --noEmit 2>&1 | head -30
Expected: zero errors
  • Step 3: Commit
git add server/src/lib/rules/index.ts
git commit -m "feat(rules): export createSchedulingRules factory"

Task 12: Wire rules into appointments.ts (Phase 2)

Files:
  • Modify: server/src/routes/appointments.ts
This task replaces four inline validation blocks one at a time. Each replacement is its own commit. The existing route file is never wholesale rewritten.

12a — POST / creation validation

  • Step 1: Add import at top of appointments.ts
At the end of the existing imports block (after line 23), add:
import { createSchedulingRules } from '../lib/rules';
  • Step 2: Replace the inline creation validators
In appointmentsRoute.post('/', ...), find the block starting at // Validation: Check if appointment date is in the past (line ~273) and ending at the closing } of the doctor/room conflict loop (line ~472). Replace the entire block with:
    // ── Scheduling rules ───────────────────────────────────────────────────
    const rules = createSchedulingRules(db);
    const ruleCtx = {
      clinicId: currentClinicId,
      doctorId: body.doctorId,
      patientId: body.patientId,
      appointmentDate: body.appointmentDate,
      appointmentTime: body.appointmentTime,
      durationMinutes: body.durationMinutes || 30,
      operatory: body.operatory,
      requestingRole: user.role,
    };
    const createCheck = await rules.canCreateAppointment(ruleCtx);
    if (!createCheck.allowed) {
      return c.json(
        { message: createCheck.message, code: createCheck.code, conflicts: createCheck.conflicts },
        createCheck.severity === 'blocking' ? 409 : 400
      );
    }
    // ───────────────────────────────────────────────────────────────────────
  • Step 3: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -30
Expected: no errors
  • Step 4: Commit
git add server/src/routes/appointments.ts
git commit -m "feat(rules): wire canCreateAppointment into POST / route"

12b — PATCH /status state machine + role matrix

  • Step 1: In appointmentsRoute.patch('/:id/status', ...), replace the time-based restriction block, state machine check, and role permission block
Find the block from // Prevent completing, starting, or marking no-show for future appointments (line ~1326) to the end of the role permission block } (line ~1417). Replace with:
    const statusCheck = rules.canChangeStatus({
      currentStatus,
      newStatus,
      requestingRole: role,
      appointmentDate: existing.appointmentDate,
      appointmentTime: existing.appointmentTime,
    });
    if (!statusCheck.allowed) {
      return c.json(
        { message: statusCheck.message, code: statusCheck.code },
        statusCheck.code === 'ROLE_PERMISSION' ? 403 : 400
      );
    }
Note: rules was already declared above in the handler body. If not, add const rules = createSchedulingRules(db); before this block.
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -30
Expected: no errors
  • Step 3: Commit
git add server/src/routes/appointments.ts
git commit -m "feat(rules): wire canChangeStatus into PATCH /status route"

12c — PATCH /status patient cancellation + completion invoice

  • Step 1: Replace patient cancellation block (line ~1419-1451)
Find the block // Patient-specific restrictions and replace with:
    if (role === 'patient' && newStatus === 'cancelled') {
      const patientOwns = !!(patientInfo && (() => {
        const userEmail = user.email || '';
        return userEmail && patientInfo.email === userEmail;
      })());
      const cancelCheck = rules.canCancelAppointment({
        requestingRole: role,
        appointmentDate: existing.appointmentDate,
        appointmentTime: existing.appointmentTime,
        patientOwnsAppointment: patientOwns,
      });
      if (!cancelCheck.allowed) {
        return c.json({ message: cancelCheck.message, code: cancelCheck.code }, 400);
      }
    }
  • Step 2: Replace invoice check inside the transaction (lines ~1457-1487)
Find the block if (newStatus === 'completed') { inside db.transaction(...). Replace with:
      if (newStatus === 'completed') {
        const completeCheck = await rules.canCompleteAppointment(id, currentClinicId, !!invoicePayload);
        if (!completeCheck.allowed) {
          throw new AppError(completeCheck.message, 400);
        }
        const [existingInvoice] = await tx.select()
          .from(invoices)
          .where(and(eq(invoices.appointmentId, id), eq(invoices.clinicId, currentClinicId)))
          .limit(1);

        if (existingInvoice) {
          linkedInvoice = existingInvoice;
        } else if (invoicePayload) {
          createdInvoice = await createInvoiceWithItems(tx, {
            clinicId: currentClinicId,
            user,
            patientId: existing.patientId,
            appointmentId: id,
            invoiceDate: nowPKTDateString(),
            dueDate: invoicePayload.dueDate || null,
            discountAmount: invoicePayload.discountAmount || 0,
            taxAmount: invoicePayload.taxAmount,
            notes: invoicePayload.notes,
            items: invoicePayload.items.map((item) => ({
              description: item.description,
              quantity: item.quantity,
              unitPrice: item.unitPrice,
              cost: item.cost || 0,
            })),
          });
        }
      }
  • Step 3: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -30
Expected: no errors
  • Step 4: Commit
git add server/src/routes/appointments.ts
git commit -m "feat(rules): wire canCancelAppointment + canCompleteAppointment into PATCH /status"

12d — GET /available-slots route body

  • Step 1: Replace the entire route body of appointmentsRoute.get('/available-slots', ...) with:
appointmentsRoute.get('/available-slots', requireClinicContext, async (c) => {
  try {
    const db = getReadDb();
    await ensureAppointmentsSchema(db);

    const clinicContext = c.get('clinicContext');
    const currentClinicId = clinicContext?.currentClinicId || '';
    if (!currentClinicId) throw new AppError('Clinic ID required', 400);

    const dateParam = c.req.query('date');
    const doctorIdParam = c.req.query('doctorId') || null;

    if (!dateParam || !/^\d{4}-\d{2}-\d{2}$/.test(dateParam)) {
      throw new AppError('date query param required (YYYY-MM-DD)', 400);
    }

    const rules = createSchedulingRules(db);
    const result = await rules.calculateAvailableSlots(currentClinicId, dateParam, doctorIdParam);
    return c.json(result);
  } catch (error) {
    return handleError(error, c);
  }
});
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -30
Expected: no errors
  • Step 3: Verify the available-slots endpoint still works via wrangler dev
cd server && npx wrangler dev --port 8788 &
# In another terminal or after starting:
curl "http://localhost:8788/api/v1/protected/appointments/available-slots?date=2026-05-06" \
  -H "Authorization: Bearer test" 2>&1 | head -5
Expected: JSON response (may be 401 in local, that’s fine — server starts without crash)
  • Step 4: Commit
git add server/src/routes/appointments.ts
git commit -m "feat(rules): wire calculateAvailableSlots into GET /available-slots"

Task 13: UI types — extend SchedulerContextType

Files:
  • Modify: ui/src/types/index.ts
  • Step 1: Add DoctorEffectiveHours type and extend SchedulerContextType
In ui/src/types/index.ts, after the line export type ClinicOperatingHours = Record<string, ClinicDayHours>; (line 98), add:
export interface DoctorEffectiveHours {
  startHour: number;
  endHour: number;
}
Then in the SchedulerContextType interface (starting at line 100), add two new fields after operatingHours:
  doctorOffToday: boolean;
  doctorEffectiveHours: DoctorEffectiveHours | null;
The complete updated SchedulerContextType should read:
export interface SchedulerContextType {
  events: SchedulerState;
  dispatch: Dispatch<Action>;
  getters: Getters;
  handlers: Handlers;
  weekStartsOn: startOfWeek;
  filterDoctorId: string | null;
  setFilterDoctorId: (id: string | null) => void;
  operatingHours?: ClinicOperatingHours | null;
  doctorOffToday: boolean;
  doctorEffectiveHours: DoctorEffectiveHours | null;
}
  • Step 2: Verify TypeScript compiles
cd ui && npx tsc --noEmit 2>&1 | head -30
Expected: errors mentioning doctorOffToday/doctorEffectiveHours not provided — that’s expected, will be fixed in Task 14.
  • Step 3: Commit
git add ui/src/types/index.ts
git commit -m "feat(schedule): add DoctorEffectiveHours type to SchedulerContextType"

Task 14: SchedulerProvider — accept doctorSchedules + currentDate, compute doctor-aware hours

Files:
  • Modify: ui/src/providers/schedular-provider.tsx
  • Step 1: Add new props and internal state
In ui/src/providers/schedular-provider.tsx, update the SchedulerProvider component:
  1. Add import for DoctorScheduleRow type at the top of the file (after existing imports):
import type { DoctorScheduleRow } from '@/lib/serverComm';
import type { DoctorEffectiveHours } from '@/types/index';
  1. Add the new props to the destructuring signature (after operatingHours):
  doctorSchedules,
  currentDate,
}: {
  // ...existing props...
  doctorSchedules?: DoctorScheduleRow[];
  currentDate?: Date;
}) => {
The complete updated props destructuring for SchedulerProvider (replacing the existing {...} parameter list) is:
export const SchedulerProvider = ({
  children,
  onAddEvent,
  onUpdateEvent,
  onDeleteEvent,
  initialState,
  weekStartsOn = "sunday",
  startHour = 0,
  endHour = 24,
  operatingHours,
  doctorSchedules,
  currentDate,
}: {
  onAddEvent?: (event: Event) => void;
  onUpdateEvent?: (event: Event) => void;
  onDeleteEvent?: (id: string) => void;
  weekStartsOn?: startOfWeek;
  children: ReactNode;
  initialState?: Event[];
  startHour?: number;
  endHour?: number;
  operatingHours?: ClinicOperatingHours | null;
  doctorSchedules?: DoctorScheduleRow[];
  currentDate?: Date;
}) => {
  1. After const [filterDoctorId, setFilterDoctorId] = useState<string | null>(null); (line 105), add:
  const [doctorOffToday, setDoctorOffToday] = useState(false);
  const [doctorEffectiveHours, setDoctorEffectiveHours] = useState<DoctorEffectiveHours | null>(null);

  const DAY_NAMES_LOWER = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'];

  useEffect(() => {
    if (!filterDoctorId || !currentDate || !doctorSchedules?.length) {
      setDoctorOffToday(false);
      setDoctorEffectiveHours(null);
      return;
    }
    const dayName = DAY_NAMES_LOWER[currentDate.getDay()];
    const schedule = doctorSchedules.find(s => s.doctorId === filterDoctorId && s.dayOfWeek === dayName);
    if (!schedule) {
      setDoctorOffToday(false);
      setDoctorEffectiveHours(null);
      return;
    }
    if (schedule.isOff) {
      setDoctorOffToday(true);
      setDoctorEffectiveHours(null);
      return;
    }
    const toHour = (t: string) => parseInt(t.split(':')[0], 10);
    setDoctorOffToday(false);
    setDoctorEffectiveHours({ startHour: toHour(schedule.startTime), endHour: toHour(schedule.endTime) });
  }, [filterDoctorId, currentDate, doctorSchedules]);
  1. In the context value object (the <SchedulerContext.Provider value={{ ... }}> at line ~361), add the two new fields:
      value={{
        events: state,
        dispatch,
        getters,
        handlers,
        weekStartsOn,
        filterDoctorId,
        setFilterDoctorId,
        operatingHours,
        doctorOffToday,
        doctorEffectiveHours,
      }}
  • Step 2: Verify TypeScript compiles
cd ui && npx tsc --noEmit 2>&1 | head -30
Expected: errors should reduce — doctorOffToday and doctorEffectiveHours are now provided. Remaining errors are about the AppointmentCalendar not yet passing the new props.
  • Step 3: Commit
git add ui/src/providers/schedular-provider.tsx
git commit -m "feat(schedule): SchedulerProvider computes doctor-aware hours via context"

Task 15: AppointmentCalendar — add doctorSchedules query + pass to SchedulerProvider

Files:
  • Modify: ui/src/components/appointments/AppointmentCalendar.tsx
  • Step 1: Add getDoctorSchedules import
The file already imports updateAppointment and others from @/lib/serverComm (line 18). Add getDoctorSchedules to the same import:
import {
  // ...existing imports...
  getDoctorSchedules,
} from '@/lib/serverComm';
Also add qk query key reference if not already present (it’s already imported at line 3 via import { qk } from '@/lib/queryKeys').
  • Step 2: Add the doctorSchedules query
After the existing useQuery calls (or near the doctors state, around line 681), add:
  const { data: doctorSchedules = [] } = useQuery({
    queryKey: qk.doctorSchedules.all(),
    queryFn: getDoctorSchedules,
    staleTime: 5 * 60_000,
  });
  • Step 3: Pass doctorSchedules and currentDate to SchedulerProvider
In the JSX where <SchedulerProvider ...> is rendered (around line 885), add two new props:
        <SchedulerProvider
          initialState={events}
          weekStartsOn="monday"
          startHour={startHour}
          endHour={endHour}
          operatingHours={operatingHours}
          doctorSchedules={doctorSchedules}
          currentDate={currentDate}
        >
  • Step 4: Verify TypeScript compiles
cd ui && npx tsc --noEmit 2>&1 | head -30
Expected: zero type errors
  • Step 5: Commit
git add ui/src/components/appointments/AppointmentCalendar.tsx
git commit -m "feat(schedule): pass doctorSchedules + currentDate to SchedulerProvider"

Task 16: DoctorDayView — doctor-off banner + unassigned assign action

Files:
  • Modify: ui/src/components/schedule/_components/view/day/doctor-day-view.tsx
  • Step 1: Add new imports
Add the following imports to doctor-day-view.tsx at the top, after the existing imports:
import { useState } from "react";
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { UserPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { updateAppointment } from '@/lib/serverComm';
import { qk } from '@/lib/queryKeys';
Note: useState is already imported on line 1 via import React, { useRef, useState, ... }. Do not duplicate it — just add the other imports.
  • Step 2: Add AssignButton component (inside the file, before the DoctorDayView function)
function AssignButton({ event, doctors }: { event: Event; doctors: Doctor[] }) {
  const queryClient = useQueryClient();
  const [open, setOpen] = useState(false);

  const mutation = useMutation({
    mutationFn: (doctorId: string) => updateAppointment(event.id, { doctorId }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: qk.appointments.all() });
      setOpen(false);
    },
  });

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button
          variant="ghost"
          size="icon"
          className="absolute bottom-1 right-1 h-5 w-5 bg-background/80 hover:bg-background z-[60]"
          onClick={(e) => e.stopPropagation()}
          title="Assign to doctor"
        >
          <UserPlus className="h-3 w-3" />
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-48 p-2" side="right" align="end">
        <p className="text-xs font-medium text-muted-foreground mb-2">Assign to doctor</p>
        <Select onValueChange={(id) => mutation.mutate(id)} disabled={mutation.isPending}>
          <SelectTrigger className="h-8 text-xs">
            <SelectValue placeholder="Select doctor" />
          </SelectTrigger>
          <SelectContent>
            {doctors.map((doc) => (
              <SelectItem key={doc.id} value={doc.id} className="text-xs">
                Dr. {doc.firstName} {doc.lastName}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </PopoverContent>
    </Popover>
  );
}
  • Step 3: Update the useScheduler destructure to include new context values
Find (line ~122):
  const { getters, handlers } = useScheduler();
Replace with:
  const { getters, handlers, filterDoctorId, doctorOffToday, doctorEffectiveHours } = useScheduler();
  • Step 4: Add effective hours override
After the useScheduler line, add:
  const effectiveStartHour = (filterDoctorId && doctorEffectiveHours) ? doctorEffectiveHours.startHour : startHour;
  const effectiveEndHour = (filterDoctorId && doctorEffectiveHours) ? doctorEffectiveHours.endHour : endHour;
Then replace all four usages of startHour and endHour in the component body with effectiveStartHour and effectiveEndHour:
  • In const totalHours = endHour - startHour;const totalHours = effectiveEndHour - effectiveStartHour;
  • In Array.from({ length: totalHours }, (_, i) => { const hour = (startHour + i) ...(effectiveStartHour + i)
  • In const currentHour = now.getHours() + now.getMinutes() / 60; if (currentHour < startHour || currentHour >= endHour)< effectiveStartHour and >= effectiveEndHour
  • In return (currentHour - startHour) * hourHeight;(currentHour - effectiveStartHour)
  • In const hourIndex = startHour + i; inside Array.from(...) for click slots → effectiveStartHour + i
  • Step 5: Add doctor-off banner
At the start of the return (...) block in DoctorDayView, add an early return for the off-today case. Insert before the existing return ( at the top of the render:
  if (filterDoctorId && doctorOffToday) {
    const offDoctor = doctors.find(d => d.id === filterDoctorId);
    const name = offDoctor ? `Dr. ${offDoctor.firstName} ${offDoctor.lastName}` : 'The selected doctor';
    return (
      <div className="flex h-full items-center justify-center p-8">
        <div className="text-center space-y-2">
          <p className="text-base font-medium text-muted-foreground">{name} is off today.</p>
          <p className="text-sm text-muted-foreground">Select a different date or change the doctor filter.</p>
        </div>
      </div>
    );
  }
  • Step 6: Add AssignButton to unassigned event cards
In the columns render loop, find the <motion.div> that wraps <EventStyled> (inside the colEvents?.map((event) => {...}) call). The current structure ends with:
                              <EventStyled
                                event={{
                                  ...event,
                                  CustomEventComponent,
                                  minmized: true,
                                }}
                                CustomEventModal={CustomEventModal}
                              />
Change it to:
                              <EventStyled
                                event={{
                                  ...event,
                                  CustomEventComponent,
                                  minmized: true,
                                }}
                                CustomEventModal={CustomEventModal}
                              />
                              {col.id === '__unassigned__' && (
                                <AssignButton event={event} doctors={doctors} />
                              )}
The <motion.div> already has className="... absolute" so positioning the button with absolute bottom-1 right-1 will work within it.
  • Step 7: Verify TypeScript compiles
cd ui && npx tsc --noEmit 2>&1 | head -30
Expected: zero errors
  • Step 8: Build UI to verify no bundle errors
cd ui && npm run build 2>&1 | tail -10
Expected: ✓ built in ... with no errors
  • Step 9: Commit
git add ui/src/components/schedule/_components/view/day/doctor-day-view.tsx
git commit -m "feat(schedule): doctor-off banner + unassigned assign action in DoctorDayView"

Task 17: Deploy and verify

  • Step 1: Deploy server
cd server && npx wrangler deploy 2>&1 | tail -10
Expected: ✅ Deployed ... Successfully with no migration errors
  • Step 2: Build and deploy UI
cd ui && npm run build 2>&1 | tail -5
npx wrangler pages deploy dist --project-name odonto-prod-ui --branch main --commit-dirty=true 2>&1 | tail -8
  • Step 3: Force-promote canonical deployment (per odontox-commit-deploy skill, Step 7)
ACCT=9da8a2bb48668ff798b91bd00e9ae005
TOKEN=$(grep 'oauth_token' ~/Library/Preferences/.wrangler/config/default.toml | sed 's/.*"\(.*\)"/\1/')
PROJECT=odonto-prod-ui
LATEST=$(curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCT/pages/projects/$PROJECT" \
  -H "Authorization: Bearer $TOKEN" | \
  python3 -c "import sys,json;print(json.load(sys.stdin)['result']['latest_deployment']['id'])")
echo "Latest: $LATEST"
curl -s -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$ACCT/pages/projects/$PROJECT/deployments/$LATEST/rollback" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" | \
  python3 -c "import sys,json;d=json.load(sys.stdin);print('✅' if d.get('result') else '❌', d.get('errors',''))"
  • Step 4: Smoke tests
  1. Open the clinic portal, go to Appointments → Day view → switch to “By Doctor” layout
  2. Select a doctor from the filter dropdown who has restricted hours (e.g. starts at 13:00) — verify the time grid starts at 13:00, not 09:00
  3. Navigate to a day that doctor is marked as off — verify the “off today” banner appears
  4. Create an appointment with no doctor assigned — verify it appears in the “Unassigned” column
  5. Click the person-plus icon on an unassigned card — verify the doctor popover opens with doctor list
  6. Select a doctor — verify the card moves to that doctor’s column
  7. Go to Staff Management → pick a doctor → save schedule — verify no 500 errors in console (cold-start fix from previous session)
  8. Try to book a past appointment via the API — verify 409 with code: "PAST_DATE" in the response body

Spec coverage check

Spec sectionCovered by
SchedulingRulesService file structure (§1.1)Tasks 1–11
Core types (§1.2)Task 1
Rules extracted (§1.3 table)Tasks 4–10
Migration strategy Phase 1 + 2 (§1.4)Tasks 1–11 (phase 1), Task 12 (phase 2)
Structured error responses (§1.5)Task 12 — c.json({ message, code, conflicts }, ...)
Doctor-aware day view (§2)Tasks 13–15
Day-of-week mapping (§2.3)Task 14 — DAY_NAMES_LOWER[currentDate.getDay()]
Unassigned assignment (§3)Task 16
No new API endpoint for assignment (§3.2)Confirmed — uses existing PUT /appointments/:id
GET /doctor-schedules 400 fix (§4)Already deployed (previous session)