
Booking Prototype
An appointment-booking prototype I built for a barbershop. Not a client project — I built it to try out where appointment conflicts should be prevented, and moved the rule out of the application layer into a Postgres exclusion constraint.
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.
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.
Single developer, the whole prototype. Data model and constraints, availability calculation, booking and cancellation flows, admin panel, notification layer.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Tell me what you want to build; I'll tell you up front how long it takes and where to start.