Salon Appointment System

Salon Appointment Systemprivate client project

Turn on JavaScript to run the scenarios.

What it does

A full-stack appointment booking and salon-management system, built for a real client and running in production. Customers book without creating an account — they select services, a stylist and a time, then verify ownership of their phone number with a one-time code. Every booking arrives as a request; the assigned stylist approves or declines it from a dashboard. The system handles all messaging around that lifecycle: confirmations, declines, scheduled reminders, and a cryptographically signed link that lets a customer cancel or reschedule without ever logging in.

How it works

A booking is a request, not an instant confirmation: the assigned stylist keeps the right to decline, so confirmation is a human decision. That one product rule gives the appointment a state machine of seven states — REQUESTED, CONFIRMED, DECLINED, EXPIRED, CANCELLED, COMPLETED and NO_SHOW — where every transition is a conditional UPDATE ... WHERE status = ?, so when two actors race, the one that affects zero rows gets a clean 409.

A customer picks services, a stylist and a date, and availability is computed on demand as working hours minus time off minus busy appointments; there is no slot table. They verify their phone with a one-time code, and POST /booking reads the phone from the verified cookie only, takes an advisory lock on salon and phone, applies the open-request cap, resolves the least-loaded qualifying stylist, snapshots duration and price, and inserts the request. The assigned stylist is notified and approves or declines from the dashboard.

Application-side availability checks are advisory only. The guarantee is a GiST exclusion constraint in PostgreSQL that physically cannot admit two overlapping active appointments for one stylist, pending and confirmed alike. When two customers tap the same slot, both pass the check, one insert commits, and the other fails with SQLSTATE 23P01, translated narrowly into 409 SLOT_CONFLICT.

A reschedule never moves the appointment: it inserts a new REQUESTED row pointing at the original, which stays CONFIRMED and keeps its slot and its reminders. Approving the replacement cancels the original silently. If nobody approves, the expiry worker, running every 60 seconds, expires the request without messaging anyone, and the original booking stands.

Design decisions

  • The database is the arbiter: a GiST exclusion constraint over (staff_id, time range), partial on active statuses, cannot admit two overlapping appointments; application checks only produce good error messages.
  • One product decision — every booking is a request a stylist may decline — shapes a seven-state machine, an expiry worker, a notification for every state change, and the reschedule flow.
  • Rescheduling inserts a replacement request that points at the original, so the original holds its slot until the replacement is approved; a customer can never end up with no appointment.
  • Notifications carry a template identifier plus ordered parameters, never rendered strings — built for WhatsApp Business templates, and proven when an SMS vendor migration left the provider class untouched.
  • Customer photos are identified by their magic bytes and re-encoded to WebP, which strips EXIF location data as a side effect rather than as a step that can be skipped.
  • A startup guard refuses to boot when customer links would still point at localhost, turning a silent, customer-visible outage into a deploy that does not go live.
  • 12,224 lines of tests against 11,610 lines of production code — 384 test methods across 48 classes — with integration tests that race two real threads against a real PostgreSQL 16.

Stack

LayerTechnology
BackendSpring Boot 4.1, Java 21, Spring Web MVC, Spring Data JPA, Spring Security, Bean Validation
DatabasePostgreSQL 16 with the btree_gist extension; schema owned by Flyway
AuthenticationStateless HMAC-signed JWTs in httpOnly cookies — no password storage anywhere
FrontendNext.js 16 (App Router), React 19, TypeScript, next-intl
Media pipelineCloudflare R2 (S3 API), Thumbnailator, WebP ImageIO
MessagingSMS gateway behind a provider abstraction, with a logging stub for local development
TestingJUnit 5, Mockito, AssertJ, Testcontainers
DeliveryDocker multi-stage builds for both services, Docker Compose, GitHub Actions CI

Source

Private repository — built solo for a real client and running in production. The client is not named.