Salon Appointment System
- The customer picks services, a stylist and a date. GET /availability computes working hours minus time off minus busy appointments; no slot table is stored.
- POST /booking/otp/request sends a one-time code by SMS, and POST /booking/otp/verify sets a remembered-phone cookie.
- POST /booking takes the phone from that cookie only, holds an advisory lock on salon and phone, checks the open-request cap, and resolves the least-loaded qualifying stylist.
- The row is inserted and flushed. The exclusion constraint admits it, and the appointment is REQUESTED; the customer sees Pending.
- The assigned stylist is notified by SMS and approves from the dashboard: CONFIRMED, with reminders scheduled.
- The confirmation carries a signed manage link, so the customer can cancel or reschedule without an account.
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
| Layer | Technology |
|---|---|
| Backend | Spring Boot 4.1, Java 21, Spring Web MVC, Spring Data JPA, Spring Security, Bean Validation |
| Database | PostgreSQL 16 with the btree_gist extension; schema owned by Flyway |
| Authentication | Stateless HMAC-signed JWTs in httpOnly cookies — no password storage anywhere |
| Frontend | Next.js 16 (App Router), React 19, TypeScript, next-intl |
| Media pipeline | Cloudflare R2 (S3 API), Thumbnailator, WebP ImageIO |
| Messaging | SMS gateway behind a provider abstraction, with a logging stub for local development |
| Testing | JUnit 5, Mockito, AssertJ, Testcontainers |
| Delivery | Docker 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.