Skip to main content
Back to Projects
Ustura logo

Ustura

Booking Prototype

Ustura booking prototype diagram Ustura — randevu prototipi Çakışma kuralı uygulamada değil, veritabanında Müşteri projesi değil; kuralın nereye konduğunu denemek için yazılmış bir prototip. Müşteri — web uygun saati seçer Europe/Istanbul görür Personel — panel manuel randevu, blok, izin aynı anda yazabilir Server Action + Zod girdi doğrulama · UTC'ye çevirme Postgres EXCLUDE USING gist staff_id WITH = tstzrange(starts_at, ends_at) WITH && WHERE status = 'confirmed' Çakışan ikinci yazma reddedilir hangi yoldan gelirse gelsin Kuralın yeri neden veritabanı Uygulama katmanındaki "önce oku, boşsa yaz" kontrolü iki eşzamanlı istek arasında yarış bırakır. Kısıt veritabanında durduğunda web formu, personel paneli, seed script'i ve elle atılan SQL aynı kurala tabidir. Zaman: UTC sakla, yerelde göster timestamptz + tstzrange görüntüleme Europe/Istanbul Env: Zod ile derleme anında zorunlu değişken eksikse build düşer opsiyonel entegrasyon sessizce kapalı Bildirim: e-posta ve web push anahtar yoksa özellik kapanır uygulama yine ayağa kalkar
The overlap rule lives in the database, not in the application. Both the customer web form and the staff panel go through the same Server Action plus Zod path, input is converted to UTC and written to Postgres. An EXCLUDE USING gist constraint rejects a second overlapping confirmed booking for the same staff member no matter where it comes from — seed scripts and hand-written SQL included. Time is stored as timestamptz and displayed in Europe/Istanbul.

An appointment-booking prototype I built for a barbershop. Not a client project: an experiment in making the database itself, rather than staff attention, stop the same slot from being given to two customers.

Technologies Used

  • Next.js 16
  • TypeScript
  • Drizzle ORM
  • Neon Postgres
  • Auth.js
  • Zod
  • date-fns-tz
  • Web Push (VAPID)

Key Features

  • Appointment overlap is impossible at the database level — no check in the application layer
  • Time is stored in UTC and displayed in Europe/Istanbul
  • Availability calculation is a pure function — tested without touching the database
  • Environment variables are validated at build time with Zod
  • Staff, services, working hours, breaks and blocks are managed from the panel
  • Customers can cancel through a single-use link

Development Time

1 month

Problem

Booking systems break at one point: two people take the same slot with the same staff member at the same time. This is not a bug you discover by writing scenarios; it emerges from two requests interleaving and never shows up when tested with a single user. The common solution is "read first, write if free" — and that solution is wrong, because in the window between the read and the write another request can take the same slot. The second issue is time: a booking system has to handle daylight-saving transitions, the user's device timezone and the server's timezone correctly at the same time. This project is not client work; it is a prototype I wrote to try out where both problems should be placed.

Users

Two parties. The customer: arrives on a phone, picks a service, sees available times and books without creating an account; if they later want to cancel, they need a link in hand. The business: manages staff, service durations, working hours, breaks and closed days from the panel; follows the day in a calendar view and can add appointments manually.

My Role

Single developer, the whole prototype. Data model and constraints, availability calculation, booking and cancellation flows, admin panel, notification layer.

Architecture

Next.js 16 App Router, Neon Postgres and Drizzle. The write path is Server Action → Zod → transaction. The decision at the centre of the system lives in the data model: the `appointments` table carries an exclusion constraint over the staff identifier and the appointment range; the constraint applies only to confirmed appointments, so a cancelled record does not hold a slot. The availability calculation is kept entirely apart from the database: working hours, breaks, staff appointments, shop blocks, slot step, minimum notice and booking window come in as input, and a list of available start times comes out. Because the function is pure, its tests run without bringing up a database. On the time side there is one rule: everything is stored as a timezone-aware timestamp in UTC and converted to Europe/Istanbul in the interface; day boundaries are computed against the local day, not the server's timezone.

