Skip to content

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

sql
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)

StatusRomanian LabelDescription
bookedRezervatăBooking confirmed from public page. Contact info only — no patient account.
upcomingProgramatăPatient onboarded. Forms generated, videocall room created.
confirmedConfirmatăPatient acknowledged and confirmed they will attend
inprogressÎn lucruSpecialist has started the appointment session
doneRealizatăAppointment finished, forms completed/signed
cancelledAnulatăAppointment cancelled (by patient, specialist, or admin)
noshowNeprezentarePatient 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

FromToTriggerWhoNotes
bookedupcomingPatient onboarded (forms generated, videocall created)Admin, Specialist, SystemPrimary onboarding path
bookedcancelledCancel before onboardingAdmin, System
upcomingconfirmedPatient confirms attendancePatient, Admin
upcominginprogressSpecialist starts session (skip confirmation)Specialist, Admin
upcomingcancelledCancel appointmentPatient, Specialist, Admin
upcomingnoshowMark no-show after start time passesSpecialist, Admin, AutoAuto after 30min grace
confirmedinprogressSpecialist starts sessionSpecialist, Admin
confirmedcancelledCancel appointmentPatient, Specialist, Admin
confirmednoshowMark no-show after start time passesSpecialist, Admin, AutoAuto after 30min grace
confirmedupcomingUn-confirm (edge case: patient retracted)AdminRare
inprogressdoneSpecialist completes sessionSpecialist, Admin
inprogresscancelledCancel mid-session (rare)Admin onlyEmergency only
cancelledupcomingReinstate cancelled appointmentAdmin only
noshowupcomingReinstate no-show appointmentAdmin only

Invalid Transitions (Blocked)

FromToReason
doneanyCompleted appointments are final. Forms may be signed.
noshowany except upcomingNo-show is final. Can only reinstate to upcoming by admin.
inprogressupcomingCan't go backwards from active session
inprogressconfirmedCan't go backwards from active session
inprogressnoshowPatient is present (session started)
bookedconfirmedMust onboard first (booked → upcoming → confirmed)
bookedinprogressMust onboard first

Side Effects Per Transition

Each status transition triggers domain-specific side effects:

TransitionSide Effects
booked → upcomingOnboard patient (find/create user + patient record). Generate forms from appointment type config. Create Daily.co room.
booked → cancelledClear rate limit for the booking client. Emit appointment.cancelled webhook.
* → cancelledDelete Daily.co room. Emit appointment.cancelled webhook.
cancelled → upcomingRecreate Daily.co room.
* → noshowDelete Daily.co room (if not already). Mark in audit log.
noshow → upcomingRecreate Daily.co room.
upcoming → inprogressLog session start time.
inprogress → doneLog session end time. Check if all required forms are completed/signed (warn if not).

Implementation Reference

Transition Validation

go
// 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

json
{
  "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: NULL

Patient 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 forms

Triggers:

  • 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 upcoming and confirmed appointments (not booked)

Implementation:

go
// 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

MethodEndpointInputWhoResult Status
Public BookingPOST /v1/appointment-types/{id}/bookholdId, contactName, contactEmail, contactPhonePublic (no auth)booked
Admin/Specialist CreatePOST /v1/appointmentspatient_id, specialist_id, started_at, appointment_type_idAdmin, Specialistupcoming
Attach FormsPOST /v1/appointments/{id}/attach-formsappointment_type_id on existing appointmentAdmin, Specialistunchanged

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:

json
{
  "started_at": "2025-02-20T14:00:00Z"
}

Logic:

  1. Load appointment
  2. Validate: status must be booked, upcoming, or confirmed
  3. Calculate new ended_at from appointment type's slot duration
  4. Update appointment dates
  5. Update Daily.co room expiration (if exists)
  6. Audit log
  7. 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:

  1. Load appointment
  2. Validate: transition from current status to cancelled is allowed
  3. Execute side effects (videocall delete, rate limit clear, notification)
  4. Update status
  5. Audit log
  6. 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
  • booked appointments can be cancelled by admin at any time (no patient to notify)
go
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.

LayerTimezone
Specialist scheduling profilescheduling_timezone — IANA timezone for weekly hours/overrides
Availability engineConverts specialist's local wall-clock times to UTC intervals
Appointment tableUTC only (TIMESTAMPTZ)
API responsesUTC (ISO 8601)
FrontendConverts 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

SourceMethod
From public bookingappointment_type.slot_duration_minutesended_at = started_at + duration
Manual creationAdmin provides both started_at and ended_at directly
ReschedulePreserves original duration: new_ended_at = new_started_at + (old_ended_at - old_started_at)
go
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 │
                                  └──────────────────────────────────────────┘