Nabadi Barbershop

A barbershop's entire operation, in Georgian and English. Customers book a specific barber for a specific service and only ever see slots that barber can actually work. Behind it, a staff console runs the shop: the day's chairs, walk-ins, the service catalogue, each barber's hours and time off, promotions, review moderation, analytics, and a permission matrix that decides which of four roles sees what. We built it end to end — Django 5 + DRF on Postgres, and two React 19 front ends on top.

Year: 2026 · Category: booking

Our role

Full-stack. The data model, the availability engine and the booking constraints, the permission system, and both front ends.

The problem

A barbershop's diary is a paper book behind the counter, and it only works while somebody is standing next to it. A customer who wants a particular barber for a beard sculpt at six on Thursday has to call, wait while the page is found, and take the receptionist's word for it. The shop, meanwhile, has no idea what next week looks like until it arrives.

The failure modes are all the same failure: the diary is a single copy in a single place. Two people are promised the same chair. A barber's day off is remembered by one member of staff and not another. A regular's history — what they had last time, which barber, whether they showed up — exists only in somebody's memory.

The approach

The booking rules belong in the system that holds the bookings, so that is where we put them.

A slot is offered only if the barber works then, is not on time off, is not already booked, and the service fits before closing. That single question is answered by one availability engine, and the customer's calendar, the reception desk's walk-in screen and the admin's day view all ask it — so no two screens can disagree about whether a chair is free. Underneath, a Postgres exclusion constraint makes the overlap physically impossible rather than merely unlikely.

Around that sits the rest of a shop: a service catalogue with per-barber price and duration overrides, working hours that fall back to the shop's, time off that can be one barber's or everyone's, promo codes with use limits, reviews a customer can only leave for an appointment they actually attended, and an audit trail over all of it.

Highlights

  • Double-booking is refused by the database, not by the application: a Postgres exclusion constraint over (barber, time range) means two requests racing for the same chair cannot both win, however the API is called.
  • Availability is computed, not stored. A barber's own hours fall back to the shop's, time off is subtracted — a lunch break is entered as one, so there is no second concept to keep in step — the chosen service's duration decides how wide a slot has to be, and lead time trims the front of today, so the calendar only ever offers a slot the shop can honour.
  • Four roles and one permission table of sixteen keys. Every flag is enforced three times over — a DRF permission class on the endpoint, a React gate on the route, and the sidebar that decides the screen is worth showing. A backend test holds the seeded matrix, the enforcement probes and the key list to each other; the console's own copy of the map is kept in step by a parity checker run over the three.
  • Every staff mutation writes an audit row with a full before/after diff, including a pre-delete snapshot, so 'who changed this and when' has an answer after the row is gone.
  • Bilingual to the content model: services, categories and every landing section store Georgian and English side by side, and an admin edits both in one form.
  • The public site is admin-editable — hero, gallery, opening hours, address, social links and the featured reviews band are data, not a deploy.
  • 564 backend tests and 36 front-end suites holding 180 cases, with the booking constraints and the slot algorithm carrying the heaviest coverage, behind a 78% coverage gate in CI.

Architecture

  • Customer site: A four-step booking wizard — service, barber, day, time — that keeps its state in Zustand so a refresh or a back button does not lose the booking half-made. The landing page, gallery, opening hours and contact details are served from the CMS, so the shop edits its own front page.
  • Staff console: The shop's working surface: a day calendar with a lane per barber, the booking list with its filters and its XLSX export, walk-in entry at the desk, customer histories with private staff notes, review moderation, analytics, and the permission matrix itself.
  • Availability engine: One module answers 'when can this barber do this service'. Per-barber working hours with a shop-hours fallback, barber-specific and shop-wide time off (which is also how a lunch break is entered), service duration and per-barber overrides, minimum lead time and maximum booking horizon — all resolved in Asia/Tbilisi, which is the only timezone the shop has ever cared about.
  • Permissions: Four roles — admin, administrator, barber, customer — over a seeded feature-flag table. The backend enforces it with a DRF permission class, the console gates the route and hides the nav entry, and a parity checker is run over the three to keep them in step, because a permission that is enforced in two places out of three is a bug waiting for a deep link.
  • Backend: Eleven Django apps, 19 models, DRF with explicit per-view permissions, JWT in HttpOnly cookies with rotation and blacklisting, a single error envelope every endpoint speaks, and Celery for the reminder SMS and the sweep that closes yesterday's unfinished appointments.
  • Content and identity: Landing content and site settings are singletons with paired Georgian/English columns; booking policy — slot granularity, lead time, how far ahead you may book, the cancellation window — is configuration the admin edits, not constants we compiled in.

In numbers

Django models: 19 · Backend tests: 564 · Front-end lines: 22k · Languages: 2

Results

  • The chair a customer is offered is one the shop can actually honour — the constraint that prevents a double booking lives in the database, so no code path can get around it.
  • The shop's diary stopped being a single copy in a single place: the desk, the barber and the customer all read the same day.
  • Opening hours, prices, services, promotions and the whole front page became admin actions rather than deploys.
  • Every staff change to a booking, a customer or a price is recoverable from an audit row, including the ones that deleted something.

What is used

  • Backend: Python 3.13, Django 5.2 LTS, Django REST Framework 3.15, SimpleJWT 5.3 (httpOnly cookies, rotation + blacklist), drf-spectacular 0.28, django-cors-headers 4.6, phonenumbers 8.13
  • Data and jobs: PostgreSQL 16 + btree_gist, psycopg 3.2, Redis 7, Celery 5.4, django-celery-beat 2.9, openpyxl 3.1, Pillow 11
  • Front ends: React 19, TypeScript 6, Vite 8, Tailwind CSS 4, TanStack Query 5, Zustand 5 (customer app), Recharts 3 (console), Radix UI, React Hook Form 7 + Zod 3, React Router 7, i18next 23 (KA/EN), date-fns 4
  • Testing and quality: pytest 8.3 + pytest-django 4.9, pytest-cov (78% gate), factory-boy 3.3, Vitest 2 + Testing Library, ruff 0.8 + black 24.10, ESLint 10 + Prettier 3, pre-commit, GitHub Actions
  • Runtime and delivery: Docker Compose, Gunicorn 23, nginx, WhiteNoise 6.8, Sentry, smsoffice.ge SMS gateway, just

The demo

Both front ends, with the Django backend replaced by an in-browser mock: seed data compiled into the bundle, served from memory. The two apps ship as one bundle over one store, so a booking you make on the site is waiting in the console when you switch — the banner in the corner signs you in as a customer or as staff. No seeded row is ever written to storage, so a reload restores the pristine shop.