Back to engineering

Architecture

Recurring appointments and the 'admin force-book' rule

The Bookatu engineering team7 min read

How Bookatu books a standing recurring series through the same pipeline as one-off bookings, why admin scheduling force-books while customer booking stays strict, and the one collision the database still refuses.

Standing recurring appointments sound trivial. The owner finishes a booking, the client says "same time every four weeks", and you stamp out the next six visits. The hard part is not the arithmetic. It is deciding who gets to override availability and who does not, and then making sure the one rule you never bend is enforced by something more reliable than a code path.

We had a real bug here. An early version ran a strict availability pre-check on every occurrence before booking it. On a salon that hadn't finished setting up its working hours, every repeat got skipped as "out of hours". The owner had deliberately picked those times, and the system threw them all away. That bug forced us to be precise about a distinction we'd been hand-waving: availability is a gate for customers, but it's only guidance for an admin.

The gist

  • A recurring series books each visit through the same createBooking pipeline as everything else, tagged with source "admin".
  • Customer bookings are strict: an unavailable slot is rejected. Admin bookings force-book, so working hours and days off are guidance, not a gate.
  • The one thing nobody can override is a real double-book, caught by a partial unique index on (staff, start time) in Postgres.
  • Force-booking lives in the application; the collision rule lives in the database. Different problems, different layers.

One pipeline, a source argument

There's exactly one function that creates a booking, and a recurring series is just a loop that calls it N times. We resisted writing a parallel bulk-insert path. A series occurrence is a real appointment. It needs the same customer matching, the same duration handling, the same row shape, the same side effects. Forking that logic would mean two places to keep in sync and two places to get subtly wrong.

The whole behaviour difference is carried by one argument. createBooking takes a source, and that single value decides whether availability is a gate or a hint.

ts
export async function createBooking(
  input: CreateBookingInput,
  source: BookingSource = "online", // the strict, customer-facing default
): Promise<CreateBookingResult>

The recurring module passes "admin" for every occurrence, because an owner locking in a standing series is doing the same deliberate scheduling as someone dragging an appointment onto the calendar. The default is "online", the strict path the public booking page uses. The default matters: the safe, locked-down behaviour is what you get if you forget to think about it.

Why customer booking stays strict

Before writing anything, createBooking resolves the slot against the org's real availability: working hours, the staff member's roster, room or chair resources, existing appointments. For an online or magic-fill booking, a failed resolve ends the request.

ts
const slot = await resolveBookingSlot({
  orgId, serviceId, durationMin, staffId, startAt,
});

// Customer-facing claims must land on a still-open slot.
if (!slot.ok && isCustomerFacing(source))
  return { ok: false, error: slot.reason ?? "That time is no longer available" };

This is non-negotiable for self-serve. A customer on the public site has no business placing themselves outside open hours, on a day the salon is closed, or on top of someone else's appointment. The strict reject is the only correct answer, and "that time is no longer available" is exactly what they should see. Magic Fill, a one-tap claim of a freed slot, is held to the same standard on purpose. If two people race for the same recovered slot, the loser gets a clean rejection rather than a silent double-book.

Why admin scheduling force-books

An admin is not a customer. When an owner sets up "every four weeks at 2pm", they're asserting a fact about their own calendar. They might be booking before they've finished entering their hours. They might run a regular Sunday slot for a loyal client even though Sunday isn't a published opening. Treating availability as a hard gate here means the system overrides the person who runs the business, which is backwards.

So for the admin source, a failed slot resolve is not fatal. The booking proceeds. The recurring loop spells this out in a comment, and it's the heart of the whole thing:

Each occurrence force-books like the anchor appointment: availability (working hours, a day off) is guidance, not a gate, for an admin who is deliberately scheduling these times. Only a genuine same-(staff, instant) double-book is rejected by createBooking's unique slot index and reported as skipped.

The same asymmetry shows up in rescheduleAppointment, which defaults to "online" and only relaxes for "admin". When an admin drags a booking on the 24-hour calendar grid, it can land outside open hours. A customer-initiated reschedule still has to find a real, open slot.

ts
// Admins (calendar drag) may place an appointment outside open hours;
// customer-facing reschedules (the default) stay strict.
if (!slot.ok && isCustomerFacing(source)) return { ok: false, error: slot.reason };

There's a detail worth noting when the slot doesn't resolve. The resolver also assigns things like a free room or chair. When an admin force-books past a failed resolve, there's no resolver output to trust, so we keep the staff member the admin chose and drop the auto-assigned resource rather than invent one. Forcing the time does not mean fabricating the rest of the row.

The one line nobody crosses: the unique index

Force-booking opens an obvious hole. If admins can ignore availability, what stops two appointments for the same staff member at the same instant? The answer is not a check in the booking function. Checks lose races. The answer is a partial unique index in Postgres on (staff, start time), scoped to live (pending or confirmed) rows with a staff member set.

This matters because the availability check and the insert are not atomic. Two bookings can both pass resolveBookingSlot, then both try to write the same slot. The database is the only place that can settle that, because it's the single point where both writes serialise. When the second insert violates the index, the driver throws, and we translate it into a friendly retry instead of a 500.

ts
try {
  // insert appointment + add-ons + wallet debit, in one transaction
  appointment = await db.transaction(async (tx) => { /* ... */ });
} catch (e) {
  // The partial unique index (staff + start time) rejected a colliding
  // booking that slipped through the availability check via a race.
  if (isUniqueViolation(e))
    return { ok: false, error: "Sorry, that time was just taken. Please choose another." };
  throw e;
}

The recurring loop relies on exactly this. It does not pre-check each occurrence for clashes. It tries to book, and if createBooking comes back with an error (a real collision tripping the index), it records that occurrence as skipped with a reason and moves on. The owner sees "booked five, skipped one", can look at the overlap, and fix it. Force-booking and collision-rejection aren't in tension. They're two different rules living in two different layers, which is why both can be true at once.

  • Force-book versus strict is a business rule, so it lives in application code, keyed off source.
  • No real double-book is an invariant, so it lives in the schema as a constraint.
  • isUniqueViolation walks the error's cause chain because Drizzle wraps the driver error; trusting the top-level message would miss the 23505.
  • The same transaction protects the wallet debit and add-on inserts, since they share the booking transaction and roll back together.

The few things we still won't force

Force-booking is not a blank cheque. The recurring path still refuses to create a back-dated appointment: an occurrence that lands in the past is skipped, not booked. We also org-scope the staff member before persisting the series. A foreign-org staff id would create an "active" series whose every occurrence silently skips forever, because that staffer is never in this org's qualified set. That's a permanently stuck series that can never book, so we reject it up front with "staff member not found" rather than let it rot.

There's also a deliberate billing decision baked in. Admin bookings skip deposits, so each occurrence is created confirmed with no payment. A recurring series never auto-charges and never touches Stripe. That fits how Bookatu works generally: 0% booking commission, tenants run their own Stripe, money lands in their bank, not ours. Materialising six future visits should not quietly create six payment intents.

What's next

Right now a series materialises its occurrences up front, in one batch. The schema already hints at where this goes. Each series row stores a nextDate marking where the visit after the current batch would fall, which is the hook for a future cron that extends a standing series instead of booking a fixed run. When we build that, the force-book rule and the unique index won't change. They already draw the line in the right place: be generous to the person running the business, and let the database be the one thing that never bends.

architectureschedulingpostgresconcurrencybooking

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs