Back to engineering

Engineering

Six tables, one booking a night

The Bookatu engineering team10 min read

Our availability engine grew up in a salon, where a busy stylist means no appointment however many chairs are free. A restaurant is the opposite shape, and one line that had been in the file since its first version meant a dining room with six tables went dark the moment the first party sat down. The fix was small. The two layers underneath it were not.

The short version

  • The inner loop of our availability engine asked whether a staff member was free and gave up before it ever looked at rooms or tables. For a salon that is correct, because somebody has to cut the hair.
  • A new restaurant is seeded with six tables and one staff row, the owner. The first party of the evening made the owner busy, so every remaining table went dark.
  • The distinction that fixes it: a booking that needs a resource and names a party size is a reservation, and a reservation is capped by tables rather than by people. Everything else still gates on staff.
  • The obvious fix swapped one bug for a worse one. A booking with no staff member blocks everyone in our model, because that is how a shop-wide hold works.
  • A unique index on (staff_id, start_at) had been quietly enforcing the same assumption. It had never once got in our way, which we had read as the model being right.

Bookatu's availability engine was written for salons and it shows in the vocabulary. Its nouns are staff, services, and resources, which is our word for the physical thing a booking might also need: a treatment room, or a colouring chair. The engine walks a day in slot-sized steps and, for each candidate start time, works out who could take it and what would have to be free.

Then we turned on restaurants, and a dining room with six tables would take exactly one booking a night.

The line that was right for a salon

Here is the shape of the inner loop as it stood. For each candidate slot it filters the qualified staff down to the ones with no conflict, and then, only if somebody survives, it goes on to check the resource pool.

ts
const freeStaff = p.staffIds.filter((sid) => {
  const works = (p.workingDaysByStaff.get(sid) ?? []).includes(weekday);
  if (!works) return false;
  const own = busyByStaff.get(sid);
  for (const [cs, ce] of candidateWork) {
    if (own) {
      for (const [bs, be] of own) {
        if (cs < be + buffer && bs < ce + buffer) return false;
      }
    }
    for (const [bs, be] of globalBusy) {
      if (cs < be + buffer && bs < ce + buffer) return false;
    }
  }
  return true;
});

if (!freeStaff.length) continue;   // <- the whole bug

// Only now does anything look at rooms, chairs or tables.
if (p.requiresResource) {
  const freeResources = p.resourcePool.filter(/* ... */);
  if (!freeResources.length) continue;
  slots.push({ startMin: start, startAt, staffIds: freeStaff, resourceIds: freeResources });
  continue;
}

That continue is not a mistake in a salon. It is the load-bearing rule. A haircut consumes a person, and if the only stylist is already busy at two o'clock then there is no two o'clock appointment no matter how many chairs are empty. Take the line out and a one-person salon will happily double-book itself, which is a much louder failure than the one we were about to meet.

The resource check underneath it was written as a narrowing pass. A treatment room is a second condition on top of the therapist, never a substitute for one, so the code only ever reached it once it had already found somebody. Nothing about that ordering was decided. It just followed from what a booking meant when the file was written.

What a restaurant looks like on day one

New businesses get seeded, because an empty booking page is the fastest way to lose somebody in their first ten minutes. Every org gets its owner as the first bookable staff member and a handful of realistic starter services. A restaurant additionally gets a starter dining room, so a reservation can assign a real table on the first evening rather than after an hour in the floor-plan editor.

ts
// The owner is the first bookable team member. Exactly one staff row.
await db.insert(staff).values({
  orgId: input.orgId,
  userId: input.ownerUserId ?? null,
  name: input.ownerName.trim() || "Me",
  bookable: true,
  active: true,
});

// Restaurants also get a starter dining room, pre-laid-out on the floor plan.
if (vertical(input.industry).value === "restaurant") {
  const STARTER_TABLES = [
    { name: "Table 1", seats: 2, x: 22, y: 26, shape: "rect" },
    { name: "Table 2", seats: 2, x: 50, y: 26, shape: "rect" },
    { name: "Table 3", seats: 4, x: 78, y: 26, shape: "rect" },
    { name: "Table 4", seats: 4, x: 30, y: 62, shape: "circle" },
    { name: "Table 5", seats: 6, x: 62, y: 62, shape: "rect" },
    { name: "Booth 1", seats: 6, x: 86, y: 62, shape: "rect" },
  ];
  await db.insert(resources).values(/* ... */);
}

