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:
| Value | Cadence-denominator effect | Description |
|---|---|---|
booked | excluded (future / pre-onboarding) | Public booking confirmed, no patient account yet |
upcoming | excluded (future) | Patient onboarded, forms + videocall room ready |
confirmed | excluded (future) | Patient confirmed attendance |
inprogress | excluded (in-flight) | Specialist has started the session |
done | counts (numerator + denominator) | Appointment completed |
noshow | counts (denominator only) | Patient didn't attend |
cancelled_by_patient | counts (denominator only) | Patient cancelled with reasonable notice |
cancelled_late | counts (denominator only) | Patient cancelled too late to rebook (threshold-derived) |
cancelled_by_clinic | excluded | Clinic 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)
| Status | Romanian Label |
|---|---|
booked | Rezervată |
upcoming | Programată |
confirmed | Confirmată |
inprogress | În lucru |
done | Realizată |
noshow | Neprezentare |
cancelled_by_patient | Anulată de pacient |
cancelled_late | Anulată târziu |
cancelled_by_clinic | Anulată 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_lateValid 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).
| 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, Patient self-cancel, System | |
upcoming | confirmed | Patient confirms attendance | Patient, Admin | |
upcoming | inprogress | Specialist starts session (skip confirmation) | Specialist, Admin | |
upcoming | cancelled_* | Cancel appointment | Patient, Specialist, Admin | Attribution picks the specific value |
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 | Attribution picks the specific value |
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_by_clinic | Cancel mid-session (rare) | Admin only | Emergency 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)
| From | To | Reason |
|---|---|---|
done | any | Completed appointments are final. Forms may be signed. |
noshow / cancelled_* | any | Terminal. To "reinstate," create a new appointment row. |
inprogress | upcoming / confirmed | Can't go backwards from active session |
inprogress | noshow | Patient is present (session started) |
inprogress | cancelled_by_patient / cancelled_late | Mid-session cancels are always clinic-attributable (medical emergency, equipment failure, etc.) |
booked | confirmed / inprogress | Must onboard first (booked → upcoming → ...) |
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. Set cancelled_at, cancelled_by_principal_id, cancellation_reason. |
* → noshow | Delete Daily.co room (if not already). Mark in audit log. |
upcoming → inprogress | Log session start time. |
inprogress → done | Log 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
// 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
{
"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 a
cancelled_*value is allowed. - 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_threshold→cancelled_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.
- Patient self-cancel (no
- Execute side effects (videocall delete, rate limit clear, notification).
- Update status, set
cancelled_at = NOW(),cancelled_by_principal_id,cancellation_reason. - Audit log.
- Return updated appointment.
Cancellation policy (configurable per org):
- Holders of
appointments.cancel_anycan 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_lateif within the late threshold,cancelled_by_patientotherwise). - The late threshold defaults to 24 hours but is configurable via
organization_settings.late_cancellation_hours. bookedappointments — created without a patient yet attached — can always be cancelled.
// 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.
| 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 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 │
└──────────────────────────────────────────┘