Poltrona
Cloud management for hair salons and barbershops.
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.
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)
);