Appointment SaaS
Multi-tenant scheduling with availability rules, holds, reminders, and calendar sync.
Product overview
Let service businesses publish availability and accept appointments without double booking across tenant rules, time zones, payments, and external calendars.
Main actors
Customer, Practitioner, Tenant admin, Receptionist.
Assumptions
- Each organization is a tenant
- Availability is computed from rules plus exceptions
- External calendars are advisory until synchronized
- All stored instants are UTC with an IANA zone for presentation
Non-goals
- Medical records
- Calendar provider replacement
- Workforce payroll
- Arbitrary resource optimization
Requirements
Must-have
- Tenant-scoped services and staff
- Recurring availability with exceptions
- Race-safe booking
- Cancellation/reschedule policy
- Reminders
Should-have
- Deposits
- Group capacity
- External calendar sync
- Tenant booking page
Future scope
- Waitlists
- Round-robin assignment
- Packages
- Marketplace discovery
Non-functional decisions
| Concern | Decision |
|---|---|
| Consistency | Booking capacity is strongly enforced; reminders and external calendars converge. |
| Tenant isolation | organization_id participates in every ownership query and uniqueness boundary. |
| Timezones | Rules use wall time plus IANA zone; concrete slots are UTC instants. |
| Availability | Existing schedules remain readable if calendar providers fail; sync status is explicit. |
| Privacy | Collect only scheduling details and restrict practitioner notes. |
User roles
| Role | Access boundary |
|---|---|
| Customer | Own appointments via account or signed management link |
| Practitioner | Own schedule and assigned appointments |
| Receptionist | Tenant appointments and customers; limited settings |
| Tenant admin | Membership, services, policies, integrations, audit |
Core workflows
Domain model
| Entities | Relationship |
|---|---|
Organization → Membership | Membership establishes tenant role |
Service → Practitioner | Eligibility and duration define bookable offering |
AvailabilityRule + Exception | Rules generate candidates; exceptions block or add time |
Appointment → ResourceBooking | One appointment reserves practitioner/room capacity |
Appointment → ExternalCalendarLink | Tracks provider version and sync state |
Database design
PostgreSQL remains the transactional source of truth. Add specialized stores only for the workloads called out below.
availability_rules — Recurring local-time schedule
appointments — Confirmed or held booking
notification_jobs — Durable scheduled reminders
Transaction and retention boundaries
- PostgreSQL time-range exclusion constraint is final authority for a practitioner booking
- Reschedule holds new capacity and releases old in one transaction
- Appointment plus outbox commits together
- Hard-delete expired unauthenticated holds; retain appointment/audit according to tenant policy
API design
| Endpoint | Purpose and permission | Idempotency / status |
|---|---|---|
GET /v1/booking/:tenant/services/:id/slots?from=&to= | Return computed candidates Public tenant page | Not required 200, 422 |
POST /v1/appointments/holds | Temporarily reserve a slot Customer or receptionist | Required 201, 409, 422 |
POST /v1/appointments/:id/confirm | Confirm held appointment Hold owner | Required 200, 402, 409, 410 |
POST /v1/appointments/:id/reschedule | Move to a new slot Customer link or tenant staff | Required 200, 409, 422 |
GET /v1/appointments?cursor=… | Tenant schedule ordered by start/id Tenant member | Not required 200, 403 |
List endpoints use opaque keyset cursors unless a small bounded collection is explicit. Unknown objects return 404 where 403 would leak existence.
Architecture
System context
Main application architecture
Critical workflow sequence
Event model
| Event | Producer → consumers | Delivery |
|---|---|---|
AppointmentConfirmed | Scheduling → Reminder, calendar sync | Outbox; consumers key work by appointment/version |
AppointmentRescheduled | Scheduling → Reminder cancellation, calendar sync | New version supersedes stale jobs |
CalendarWebhookReceived | Webhook adapter → Sync worker | Signature checked, provider event id deduped |
Failure modes
Double booking
Failure
Two customers confirm the same slot.
Mitigation
Database exclusion/capacity constraint rejects one transaction.
Hold expires during payment
Failure
Deposit succeeds after slot release.
Mitigation
Use bounded hold and payment intent; reconcile late success into refund or manual resolution, never silent booking.
DST gap
Failure
Recurring 02:30 does not exist on transition day.
Mitigation
Timezone library resolves policy explicitly and tests transition dates.
Calendar echo
Failure
Our update returns as a provider webhook.
Mitigation
Store provider event id and our external event reference; suppress semantic duplicates.
Old reminder fires
Failure
Appointment was rescheduled after job creation.
Mitigation
Worker compares appointment version/status before send.
Signed link forwarded
Failure
Another person manages the booking.
Mitigation
Short-lived, purpose-scoped, revocable token; require account/verification for sensitive details.
Consistency and concurrency
Strong consistency / transactions
- Practitioner/resource capacity
- Hold conversion
- Reschedule
- deposit association
Eventual convergence
- External calendars
- reminders
- availability read cache
- analytics
Concurrency controls
- Time-range exclusion constraint beats application-only slot checks
- Optimistic version protects policy/status updates
- Unique reminder version prevents duplicate send scheduling
- Group bookings lock one capacity counter row
Caching and background work
Safe to cache
- Service catalog
- Availability rule versions
- Short-lived generated slot candidates
Never a cache authority
- Slot validity at confirm
- Tenant membership
- Deposit result
Cache candidate reads briefly by rule version; booking always attempts the database constraint.
Background jobs
- Expire holds
- Send version-checked reminders
- Calendar push/pull sync
- Reconcile deposits
- Apply tenant retention
Security boundaries
Security claims here are design controls, not certification. Threat modeling and jurisdiction-specific review remain required.
- Tenant scope derived from authenticated membership
- Object authorization for appointment and practitioner
- Signed booking links are scoped, expiring, and hashed at rest
- Webhook signature/replay verification
- Escape customer-entered text in notifications
- Rate-limit public slot enumeration and booking
- Keep sensitive notes out of calendar descriptions by default
Observability
Logs
- Hold, confirm, reschedule, and cancel transitions with tenant, appointment version, and policy result
- Timezone/rule version used to generate a selected slot
- Calendar provider event id, echo suppression, and sync outcome
Metrics
- Slot-query latency and confirmation conflict rate
- Hold conversion/expiry and late-deposit resolution count
- Reminder scheduling lateness and send suppression by stale version
- Calendar sync lag, webhook duplication, and provider error rate
Traces
- Trace slot discovery through range-constraint booking and deposit confirmation
- Trace reschedule through new capacity reservation, old release, and provider sync
Alerts
- Reminder queue is late enough to miss customer windows
- Calendar sync lag exceeds tenant promise
- Confirmed appointment count diverges from active capacity
Testing strategy
| Layer | What it proves |
|---|---|
| Unit | Recurrence and DST fixtures, cancellation policy, appointment state machine |
| Integration | PostgreSQL range conflicts, tenant boundaries, outbox |
| Contract | Calendar/provider webhook payloads |
| Concurrency | Two confirms and concurrent reschedule |
| Failure | Late payment, stale reminder, provider outage |
| End-to-end | Discover → hold → confirm → remind → reschedule/cancel |
Do not mock PostgreSQL constraints, transaction isolation, object-store semantics, or provider contract fixtures in the tests intended to prove those boundaries.
Deployment evolution
Scale evolution
These are architecture scenarios, not throughput claims. Each transition should follow measured workload and reliability pressure.
| Scenario | Architecture may evolve toward | Decision rule |
|---|---|---|
| 1K users | One multi-tenant API, PostgreSQL with tenant_id on every row, a reminder worker, a calendar-sync worker | Prevent double booking with an exclusion constraint on staff time ranges; store UTC instants alongside the tenant time zone |
| 100K users | Replicate stateless API; cache candidate slots; isolate reminder and calendar workers | Cache computed availability briefly but recheck the constraint when booking; keep provider rate limits from delaying reminders |
| 1M+ users | Partition tenants into cohorts; route a tenant to one booking writer and provide enterprise isolation where justified | Move large or regulated tenants to dedicated cohorts; one booking writer per tenant keeps slot contention local |