Engineering
One rule, every door
A client can now book a colour and a cut as one visit, and a braiding salon can say that a 4 braids and a 6 braids are never booked together. Bookatu takes bookings through five different doors. This is how those rules live in one place that every door has to pass through, and how we taught an engine built around a grid to find starts that are not on it.
The short version
- Whether a set of services can be one visit is decided by two pure functions: visitProblem in lib/visits-shared.ts and comboProblem in lib/service-combos.ts. Neither touches the database.
- Every door that books a visit ends in one server function, createVisit in lib/visits.ts, which asks both questions before anything is written. The booking page, the staff New booking form, the v1 API and the MCP server all call it.
- Because the rules are pure and client-safe, the booking wizard and the staff form use the same functions to decide what to show. The UI may hide a choice. Only the seam refuses one.
- Times for a visit come from the same availability engine every single booking uses, asked once per service. The only thing relaxed is the grid, through an exactStarts parameter, because a 45-minute colour puts the cut at a quarter to.
- When a reviewer found a bug in how a moved visit was checked, the fix went into the one helper that lays a visit out, and every door that moves a visit was corrected by it.
Release 5 brought two features that sound small and are not. A visit is two to four different services for one client, one straight after another, possibly with different people: a colour with Sam, then a cut with Anna. Combination rules let a business say which services go together, per service: "only with these", "never with these", and, for an extra, whether it adds its own minutes to the booking. The example we kept coming back to was a braiding salon whose 4 braids and 6 braids are mutually exclusive. A client books one or the other.
The rules themselves are a page of code. The hard part was that a booking does not arrive at Bookatu through one door.
Five ways in
- The booking page, where a client picks services in a wizard and books online (app/[org]/(site)/book/actions.ts).
- The staff New booking form, where the team books a visit for a client on the phone or at the desk (app/[org]/admin/appointments/actions.ts).
- The v1 REST API: POST /visits/book for a client booking online, POST /visits for the business's own key, and GET /visits/availability for times.
- The MCP server, whose create_visit and get_visit_availability tools are what an AI assistant calls on a client's behalf.
- The owner's phone app, which books through its own route under app/api/mobile.
Each of those has its own request shape, its own validation and its own way of saying no. The tempting design is to check the rules where the request comes in, because that is where the request is understood best. We have been down that road before, and one of our v1 routes carries a comment about it that predates visits entirely:
Re-implementing the rules here is how the app and the web would slowly come to disagree about money.
Five copies of a combination rule would agree on the day they were written. The sixth door, or the first change to what a visit may contain, is where they stop agreeing, and nothing would notice, because each door's tests would be testing its own copy.
The rule, with no database
The first half of the seam is a function that takes the services in order, the facts about each one, and whether a client or the team is asking. It returns the first reason these cannot be one visit, or null.
export function visitProblem(
serviceIds: string[],
facts: Map<string, VisitServiceFacts> | Record<string, VisitServiceFacts>,
opts: { online: boolean },
): VisitProblem | null {
if (serviceIds.length < MIN_VISIT_SERVICES) return "too_few";
if (serviceIds.length > MAX_VISIT_SERVICES) return "too_many";
if (new Set(serviceIds).size !== serviceIds.length) return "repeated";
const get = (id: string) => (facts instanceof Map ? facts.get(id) : facts[id]);
for (const id of serviceIds) {
const f = get(id);
if (!f || !f.active) return "unavailable";
if (f.isAddOn) return "add_on";
if (f.isGroup) return "class";
if (f.videoMeeting) return "video";
if ((f.staffRequired ?? 1) > 1) return "team";
if (opts.online && !f.bookableOnline) return "unavailable";
}
return null;
}The online flag is the only difference between a client and the team. A client books only what the business offers online. The team can book anything the business offers, including a service it keeps off the booking page. The cap of four is in the same file with its reason written down: four covers the real combinations, and the availability search asks once per service, so it has to stay cheap enough for the booking page.
The second half is the business's own rules. They are loaded from a service_links table, but the decision is again a pure function. Two services clash if either one says "never with" the other, or if either has an "only with" list the other is not on.
/** Whether either of the two says "never with" the other. */
export function neverTogether(rules: ComboRules | null | undefined, a: string, b: string): boolean {
if (!rules || a === b) return false;
return Boolean(rules[a]?.never?.includes(b) || rules[b]?.never?.includes(a));
}
/** Whether two services can be in one visit, and if not, why. */
export function servicesClash(rules: ComboRules | null | undefined, a: string, b: string): ComboClash | null {
if (!rules) return null;
if (neverTogether(rules, a, b)) return "excluded";
const onlyA = rules[a]?.only;
const onlyB = rules[b]?.only;
if ((onlyA && !onlyA.includes(b)) || (onlyB && !onlyB.includes(a))) return "not_allowed";
return null;
}comboProblem walks every pair of services, then every extra against the service it was added to, then every extra against everything else in the booking, and returns the first clash with the two ids it is about. A business that has set no rules gets null before any of that runs, which is how every business that never opens the rules editor books exactly as it did before.
Where the doors meet
The second half of the seam is createVisit. It is the only function that writes a visit, and it asks its questions in a fixed order before it opens a transaction: is this business offering visits to clients at all, is it a restaurant (a party is seated for one reservation, so there is no visit to make), can these services be one visit, and do the business's rules let them go together.
// Clients only once the business has turned it on; the team always.
if (!staffBooked && !visitOffer(settings)) {
return refuse("not_offered", "This business books one service at a time online.");
}
// A restaurant seats a party for one reservation; there is no visit to make.
if (org && vertical(org.industry).value === "restaurant") {
return refuse("cant_combine", "Those services can't be booked together in one visit.");
}
const ids = input.lines.map((l) => l.serviceId);
const { rows: serviceRows, facts } = await serviceFacts(orgId, ids);
if (visitProblem(ids, facts, { online: !staffBooked })) {
return refuse("cant_combine", "Those services can't be booked together in one visit.");
}
// ...extras read for each line...
const clash = await checkCombination(
orgId,
input.lines.map((l, k) => ({ serviceId: l.serviceId, extraIds: extrasByLine[k].map((x) => x.id) })),
/* names already read */,
);
if (clash) return refuse("cant_combine", clash.error, { combo: clash.named });Only after that does it ask the availability engine whether the whole visit fits, and only after that does it create a client, price the lines, share one discount across them and write anything. A refusal at any of these checks leaves no trace: the tests for it check that no appointment and no client row was created.
Who is asking is the BookingSource argument. The booking page passes "online". The staff form passes whichever channel the team picked, a phone booking or a walk-in. The API has one function for each path, and the source each one passes is the difference that matters:
// lib/api-visits.ts, bookClientVisit (POST /visits/book, create_visit)
const result = await createVisit(
{ orgId: org.id, lines: await linesForClient(org.id, first, rest, input.staffId || null, input.addOnIds), /* ... */ },
"online",
);
// lib/api-visits.ts, bookTeamVisit (POST /visits, the business's own key)
const result = await createVisit(
{ orgId: org.id, lines, /* ... */ },
"admin",
);The MCP server does not get a function of its own at all. Each visit tool is a thin wrapper over the operation its REST route calls, so the two surfaces cannot answer differently:
export async function createVisitMcp(orgId: string, args: unknown, opts: { appBase: string }) {
return unwrap(await bookClientVisit(await orgOrThrow(orgId), args, { channel: "mcp", appBase: opts.appBase }));
}The refusal comes back from createVisit carrying the pair by name, and each door only decides how to say it. The booking page says it in the business's language from the translation catalog. The staff form adds a pointer to where the rule can be changed, under Services. The API returns the engine's own English sentence with a 409 and the code cant_combine, and the MCP server returns the same words as a tool error with the code in its data. The sentence is built in one place too, so the integration tests for different doors assert the same words: "4 braids and 6 braids can't be booked together."
The phone app has the least to check
To be straight about the fifth door: the owner's phone app does not book visits. Its booking route takes one service, with no extras, and hands it to createBooking in lib/appointments.ts, the same function behind every single booking on the web and through POST /book. There is no pair to compare, so there is nothing for a combination rule to say. What matters is where it stands. createBooking is the single-booking seam, and it already refuses an extra the service does not take, with the same checkCombination call and the same named reason. The phone app sits behind it rather than beside it.
Asking for times is a second question
Booking is not the only moment a door needs the rules. Before a client picks a time, the page asks which times the visit fits. If that answer ignored the rules, the wizard could offer a slot for a 4 braids and a 6 braids, the client would pick it, and only then would createVisit refuse. So the times doors ask the same functions before they search. This is the booking page's:
if (visitProblem(ids, facts, { online: true })) return [];
// Services the business's rules keep apart are no visit either (S3-K4).
if (await checkCombination(org.id, ids.map((serviceId) => ({ serviceId })))) return [];The comment above that function states the goal plainly: a stale page can never be offered a time it cannot book. The API's visitTimes makes the same two calls and turns a problem into a 409, and the staff form's times ask checkCombination before searching. This is the one place the doors repeat themselves: three call sites asking the same shared questions. We accepted it because what they repeat is a call, not a rule, and because createVisit asks again regardless. A mistake at a times door can show or hide a time. It cannot write a booking.
Back to back on an engine built for a grid
The availability engine in lib/availability.ts offers starts on the business's grid. With a 30 minute interval and a 9:00 opening, a client sees 9:00, 9:30, 10:00, and a booking for 9:15 is refused. We have a characterization test pinning exactly that, written before visits existed, and it still passes. A visit breaks the assumption immediately. A 45 minute colour at 9:00 means the cut starts at 9:45, which is not on the grid, and refusing it would make most visits unbookable.
We did not write a second engine for visits. Everything the engine knows about a single booking still has to hold for each service of a visit: the person's hours and break, their other bookings with the buffer, the room the service needs, the notice period and the horizon. So lib/visits-availability.ts asks the existing engine once per service. The first service is asked on the grid over the whole range. Each later one is asked at exactly the instants the visit would put it at.
const first = await ask(0, { ...range, ...(firstExact ? { exactStarts: firstExact } : {}) });
const later: Array<Map<number, DaySlot>> = [];
for (let k = 1; k < lines.length; k++) {
const starts = first.flatMap((d) => d.slots.map((s) => plus(toDate(s.startAt).getTime(), offsets[k])));
const found = new Map<number, DaySlot>();
if (starts.length > 0) {
const days = await ask(k, { fromKey: range.fromKey, toKey: addDaysToKey(range.toKey, 1), exactStarts: starts });
for (const d of days) for (const s of d.slots) if (s.staffIds.length > 0) found.set(toDate(s.startAt).getTime(), s);
}
later.push(found);
}Inside the engine, exactStarts changes two things and nothing else. The candidate starts are the instants handed in instead of the grid, and the window check stops requiring a start to sit on the grid. Opening, closing and the break still apply.
const onGrid = p.exactStartMins == null;
const fitsWindow = (w: WorkWindow | null | undefined, start: number, end: number): boolean =>
!!w &&
start >= w.openMin &&
end <= w.closeMin &&
(!onGrid || (start - w.openMin) % interval === 0) &&
!(w.brk && start < w.brk.endMin && w.brk.startMin < end);
// ...
if (!onGrid) {
for (const m of p.exactStartMins!) tryStarts.add(m);
} else {
if (businessWindow) addGrid(businessWindow);
// ...
}Only the visit search sets exactStarts, so every other caller gets the grid it always had. The cost is one engine pass per service for the whole range, at most four, rather than one per candidate start. The day after the range is included for the later services, so a visit that runs past midnight is still found.
At booking time the same reasoning applies once more. createVisit calls resolveVisitSequence, which checks each service at its time through resolveBookingSlot, the function a single booking is checked with. The first service of a client's visit must be on the grid like any client booking. The later ones, and every service the team books, are checked at their exact start.
The UI uses the same functions
lib/service-combos.ts is pure and client-safe, as is lib/visits-shared.ts, so the booking wizard and the staff form import the rules directly. The wizard only lists a service as something to add when it goes with every service already in the visit, and when the client changes the first service, it lets go of any added service that no longer fits:
// And each one only while it goes with the services before it (S3-K4): a
// saved 6 braids is let go of when the first service is now a 4 braids.
const visitKey = (serviceId ? keepCompatible(comboRules, [serviceId, ...visitAdded]).slice(1) : visitAdded).join(",");The staff form runs comboProblem over what is on the form and shows the same sentence as a warning before anyone presses Book. This is where the seam pays off for the people using it, because the reason a choice is missing on screen is the same reason the server would give. But the line we hold is that the UI may hide and only the seam may refuse. Nothing on the client is trusted to keep a rule. It only saves somebody a round trip.
What one seam bought us
The first payoff is the tests. The combination rules have integration tests through the doors on a real in-process Postgres: createVisit directly, the booking page's actions, the staff form's actions, the v1 visit routes, and the v1 and MCP booking tools for extras. They do not each test a copy. They test that each door reaches the one implementation and reports what it says.
it("refuses two services that are never together, whichever of them the rule was set on", async () => {
const s = await braids("cmbvisit");
await rule(s, s.sixId, s.fourId, "excludes");
const res = await createVisit(
{ orgId: s.orgId, lines: [{ serviceId: s.fourId }, { serviceId: s.sixId }], startAt: at(dayKey(8), 540), ...who("bria.visit@example.com") },
"phone",
);
expect(res).toMatchObject({
ok: false,
code: "cant_combine",
error: "4 braids and 6 braids can't be booked together.",
combo: { kind: "services", first: "4 braids", second: "6 braids" },
});
expect(await rows(s.orgId)).toHaveLength(0);
expect(await clients(s.orgId)).toHaveLength(0);
});The second payoff arrived in the release review. A reviewer found that moving a whole visit checked its services back to back, even when the team had already dragged one service later on its own. The person doing the later service was checked at a time it would never be saved at, and could be double-booked at the time it was. The search and the check both worked out each service's start in one helper in lib/visits-availability.ts. The fix let that helper take the gaps the visit is booked with, and fall back to back to back only when none are given; lib/visits.ts now passes the booked gaps from its two move functions.
function startsFor(durations: number[], offsets?: number[]): number[] {
return offsets?.length === durations.length ? offsets : visitOffsets(durations);
}Every way of moving a whole visit goes through those two functions, moveVisit and visitMoveTimes: the client's manage link, the v1 reschedule routes and the MCP reschedule tool. One fix corrected all of them, and the test for it exercises both the client's path and the team's. In a design where each door kept its own idea of when a visit's services start, the same fix would have been a search for every copy, and the copy nobody found would have been the next bug report.
None of this is novel. A rule that matters lives in one function, and every way in has to pass through it. What made it hold here was being strict about which side of the line each thing sits on. The doors parse, authenticate and translate. They do not decide.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs