Appointment Lifecycle & State Machine
Overview
Appointments follow a strict lifecycle from public booking through clinical completion. The state machine enforces valid transitions, role-based permissions, and automatic side effects.
Status Definitions
CREATE TYPE appointment_status AS ENUM (
'booked', -- Public booking confirmed, no patient account yet
'upcoming', -- Patient onboarded, appointment scheduled
'confirmed', -- Patient confirmed attendance
'inprogress', -- Appointment is happening now
'done', -- Appointment completed
'cancelled', -- Appointment cancelled
'noshow' -- Patient didn't show up
);Status Labels (Romanian)
| Status | Romanian Label | Description |
|---|---|---|
booked | Rezervată | Booking confirmed from public page. Contact info only — no patient account. |
upcoming | Programată | Patient onboarded. Forms generated, videocall room created. |
confirmed | Confirmată | Patient acknowledged and confirmed they will attend |
inprogress | În lucru | Specialist has started the appointment session |
done | Realizată | Appointment finished, forms completed/signed |
cancelled | Anulată | Appointment cancelled (by patient, specialist, or admin) |
noshow | Neprezentare | Patient didn't attend the scheduled appointment |
State Transition Diagram
┌──────────────────────────────────────────┐
│ │
▼ │
Public ┌──────────┐ Onboard ┌───────────┐ ┌─────┴─────┐
Booking ───▶│ booked │───────────▶│ upcoming │─────── Patient confirms ───▶│ confirmed │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
▼ ┌────────┼────────────┐ ┌────────────┼────────────┐
┌───────────┐ │ │ │ │ │ │
│ cancelled │ ▼ ▼ ▼ ▼ ▼ ▼
└───────────┘ ┌───────────┐ ┌─────────┐ ┌─────────┐ ┌───────────┐ ┌─────────┐ ┌─────────┐
│ cancelled │ │ noshow │ │inprogress│ │ cancelled │ │ noshow │ │inprogress│
└───────────┘ └─────────┘ └────┬────┘ └───────────┘ └─────────┘ └────┬────┘
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ done │ │ done │
└─────────┘ └─────────┘Valid Transitions
| From | To | Trigger | Who | Notes |
|---|---|---|---|---|
booked | upcoming | Patient onboarded (forms generated, videocall created) | Admin, Specialist, System | Primary onboarding path |
booked | cancelled | Cancel before onboarding | Admin, System | |
upcoming | confirmed | Patient confirms attendance | Patient, Admin | |
upcoming | inprogress | Specialist starts session (skip confirmation) | Specialist, Admin | |
upcoming | cancelled | Cancel appointment | Patient, Specialist, Admin | |
upcoming | noshow | Mark no-show after start time passes | Specialist, Admin, Auto | Auto after 30min grace |
confirmed | inprogress | Specialist starts session | Specialist, Admin | |
confirmed | cancelled | Cancel appointment | Patient, Specialist, Admin | |
confirmed | noshow | Mark no-show after start time passes | Specialist, Admin, Auto | Auto after 30min grace |
confirmed | upcoming | Un-confirm (edge case: patient retracted) | Admin | Rare |
inprogress | done | Specialist completes session | Specialist, Admin | |
inprogress | cancelled | Cancel mid-session (rare) | Admin only | Emergency only |
cancelled | upcoming | Reinstate cancelled appointment | Admin only | |
noshow | upcoming | Reinstate no-show appointment | Admin only |
Invalid Transitions (Blocked)
| From | To | Reason |
|---|---|---|
done | any | Completed appointments are final. Forms may be signed. |
noshow | any except upcoming | No-show is final. Can only reinstate to upcoming by admin. |
inprogress | upcoming | Can't go backwards from active session |
inprogress | confirmed | Can't go backwards from active session |
inprogress | noshow | Patient is present (session started) |
booked | confirmed | Must onboard first (booked → upcoming → confirmed) |
booked | inprogress | Must onboard first |
Side Effects Per Transition
Each status transition triggers domain-specific side effects:
| Transition | Side Effects |
|---|---|
booked → upcoming | Onboard patient (find/create user + patient record). Generate forms from appointment type config. Create Daily.co room. |
booked → cancelled | Clear rate limit for the booking client. Emit appointment.cancelled webhook. |
* → cancelled | Delete Daily.co room. Emit appointment.cancelled webhook. |
cancelled → upcoming | Recreate Daily.co room. |
* → noshow | Delete Daily.co room (if not already). Mark in audit log. |
noshow → upcoming | Recreate Daily.co room. |
upcoming → inprogress | Log session start time. |
inprogress → done | Log session end time. Check if all required forms are completed/signed (warn if not). |
Implementation Reference
Transition Validation
// internal/domain/appointment/state.go
type StatusTransition struct {
From AppointmentStatus
To AppointmentStatus
Roles []string // Roles that can trigger this transition
Auto bool // Can be triggered automatically by the system
}
var validTransitions = []StatusTransition{
// From booked (new: public booking, pre-patient)
{From: "booked", To: "upcoming", Roles: []string{"specialist", "admin", "superadmin"}, Auto: true},
{From: "booked", To: "cancelled", Roles: []string{"admin", "superadmin"}, Auto: true},
// From upcoming
{From: "upcoming", To: "confirmed", Roles: []string{"patient", "admin", "superadmin"}},
{From: "upcoming", To: "inprogress", Roles: []string{"specialist", "admin", "superadmin"}},
{From: "upcoming", To: "cancelled", Roles: []string{"patient", "specialist", "admin", "superadmin"}},
{From: "upcoming", To: "noshow", Roles: []string{"specialist", "admin", "superadmin"}, Auto: true},
// From confirmed
{From: "confirmed", To: "inprogress", Roles: []string{"specialist", "admin", "superadmin"}},
{From: "confirmed", To: "cancelled", Roles: []string{"patient", "specialist", "admin", "superadmin"}},
{From: "confirmed", To: "noshow", Roles: []string{"specialist", "admin", "superadmin"}, Auto: true},
{From: "confirmed", To: "upcoming", Roles: []string{"admin", "superadmin"}},
// From inprogress
{From: "inprogress", To: "done", Roles: []string{"specialist", "admin", "superadmin"}},
{From: "inprogress", To: "cancelled", Roles: []string{"admin", "superadmin"}}, // Admin-only emergency cancel
// From cancelled (reinstate)
{From: "cancelled", To: "upcoming", Roles: []string{"admin", "superadmin"}},
// From noshow (reinstate)
{From: "noshow", To: "upcoming", Roles: []string{"admin", "superadmin"}},
}
func CanTransition(from, to AppointmentStatus, role string) bool {
for _, t := range validTransitions {
if t.From == from && t.To == to {
if slices.Contains(t.Roles, role) || t.Auto {
return true
}
}
}
return false
}
func ValidateTransition(from, to AppointmentStatus, role string) error {
if from == to {
return nil // No-op, not an error
}
if !CanTransition(from, to, role) {
return &InvalidTransitionError{
From: from,
To: to,
Role: role,
Message: fmt.Sprintf("cannot transition from %q to %q as %q", from, to, role),
}
}
return nil
}Error Response
{
"status": 400,
"name": "InvalidTransitionError",
"message": "cannot transition from \"done\" to \"upcoming\" as \"specialist\"",
"details": {
"from": "done",
"to": "upcoming",
"role": "specialist"
}
}Booking Flow
┌─────────────┐
│ View Slots │ GET /v1/appointment-types/{id}/timeslots
└──────┬──────┘ Returns available slots across all specialists (pooled)
│
▼
┌─────────────┐
│ Create Hold │ POST /v1/holds
└──────┬──────┘ 30-sec TTL, heartbeat every 20s via PATCH /v1/holds
│ System auto-selects highest priority specialist
│ (or direct specialist selection via specialistId param)
▼
┌─────────────┐
│ Confirm │ POST /v1/appointment-types/{id}/book
│ Booking │ Converts hold → appointment (status: booked)
└──────┬──────┘ Contact info stored, no patient account yet
│
▼
┌─────────────┐
│ Appointment │ Status: "booked"
│ Created │ Has: startDate, endDate, contactName, contactEmail,
└─────────────┘ specialistId, appointmentTypeId, patient_id: NULLPatient Onboarding (booked → upcoming)
The booked → upcoming transition is the bridge between the public booking flow and the clinical workflow.
Endpoint: POST /v1/appointments/{id}/onboard
Flow:
1. Load appointment (must be status "booked")
2. Find or create user by contact_email
└── If user exists: verify org membership, find/create patient record
└── If new: create user + patient record, add to org
3. Link patient to appointment (SET patient_id)
4. Generate forms from appointment type config
└── Read appointment_type_forms for this type
└── For each form_template: create form instance
└── Auto-fill patient profile values
5. Create videocall room
└── Daily.co room: restartix-{orgID}-{appointmentID}
6. Transition status: booked → upcoming
7. Return complete appointment with formsTriggers:
- Manual: Admin/specialist clicks "Onboard" in dashboard
- Automatic: Webhook or automation (e.g., after payment confirmation)
Automatic Transitions
Auto No-Show
If an appointment is upcoming or confirmed and the start time has passed by a configurable threshold, the system automatically marks it as noshow.
Configuration:
- Grace period: 30 minutes (configurable per organization)
- Job frequency: Every 15 minutes
- Only affects
upcomingandconfirmedappointments (notbooked)
Implementation:
// internal/jobs/auto_noshow.go
// Runs every 15 minutes via cron
const noShowGracePeriod = 30 * time.Minute
func (j *AutoNoShowJob) Run(ctx context.Context) error {
cutoff := time.Now().Add(-noShowGracePeriod)
// Find appointments that started > 30 minutes ago and are still upcoming/confirmed
appointments, err := j.repo.FindByStatusAndStartedBefore(ctx,
[]string{"upcoming", "confirmed"},
cutoff,
)
if err != nil {
return err
}
for _, appt := range appointments {
err := j.service.TransitionStatus(ctx, appt.ID, "noshow")
if err != nil {
j.logger.Error("auto-noshow failed", "appointment_id", appt.ID, "error", err)
continue
}
j.logger.Info("auto-noshow applied", "appointment_id", appt.ID)
}
return nil
}Override: Specialists can manually override by transitioning to inprogress or done if the patient arrived late.
Appointment Creation Methods
| Method | Endpoint | Input | Who | Result Status |
|---|---|---|---|---|
| Public Booking | POST /v1/appointment-types/{id}/book | holdId, contactName, contactEmail, contactPhone | Public (no auth) | booked |
| Admin/Specialist Create | POST /v1/appointments | patient_id, specialist_id, started_at, appointment_type_id | Admin, Specialist | upcoming |
| Attach Forms | POST /v1/appointments/{id}/attach-forms | appointment_type_id on existing appointment | Admin, Specialist | unchanged |
Public Booking is the primary path for patient-initiated bookings. Creates an appointment in booked status with contact info only.
Admin/Specialist Create is used when staff creates appointments manually. Patient is already known, so it starts at upcoming.
Reschedule Flow
Endpoint: PUT /v1/appointments/{id}/reschedule
Request:
{
"started_at": "2025-02-20T14:00:00Z"
}Logic:
- Load appointment
- Validate: status must be
booked,upcoming, orconfirmed - Calculate new
ended_atfrom appointment type's slot duration - Update appointment dates
- Update Daily.co room expiration (if exists)
- Audit log
- Return updated appointment
Allowed roles:
- Patient (own appointments, only
upcoming/confirmed) - Specialist (own appointments)
- Admin, Superadmin
Restriction: Cannot reschedule to a past date. Validation: started_at > NOW().
Cancel Flow
Endpoint: POST /v1/appointments/{id}/cancel
Logic:
- Load appointment
- Validate: transition from current status to
cancelledis allowed - Execute side effects (videocall delete, rate limit clear, notification)
- Update status
- Audit log
- Return updated appointment
Cancellation policy (configurable per org):
- Patients can cancel up to 24 hours before start (default)
- Specialists and admins can cancel at any time
- Late cancellations (< 24 hours) are logged with a flag
bookedappointments can be cancelled by admin at any time (no patient to notify)
func (s *AppointmentService) validateCancellation(ctx context.Context, appt *Appointment, role string) error {
if role == "admin" || role == "superadmin" {
return nil // Admins can always cancel
}
if appt.Status == "booked" {
return nil // Booked appointments can always be cancelled (no patient yet)
}
if role == "patient" && appt.StartedAt != nil {
hoursUntilStart := time.Until(*appt.StartedAt).Hours()
if hoursUntilStart < 24 {
s.logger.Info("late cancellation by patient",
"appointment_id", appt.ID,
"hours_until_start", hoursUntilStart,
)
}
}
return nil
}Timezone Handling
All times stored in UTC. No timezone columns on the appointments table.
| Layer | Timezone |
|---|---|
| Specialist scheduling profile | scheduling_timezone — IANA timezone for weekly hours/overrides |
| Availability engine | Converts specialist's local wall-clock times to UTC intervals |
| Appointment table | UTC only (TIMESTAMPTZ) |
| API responses | UTC (ISO 8601) |
| Frontend | Converts to user's local timezone for display |
The specialist's scheduling_timezone is used only by the availability engine for:
- Converting weekly hours (local time) to UTC slots
- Displaying provider availability in their local time
- Calculating slot boundaries around DST transitions
Duration Calculation
| Source | Method |
|---|---|
| From public booking | appointment_type.slot_duration_minutes → ended_at = started_at + duration |
| Manual creation | Admin provides both started_at and ended_at directly |
| Reschedule | Preserves original duration: new_ended_at = new_started_at + (old_ended_at - old_started_at) |
func calculateEndTime(startedAt time.Time, durationMinutes int) time.Time {
return startedAt.Add(time.Duration(durationMinutes) * time.Minute)
}
func preserveDuration(oldStart, oldEnd, newStart time.Time) time.Time {
duration := oldEnd.Sub(oldStart)
return newStart.Add(duration)
}Complete State Machine Summary
Public Booking Page Core API (Go)
┌─────────────────────┐ ┌──────────────────────────────────────────┐
│ │ │ │
│ View Timeslots │ │ │
│ Create Hold │ │ │
│ Confirm Booking │ │ │
│ │ │ book │ │
│ ▼ │ ───────► │ ┌──────────┐ │
│ │ │ │ booked │ (contact info only) │
│ │ │ └─────┬─────┘ │
└─────────────────────┘ │ │ onboard │
│ ▼ │
│ ┌───────────┐ │
│ │ upcoming │──── confirm ───► confirmed│
│ └─────┬─────┘ │ │
│ ┌────┼────┐ ┌────┼────┐ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ cancel noshow inprogress cancel noshow inprogress│
│ │ │ │
│ ▼ ▼ │
│ done done │
└──────────────────────────────────────────┘