Skip to content

Appointment Lifecycle & State Machine

THE appointments TABLE DOES NOT EXIST — reconciled 2026-08-02

Verified against schema 000039: no appointments table, no domain, no routes, zero appointments.* permission rows. Ships in migration 000046. See index.md for the full correction list — the load-bearing ones: offering_id, not service_id; no users table / no user_id; patient_profiles, not patient_persons; UUID PKs; nine statuses with cancelled split three ways; no deleted_at; and patient_service_plan_id / plan_session_number are removed because the plans they reference are deferred.

The state machine is new construction, not a port — the legacy system enforces no transitions at all. Two things this page must be read against: the status enum is nine values (cancelled splits into cancelled_by_patient / cancelled_by_clinic / cancelled_late, because clinic-attributable cancellations are excluded from the adherence denominator), and noshow → inprogress is an unresolved open decision — see the Open Decisions section in index.md. Getting it wrong silently corrupts adherence for patients who actually attended.

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

The substrate uses a TEXT NOT NULL column with a CHECK constraint (per the canonical schema in architecture/appointments-substrate.md); the enumerated values below are the authoritative set:

ValueCadence-denominator effectDescription
bookedexcluded (future / pre-onboarding)Public booking confirmed, no patient account yet
upcomingexcluded (future)Patient onboarded, forms + videocall room ready
confirmedexcluded (future)Patient confirmed attendance
inprogressexcluded (in-flight)Specialist has started the session
donecounts (numerator + denominator)Appointment completed
noshowcounts (denominator only)Patient didn't attend
cancelled_by_patientcounts (denominator only)Patient cancelled with reasonable notice
cancelled_latecounts (denominator only)Patient cancelled too late to rebook (threshold-derived)
cancelled_by_clinicexcludedClinic cancelled — patient isn't penalised for capacity shortfalls

The split of the previous single cancelled value into three values (cancelled_by_patient, cancelled_by_clinic, cancelled_late) is load-bearing for fair adherence under the cadence & supervision design: a clinic-attributable cancellation must not drop the patient's adherence ratio. The cancel endpoint receives the attribution from the caller (patient self-service flow → _patient; clinic admin flow → _clinic) and computes cancelled_late automatically from (scheduled_at - now) < late_threshold when the attribution is patient.

Status Labels (Romanian)

StatusRomanian Label
bookedRezervată
upcomingProgramată
confirmedConfirmată
inprogressÎn lucru
doneRealizată
noshowNeprezentare
cancelled_by_patientAnulată de pacient
cancelled_lateAnulată târziu
cancelled_by_clinicAnulată de clinică

State Transition Diagram

                                                                   ┌──────────┐
                                                                   │          │
                                                                   ▼          │
  Public      ┌────────┐  Onboard    ┌──────────┐                ┌─────────┐  │
  Booking ───▶│ booked │───────────▶│ upcoming  │── confirm ───▶│confirmed │  │
              └───┬────┘            └─────┬─────┘                └────┬─────┘  │
                  │                       │                            │       │  un-confirm
                  ▼                       ▼                            ▼       │   (rare)
            ┌──────────┐         ┌──────────────┐              ┌──────────────┐
            │cancelled_│         │ cancelled_*  │              │ cancelled_*  │
            │   by_*   │         │ noshow       │              │ noshow       │
            └──────────┘         │ inprogress ──┼─── done ────▶│ inprogress ──┼─── done
                                 └──────────────┘              └──────────────┘

cancelled_* = cancelled_by_patient | cancelled_by_clinic | cancelled_late

Valid Transitions

Below, cancelled_* collapses the three cancellation values (cancelled_by_patient, cancelled_by_clinic, cancelled_late); transition rules apply to all three uniformly, but the cancel endpoint picks the specific value based on attribution + timing (see substrate spec).

FromToTriggerWhoNotes
bookedupcomingPatient onboarded (forms generated, videocall created)Admin, Specialist, SystemPrimary onboarding path
bookedcancelled_*Cancel before onboardingAdmin, Patient self-cancel, System
upcomingconfirmedPatient confirms attendancePatient, Admin
upcominginprogressSpecialist starts session (skip confirmation)Specialist, Admin
upcomingcancelled_*Cancel appointmentPatient, Specialist, AdminAttribution picks the specific value
upcomingnoshowMark no-show after start time passesSpecialist, Admin, AutoAuto after 30min grace
confirmedinprogressSpecialist starts sessionSpecialist, Admin
confirmedcancelled_*Cancel appointmentPatient, Specialist, AdminAttribution picks the specific value
confirmednoshowMark no-show after start time passesSpecialist, Admin, AutoAuto after 30min grace
confirmedupcomingUn-confirm (edge case: patient retracted)AdminRare
inprogressdoneSpecialist completes sessionSpecialist, Admin
inprogresscancelled_by_clinicCancel mid-session (rare)Admin onlyEmergency only; always clinic-attributable in this case