Six tables and one person. Now run the loop. A party books seven o'clock, the booking is written against the only staff row there is, and that person is busy from seven until half past eight. Every subsequent candidate slot in that window filters the staff list down to nothing and hits the continue. The five free tables are never considered, because the code never gets far enough to ask about them.

The engine was not wrong about the restaurant. It had never been told there was such a thing as a restaurant, and it answered the only question it knew.

What does this booking consume?

The temptation is to make the resource check an alternative to the staff check whenever a service needs a resource. Do that and you have broken every salon that books a treatment room, because the room stops needing a therapist. The condition has to be narrower than that, and the narrowing has to come from something only a restaurant sends.

Party size turned out to be it. Neither a haircut nor a treatment room booking has one. A reservation always does, because seating four people is a different request from seating two, and the number is what decides which tables can even be offered. So a booking that needs a resource and carries a party size is a reservation, and a reservation is a different kind of thing.

ts
// A booking that needs a table AND names a party size is a reservation:
// the guest is seated, not served by an assigned person. Both halves matter.
// A salon room booking still needs its stylist, and only a restaurant sends
// a party size, so every existing flow is untouched.
const isTableReservation = p.requiresResource && p.partySize != null;

// A salon appointment consumes a PERSON. A table reservation consumes a TABLE.
if (!freeStaff.length && !isTableReservation) continue;

One extra clause in the condition, and one line above it to define the term. If that were the whole change there would be nothing to write about. What made it worth writing about is that the change was not safe yet, and neither of the two reasons was visible from the diff.

A booking that holds nobody holds everybody

Once a reservation stopped needing a free staff member, the natural next step was to stop assigning one. A party is seated, not served by a named person, and the table is the thing being held. So a reservation carries a null staff id.

Our engine already had a meaning for that, and it is the opposite of what we wanted. Busy time is built per day into a flat list, then split into a per-staff index and one shared list.

ts
interface BusyInterval {
  staffId: string | null; // null = blocks everyone in this org
  startMin: number;
  endMin: number;
}

const busyByStaff = new Map<string, [number, number][]>();
const globalBusy: [number, number][] = [];

for (const b of dayBusy) {
  if (b.staffId === null) {
    globalBusy.push([b.startMin, b.endMin]);
  } else {
    const arr = busyByStaff.get(b.staffId);
    if (arr) arr.push([b.startMin, b.endMin]);
    else busyByStaff.set(b.staffId, [[b.startMin, b.endMin]]);
  }
}

Every staff member's conflict test scans their own intervals and then globalBusy, so anything in that second list rules out the slot for the entire org. That is not an accident either. It is how a shop-wide hold works. When an owner blocks out two until three for a staff meeting, the row has no staff id on purpose, and nobody can be booked into it.

So the first version of the fix would have taken a dining room that seats one party a night and turned it into a building that hosts one booking a night. Seat a table at seven and the null staff id lands in globalBusy, which shuts every other service the business offers for the length of that sitting. We would have traded a bug for a bigger one, and in the test we happened to be running it would have looked like progress.

The distinguishing feature is already on the row. A shop-wide hold has no staff and no resource. A seated party has no staff and a table. So the party is skipped when the per-staff busy list is built, and its conflict is tracked where it actually lives, on the resource.

ts
// A booking with no staffer normally blocks EVERYONE. That is what makes a
// shop-wide hold ("closed 2 to 3") work. A seated party is the one thing that
// looks like that and means the opposite: it holds one table and nothing else,
// and its conflict is already tracked through resourceBusy below.
if (a.staffId === null && a.resourceId) continue;

The constraint had been enforcing the bug

The second layer only showed up when we stopped reading and started inserting rows. Our double-booking guard is not application code. It is a partial unique index, and it has been there since the first migration.

sql
-- conceptual: names simplified for illustration
CREATE UNIQUE INDEX "one_live_booking_per_staff_slot"
  ON "appointments" ("staff_id", "start_at")
  WHERE status IN ('pending','confirmed') AND staff_id IS NOT NULL;

One live booking per staff member per start time. For a salon that is the rule you want written in the strongest place you have, and it is why our booking path has no lock and no read-then-write check that hopes to win a race.