Key Decisions

  1. The overlap guard lives in the database, not the application

    The rule is defined as an exclusion constraint: two intersecting time ranges cannot exist at once for the same staff member. Because the check happens in the database at write time, the race window between read and write disappears. The real gain is not only concurrency but coverage: the web form, an appointment added manually from the panel, the seed script and a hand-run SQL statement are all subject to the same rule. A check in the application layer only protects its own path.

    Trade-offThe error now arrives as a database constraint violation rather than a validation message; the caller has to catch it and turn it into a meaningful sentence for the user. The constraint also requires an index extension over the range type, and the rule itself lives in a migration file — someone reading the code will not find it where they look for it, in the application layer.

  2. Availability is a pure function

    Availability is where the system has the most edge cases: closed days, breaks, per-staff occupancy, times already past, minimum notice and the end of the booking window. Burying those in a database query would have made every scenario testable only with real data. The calculation was pulled out as input and output; tests set up a scenario in a single call.

    Trade-offCarrying the ranges read from the database into the function correctly is the caller's responsibility; the function cannot tell that it was given incomplete data, it only computes with what it gets. So even though the calculation is tested, the fact that it is called with the right input has to be verified separately.

  3. The timezone decision was reduced to one sentence

    Store UTC, display Europe/Istanbul. Appointment ranges are written as timezone-aware timestamps, day boundaries are computed against the local day, and display goes through one fixed zone. Keeping the rule to a single sentence stops every new screen from inventing its own conversion.

    Trade-offA fixed display zone is not enough if the business starts operating in another zone; today the zone is a constant, not a business setting. Tests also have to state the offset explicitly, otherwise a test becomes dependent on the timezone of the machine it runs on.

  4. Environment variables are validated at build time

    Because the configuration schema is imported from the config file, a broken environment fails at build or start rather than at runtime. Required variables are only those essential for the application to come up; features such as email, reminders and notifications are optional and stay off until their keys are supplied. That way a production build can be made without any secrets.

    Trade-offA missing optional key silently turns a feature off; a wrongly entered key and a key never entered look identical from the outside. There is also an escape hatch for skipping validation, and leaving that switch on in production removes the guarantee the schema provides.

Stack Rationale

Postgres is a requirement rather than a preference here: exclusion constraints and range types sit at the centre of this decision, and the same guarantee cannot be built as cheaply on another database. Drizzle was chosen because it lets the constraint be written as plain SQL in a migration file — the rule does not disappear behind an ORM abstraction, it can be read. date-fns-tz collects the conversion between local day and timezone-aware timestamp in one place. Auth.js is used only on the admin side; the customer flow has no account, and cancellation happens through a single-use link.

Security

The admin panel is session-protected; there is no account on the customer side, so the identity data stored is minimal too. Cancellation goes through a link carrying an unguessable single-use token — the appointment identifier never appears in the address bar. The consent text is stored on the record: who consented, when, and to which text does not stay open to argument afterwards, and this is protected by a separate test. Write paths run inside a single transaction; a test written for the concurrent booking case confirms that the second request is stopped by the database constraint.

Performance

This is a prototype with no published deployment, and because there is no date-stamped Lighthouse measurement, no page-speed score is written here. The measurable side is the availability calculation: a single query is sent to the database for that day's appointments and blocks, and slot generation happens in memory. As a result, moving between days in the calendar does not produce a new round of queries.

Results

Active development
2026-07 · 13 commits
TypeScript source files
133
Test files
8 — availability, dates, phone, local time, concurrent booking, cancellation, rescheduling, consent
Schema
9 tables · 4 migrations
Routes
16 pages — site and admin panel
Architectural guarantee
Two intersecting confirmed appointments for the same staff member are impossible at the database level
Architectural guarantee
If a required environment variable is missing, the production build fails

What I Would Do Differently

The real output of this prototype is not code but a habit: asking whether a rule belongs in the application layer or in the database. I carried the answer here into later work — in Bergaz Food I reduced payment callback idempotency to a uniqueness constraint, and in Bergaz Operations duplicate sample checks to a partial unique index. There is evidence in the opposite direction too: in Taxilos, because the authorisation gate was spread across functions, a single predicate falling into three-valued logic opened 45 gates at once. What I would do differently here is the state side: an appointment carries a state machine and the permitted transitions are still scattered through the code; I would reduce those to a single definition too, exactly as I did with the overlap rule.

Prototype

Let's find where to start

Tell me what you want to build; I'll tell you up front how long it takes and what the sensible first step is.