[CODE]

One Form for Two Properties: Per-Property Options and the Race That Created Duplicate Calendars

When one operator runs two B&Bs with different booking channels, a single shared form has to morph per property. And the first multi-property submission quietly created two Google calendars — until the sends were forced to run in order.

4 min read AI-generated
nextjs supabase google-calendar ux bnb

The booking form started life assuming one property per operator. Then a single operator with two properties started using it, and two assumptions broke at once: that every property accepts the same set of contact channels, and that firing all the property POSTs in parallel was harmless.

This is the data-entry surface of the same booking system covered in “When the person entering bookings is 70 years old.” That piece covers who uses the form and why it’s calendar-first. This one covers two narrower decisions: how the form adapts per property, and a concurrency bug that only surfaced the moment an operator selected more than one property in a single submission.

Why per-property channel config exists at all

The form offers a fixed list of contact channels — LINE, phone, WhatsApp, the OTAs — and booking types (whole-house vs. single room). But not every property takes bookings the same way. One property might never appear on Airbnb; another might only ever do whole-house. A form that shows every option for every property forces the operator to mentally filter on every entry, which is exactly the friction that makes a 70-year-old stop using the tool.

So each property gets two array columns — allowed_channels and allowed_booking_types — defaulted to the full set, editable from the setup page. The setup page renders each option as a toggle: enabled channels show in green with a check, disabled ones are greyed and struck through. When the operator selects a property in the record form, only that property’s enabled options appear. If a property has exactly one booking type, it’s auto-selected. The marginal cost of correct data entry drops to near zero, and the operator never edits code to get there — this is all self-service from the admin layer.

The multi-property case unions the options: select two properties, see the channels and types either one allows. That’s the right default — the operator is logging the same booking against both, and the intersection would be too restrictive.

The race that built two calendars

The original multi-property submit fired all property POSTs with Promise.all. Fast, obvious, and wrong for a brand-new operator.

Here’s why. The first time any property POST runs for an operator, the server finds-or-creates the operator’s shared Google Calendar and persists the calendar ID back to the database. The second POST is supposed to read that saved ID and reuse it. Under Promise.all, both POSTs run before either has written the calendar ID — so both find no existing calendar, both create one, and the operator ends up with two “訂房日誌” calendars. Every subsequent booking lands in one or the other at random.

The fix is to run the sends sequentially only for the first submission — the first POST creates and saves the calendar ID, then the rest can safely go parallel. The comment in the code says exactly this, because the next person to read it will be tempted to “optimize” the loop back into Promise.all and reintroduce the bug.

This is the kind of failure that doesn’t show up in any single-property test. It needs two properties, a fresh operator, and one submission — a state that exists for roughly the first thirty seconds of an operator’s life on the system, and never again.

Conflict checks and surfacing the reason

The same work added a whole-house conflict guard: the same property can’t have two overlapping whole-house bookings. The overlap condition is the standard checkin < newCheckout AND checkout > newCheckin, run on both create and edit, with edit excluding its own row. A conflict returns 409 with the conflicting dates spelled out.

That last part mattered. The form used to swallow every failure into a generic “save failed, try again.” Now it surfaces body.message ?? body.error, so a 409 conflict tells the operator which dates already have a whole-house booking. For a non-technical user, the difference between “try again” and “these dates are already booked whole-house” is the difference between giving up and fixing it.

None of these are large changes. They’re the changes that decide whether a real two-property operator can actually run their bookings through the tool without hitting a wall the same week they start.