Now read it as a restaurant. Every party was being pinned on the one seeded staff row, and every party at seven o'clock has the same start time. The second table of the evening was not being rejected by our availability rules. It was being rejected by Postgres. Even with the loop fixed, availability could have offered all six tables and the insert would have taken exactly one, with an error that looks like a constraint violation rather than a full restaurant.

That reframes the whole bug. We had a model that said a booking belongs to a person, an index that enforced it, and a seeding routine that gave restaurants one person. The three agreed with each other perfectly, which is exactly why the disagreement with reality was so hard to see from inside the code. The index had also never got in our way, and we had quietly read that as the model being right. It is not evidence of that. It is evidence that every business we had served so far was one the model happened to fit.

A constraint that has never fired is not evidence your model is right. It may be the only reason you have not met the case where it is wrong.

Which makes assigning no staff member to a reservation load-bearing rather than tidy. The index is partial, and its WHERE clause excludes rows with a null staff id, so six parties at seven o'clock are six rows the index does not police. The sibling index on (resource_id, start_at) polices them instead, which is the correct authority for a booking whose scarce thing is a table.

ts
// A table reservation claims NO staffer, and that is load-bearing. Pinning
// every party on the one seeded staff row meant the database itself refused
// the second table at 19:00. Nobody is assigned to a party anyway.
if (params.partySize != null && (slot.resourceIds?.length ?? 0) > 0) {
  return { ok: true, staffId: null, resourceId };
}

// "Any staff" request: pick the first free member.
return { ok: true, staffId: slot.staffIds[0] ?? null, resourceId };

Then we put the bug back

The tests for this run against a real Postgres in the test process, replaying our actual migrations, so the index above is present and enforcing. The suite seeds the fixture the way the seeder does: one staff row, four tables, one service that needs a table and one that does not.

Then, as we do with anything that claims to fix a bug, we put the code back to how it was and re-ran to watch the tests fail. The first three ways we tried breaking it left the suite green every time. Writing the fix had meant updating the helper that seats a party, and the updated helper no longer set up the conditions the original bug needed. Why that happens, and what we do about it now, is a post of its own: Green for the wrong reason, published alongside this one.

One test from that rewrite belongs in this post rather than that one, because it is not really about testing.

ts
it("a haircut is refused while the only stylist is busy", async () => {
  // The whole point of the distinction. Somebody has to cut the hair, so a
  // busy stylist means no appointment however many tables are free. Loosening
  // this for everything would double-book a one-person salon.
  await seat(null, cutId);
  expect(await slotsAt1900(cutId, 60)).toHaveLength(0);
});

Nothing in that test is new behaviour. It asserts the rule we started with, sitting in the same file as the rule that replaced it for one narrow case. A vertical gets added by narrowing something, and the case most worth a test is the one the narrowing was supposed to leave alone. It is easy to make a restaurant work by weakening the staff gate for everybody, and the salon failure that follows would not turn up for weeks.

Finding your own version of this

Adding a vertical sounds like a design job: new words on the booking page, and a floor plan instead of a chair list. Almost none of the real work is there. The work is finding every place where the original domain got compiled into a decision nobody wrote down, because those places do not announce themselves and none of them are in the part of the codebase named after the feature. Four places we would look first, having been through this one.

  • The early returns. Wherever a loop gives up, somebody decided what the necessary condition was. A continue is an assumption with a keyword in front of it, and ours had been sitting in plain sight since the first version of the file.
  • The order of your checks. Our resource check was not wrong, it was second. In a salon a room is a narrowing condition on a person, and in a restaurant it is the whole answer. Ordering is a claim about which constraint is primary, and it is usually invisible.
  • Your seed data. One staff row and six tables was a statement about where the scarcity lives in this business, and no code was reading it. If the numbers your seeder writes contradict the shape your engine assumes, the first customer of that vertical will find it on their first night.
  • Your unique indexes. A constraint is an assumption you cared enough to make the database enforce, which makes it the assumption least likely to be revisited. Ask each one whether it is still true in the new vertical.

There is a question underneath all four that generalises better than the checklist does. For every booking your system takes, ask what it actually consumes. We had always answered with a person, because in a salon the answer is a person and nobody ever had to say so. A restaurant consumes a table. A class consumes a seat. Get that answer wrong and everything downstream, from the availability loop to the index that protects you, will enforce the wrong thing beautifully.

availabilitypostgresverticalstestingengineering

Building on Bookatu?

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

Developer docs