Skip to content
All work

Poltrona

Cloud management for hair salons and barbershops.

Open the live demo

Source available on request

The problem

Salons book around processing times: while a colour sets, the stylist is free to take another client, and generic booking tools can’t model that. Salons also hold health data about their clients, like allergies and patch tests, and they won’t switch software unless they can bring thousands of clients with them.

Status
Pre-launch, closed beta next
Stack
Next.js 16, React 19, TypeScript, Tailwind 4, Supabase, PostgreSQL, Edge Functions, Resend, Vercel
Facts
117 routes
77 migrations, about 67 tables
About 630 tenant-isolation checks

Decisions worth explaining

The database refuses a double booking

Each appointment is split into one row per phase, each with a time range. A gist exclusion constraint applies only to phases where the stylist is actually busy, so a client can be booked during someone else’s processing time, and two tablets at the front desk can never book the same stylist twice.

Health notes live in a locked table

The sensitive-notes table has row level security on and no policies at all, so nothing can read it directly. The only way in is a function that checks consent and writes every read to an audit log. Not even an admin with support access can see those notes.

Every write is a Postgres function, and a test proves the walls hold

Tables have no write policies. Each action is one security-definer function that checks the caller itself, and creating a salon locks the profile row so a double submit can’t create two. A 4,000-line SQL suite runs inside a transaction that is always rolled back and checks that no table leaks across salons.

A piece of the code

The overlap rule is a constraint, so no code path can skip it.

supabase/migrations/…_agenda.sql
create table public.appointment_segments (
  id uuid primary key default gen_random_uuid(),
  location_id uuid not null references public.locations (id) on delete cascade,
  appointment_id uuid not null references public.appointments (id) on delete cascade,
  item_id uuid not null references public.appointment_items (id) on delete cascade,
  staff_member_id uuid not null references public.staff_members (id),
  time_range tstzrange not null check (not isempty(time_range)),
  operator_busy boolean not null,
  is_active boolean not null default true,
  -- No double booking of an operator's work (PRD "Vincoli chiave").
  constraint appointment_segments_no_overlap exclude using gist (
    staff_member_id with =,
    time_range with &&
  ) where (operator_busy and is_active)
);
Next projectCorsia