Turnia: a booking SaaS for barbershops, in three parts
Landing page, management app and Telegram bot on one database
A fictional portfolio product with real code and deployment: a Next.js landing page, a drag-and-drop calendar with ⌘K on Postgres, and a Telegram bot that books and reminds. Everything is live; demo data resets every night.
Context
A barbershop runs on its calendar: overlapping bookings, clients who don't confirm, last-minute changes and the day's till. Turnia is the exercise of solving that as a complete product —sell, operate and serve by chat— without duplicating business rules in three places, and with a public bot anyone can message without taking the server down.
Solution
One Next.js 16 app with Drizzle on Postgres (Neon) does all the work: per-staff calendar (create in a free slot, drag, resize, ⌘K that creates bookings from text, undo), clients, memberships and the daily till; a public booking link with real availability.
The bot is a pure state machine inside the app; n8n is only the channel between Telegram and the API.


Architecture
The client messages the bot on Telegram
Caddy only lets through Telegram's subnets and bodies up to 64 KB
n8n normalizes the update (global cap of 60 per minute) and forwards it to the API with a key
The app runs the state machine and replies with text and buttons (30 per chat per hour)
The booking is saved in Postgres and shows up on the calendar
An n8n cron fetches the 24 h and 2 h reminders; the 24 h one has “Confirm” / “Change”
Components
- Next.js 16 on Vercel: landing,
/app/*app, public booking and/api/bot/*API - Postgres on Neon with Drizzle ORM (serverless driver in production)
- n8n: bot, reminders, public booking and waitlist flows
- Caddy in front of n8n, filtering Telegram's subnets
- Vercel cron to reset the demo
Technical decisions
The brain lives in the app; n8n is the channel
Why: availability, overlap and till rules exist once and are shared by the calendar, the public booking page and the bot; moving from Telegram to WhatsApp means swapping two nodes.
Trade-off the app has to expose and maintain its own key-protected API.
Three layers of defense for a public bot
Edge: Caddy returns 403 outside Telegram's subnets. n8n: a global cap of 60 updates per minute. App: 30 messages per chat per hour and 200 per location per day, answered with 429.
Trade-off if Telegram adds IP ranges, the bot stops answering until the filter is updated.
Idempotent reminders
When listing, the API creates the message as “pending” and never repeats it for the same booking. Why: the cron can run twice without sending duplicates.
Trade-off one more state table in Postgres.
A public demo that resets itself
A daily Vercel cron (07:00 UTC) calls an endpoint with
Bearer CRON_SECRETand restores only the demo location to its seed. Why: anyone can log in and edit without leaving it broken.Trade-off whatever a visitor does is wiped that night.
Overlap rules on the server, optimistic UI
Dragging moves the booking instantly; if the server detects an overlap, the card snaps back with a notice.
Trade-off the client has to maintain the revert logic.
Results
- Landing, app and bot live (HTTP 200 on 2-Oct-2026)
- No layout clipping across 7 widths, 320 to 2560 px
- 0 console errors and warnings; 0 external requests
- Green build, clean tsc and eslint
- Automated bot test: 401, 400, full booking, non-duplicated reminder, 409 for a foreign chat, and both limits (31 real calls)
Stack
- Next.js 16
- React 19
- TypeScript
- Tailwind 4
- Drizzle ORM
- Postgres (Neon)
- Server Actions
- n8n
- Telegram Bot API
- Caddy
- Vercel