Terminal: done, noshow, and any cancelled_* are terminal — no transitions out. Reinstating a cancelled or no-show appointment creates a new appointment row rather than reviving the terminal one (cleaner audit trail than re-opening a terminal record).


Invalid Transitions (Blocked)

FromToReason
doneanyCompleted appointments are final. Forms may be signed.
noshow / cancelled_*anyTerminal. To "reinstate," create a new appointment row.
inprogressupcoming / confirmedCan't go backwards from active session
inprogressnoshowPatient is present (session started)
inprogresscancelled_by_patient / cancelled_lateMid-session cancels are always clinic-attributable (medical emergency, equipment failure, etc.)
bookedconfirmed / inprogressMust onboard first (booked → upcoming → ...)

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 → cancelled_*Clear rate limit for the booking client. Emit appointment.cancelled webhook.
* → cancelled_*Delete Daily.co room. Emit appointment.cancelled webhook. Set cancelled_at, cancelled_by_principal_id, cancellation_reason.
* → noshowDelete Daily.co room (if not already). Mark in audit log.
upcoming → inprogressLog session start time.
inprogress → doneLog session end time. Check if all required forms are completed/signed (warn if not).

Reinstating a cancelled or no-show appointment is not a transition — it creates a new appointment row (see Terminal note in Valid Transitions above), which then goes through the normal booked or upcoming lifecycle.


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
}

// Cancellation attribution is picked by the service layer based on
// the caller's role + (scheduled_at - now) for late detection. The
// transition table here uses `cancelled_*` as a meta-marker; in the
// real Go code each cancelled value is an explicit constant.
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{"patient", "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 (mid-session cancel is always clinic-attributable)
    {From: "inprogress", To: "done",                Roles: []string{"specialist", "admin", "superadmin"}},
    {From: "inprogress", To: "cancelled_by_clinic", Roles: []string{"admin", "superadmin"}},

    // Terminal: `done`, `noshow`, `cancelled_*` — no transitions out.
    // Reinstating creates a new row, not a transition.
}

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 a cancelled_* value is allowed.
  3. Pick the target value: attribution comes from the caller's role + the time delta:
    • Patient self-cancel (no appointments.cancel_any): (scheduled_at - now) < late_thresholdcancelled_late, otherwise → cancelled_by_patient.
    • Clinic-side cancel (admin, specialist with appointments.cancel_any): → cancelled_by_clinic.
    • Mid-session admin cancel (status inprogress): always → cancelled_by_clinic.
  4. Execute side effects (videocall delete, rate limit clear, notification).
  5. Update status, set cancelled_at = NOW(), cancelled_by_principal_id, cancellation_reason.
  6. Audit log.
  7. Return updated appointment.

Cancellation policy (configurable per org):

  • Holders of appointments.cancel_any can cancel at any time (default-granted to the admin role; specialists may also be granted this on a per-org basis).
  • Patients (callers without appointments.cancel_any) can cancel their own appointments at any time, but the resulting status reflects whether the cancellation was late (cancelled_late if within the late threshold, cancelled_by_patient otherwise).
  • The late threshold defaults to 24 hours but is configurable via organization_settings.late_cancellation_hours.
  • booked appointments — created without a patient yet attached — can always be cancelled.
go
// Permissions are the authorization primitive (CLAUDE.md → RBAC). Roles are
// labels; never branch on `userCtx.CurrentRoleCode`. Seed the permission and
// grant it to the right templates.
//
// Returns the target status the caller is allowed to transition into.
// The service layer then writes that value + cancelled_at +
// cancellation_reason in one UPDATE.
func (s *AppointmentService) resolveCancellationStatus(
    ctx context.Context,
    userCtx *auth.UserContext,
    appt *Appointment,
) (string, error) {
    // Mid-session cancel is always clinic-attributable.
    if appt.Status == "inprogress" {
        if !userCtx.HasPermission(auth.PermAppointmentsCancelAny) {
            return "", ErrPermissionDenied
        }
        return "cancelled_by_clinic", nil
    }

    // Privileged caller → clinic attribution by default. (Specialists
    // with cancel_any cancelling a patient's slot are still
    // clinic-attributable — the patient didn't choose to skip.)
    if userCtx.HasPermission(auth.PermAppointmentsCancelAny) {
        return "cancelled_by_clinic", nil
    }

    // Self-service patient cancellation. `cancelled_late` if inside
    // the org's late-cancellation threshold (configurable via
    // organization_settings.late_cancellation_hours; default 24h).
    threshold := s.orgLateCancellationThreshold(ctx, appt.OrganizationID)
    if appt.ScheduledAt.Sub(time.Now()) < threshold {
        return "cancelled_late", nil
    }
    return "cancelled_by_patient", 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                         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 │
                                  └──────────────────────────────────────────┘