Booking channels¶
A booking channel is a front that bookings are made through: the main site, a mini-site for one special event on its own domain, a partner integration, and in time a box office. Naming one lets a tenant hold part of what it offers back to one front, so a tariff meant for a gala mini-site is neither shown nor holdable anywhere else.
A site with one front needs none of this. Every tenant has a default
channel, {tenant}.default, that serves every request no other channel
claims, and what names no channel is offered everywhere, the default
channel included, so an install that never touches the feature behaves exactly
as it did before there was one.
flowchart LR
REQ[Request] --> SCC[Booking channel context]
SCC --> R1[domain resolver]
SCC --> R2[your resolver]
R1 -->|channel id| SCC
SCC -->|first non-empty wins,<br/>else the tenant's default| CH[(Channel being served)]
CH --> OFFER[What is on offer here]
CH --> ORDER[Stamped on the order it opens]
How it works¶
BookingChannelis a config entity (yoyaku_booking_channel). It belongs to exactly one tenant: mini-sites are a tenant's own, so unlike a resource type a channel is never shared between tenants. The tenant is fixed once the channel exists. Its id is the tenant's machine name and its own, joined by a dot (bordeaux.box_office), so every tenant can have its ownbox_office,weborpartnerchannel.- The booking channel context (
yoyaku.booking_channel_context,BookingChannelContextInterface) answers which channel is being served, by consulting resolvers taggedyoyaku.booking_channel_resolverin priority order. It is one service with the tenant context (TenantAndBookingChannelContext), since a channel's tenant follows it. - Every tenant has a default channel,
{tenant}.default, created when the tenant is saved and deleted with it; it cannot be deleted on its own. A request no resolver claims is served through it: the main site, the back office, the API, drush and cron. It is offered what names no channel, as every channel is, so a tariff restricted to a mini-site does not have to name it to stay off the main site's list, and naming it restricts an offer to the main site. BookingChannelField::getDefinition()provides the sharedbooking_channelsbase field, carried by the resource and the tariff. Naming no channel offers it everywhere; naming channels offers it only where one of them is served.BookingChannelFilter(yoyaku.booking_channel_filter) is the one place that rule is written. Everything that reads offers asks it, and so does the booking path.
What a channel restricts¶
| Named on | Effect |
|---|---|
| A resource | Its slots are listed and bookable on that front only. The public page for one of its slots is not served elsewhere. This is how a mini-site for a single event is built. |
| A tariff | The tariff is absent from every offer list off its front, and refused if asked for anyway. The slot itself stays available, with its other tariffs. |
A slot carries no channels of its own: it inherits its resource's.
Hiding is not enforcing¶
Both halves ship, and neither stands in for the other.
- The reads.
BookingAvailability::getTariffsByResource()narrows the read the availability map is built from, so the booking form, the calendar and the API stop offering a withheld tariff together. The place map reads its own tariffs and narrows them through the sameBookingChannelFilter, which also settles what an area may be booked at and which allowances are published, since all three come off that one read. The public routes that name a slot or a resource carry a_yoyaku_booking_channelrequirement, so a page for a withdrawn resource is refused rather than rendered and then refused on submitting. - What is already held is not narrowed. A tariff withdrawn while somebody holds a booking at it stays on the page and on the plan, at what they hold and no more, saying that it has been withdrawn. It is not put back on offer: the booking page joins it in per slot, and the plan carries it with the basket rather than with the offers, which are shared between every visitor on the front. A booker who cannot see a booking cannot give it back, and it is still on their cart.
- The hold. The engine refuses an ask naming a resource or a tariff the
front does not offer, in
BookingManager::hold()beside the rest of what a ask has to be. A list leaving a tariff out is a courtesy to the visitor; the refusal is what stops a crafted request, or a page left open while an operator withdrew the tariff, from holding it anyway. Asked there and not by the surfaces, because that is the layer every caller passes through: a surface that composes its own asks and hands them to the engine reaches capacity without going through anything above it. - The front a hold is judged on is the order's, recorded when it was opened, not the one being served at the moment a booking is added. A queued hold, a retry, and an operator finishing a booker's order are none of them standing in the front that order belongs to. An order being opened has nothing recorded yet, and there the front being served is the answer, which is the same value it is about to be stamped with.
The back office keeps seeing everything the tenant offers, restricted or not.
That is why the rule is a route requirement on a handful of public routes rather
than part of YoyakuAccessControlHandler, which lets an administrator reach
every record.
The order records the front it came in by¶
A transaction carries a booking_channel, stamped once as the order is opened and
read from there afterwards. The request only ever seeds it: a basket outlives the
request that opened it, a booker who arrived on a mini-site can come back on the
main site, and the back office and the notifications render in a context of their
own. Reading the ambient channel later would answer with whichever front happened
to be serving. Every order records one: an order opened where no other channel
answered records its tenant's default channel, so anything built for an order
later knows which front its booker used.
It is also what tells bookings apart per channel in reporting.
Placing an order for someone else¶
An operator placing an order on a booker's behalf (Place a booking, at the box office or over the phone) places it in a channel. The form lists the tenant's channels the operator may place orders in, starting on the default one, offers what the chosen channel offers, and opens the order in it, so the order records it and the engine judges every booking on it by that channel.
An administrator may place orders in every channel, and with yoyaku_order so
may whoever manages the tenant's orders. There is no permission per channel,
which would take a role per channel to give one clerk one channel. With
yoyaku_manager, a channel's Operators tab grants it to a person instead
(see Channel operators); any other module
granting order work its own way widens the list with
hook_yoyaku_channels_to_place_orders_in_alter().
The order records who placed it (placed_by, empty when the booker placed it
themselves), and the orders list filters by channel and by that operator, so a
box office's day can be reconciled: the orders it took, and who took each.
The engine refuses an order opened in a channel of another tenant, but does not check who opened it: it also runs under cron and queue workers, with no user, so the surface offering the choice offers only what the operator may.
Building for an order in its channel¶
Whatever is built about an order away from its front (an email sent during payment, by an operator in the back office, by cron) is built in the channel the order records:
$context = \Drupal::service('yoyaku.booking_channel_context');
$context->executeInBookingChannel($order->getBookingChannelId(), function () use ($order) {
// Here the channel context answers the order's channel, and the tenant
// context that channel's tenant.
});
Both are restored on return, even when the callback throws. With
yoyaku_domain, entering a channel also enters its primary domain, so what
is built carries that front's links and site name. A channel id naming nothing
is logged and the callback runs where it is.
One channel per domain¶
The Yoyaku Domain submodule (yoyaku_domain) is the shipped answer, and the
one a mini-site needs: a channel names the domains it is served on, on its
Domains tab, and a visitor arriving on one of them is offered what names that
channel plus everything that names none.
- The binding lives on the channel, not on the domain record, the same arrangement as the tenant binding described in Multi-tenancy. Several domains may share one channel.
- One domain belongs to one channel, and to the tenant that channel belongs to. Validation refuses both a domain another channel already holds and a domain served in another tenant, so the two bindings cannot contradict each other.
- A domain no channel claims defers, so the main site of a site whose only channels are its mini-sites is served through the tenant's default channel, which offers everything unrestricted.
- Which domain is being served is Domain's answer. A hostname it cannot match, as on the CLI and in cron, falls back to the default domain record, so such a run is served through whatever channel names that record.
Caching¶
Offers depend on the front, so anything showing them varies on the
yoyaku_booking_channel cache context. It ships on the slot booking form, the
calendar element, the upcoming-slots formatter, the channel access check and
the Views filter. The context is invalidated by the channel list cache tags, so
creating a channel or binding a domain to one drops pages cached before it.
url.site is not a substitute: a resolver may name the front from an API
consumer or an operator screen on the same host.
Listing with Views¶
yoyaku_views adds a filter, Offered on the booking channel being served, to
resources and tariffs. It takes no setting: rows are kept when they name no
channel, or name the one being served. A view of slots adds the resource
relationship and filters there.
To name one channel explicitly instead, filter on the Booking channels field, which Views exposes like any other.
Adding a resolver¶
Any other way of deciding the front is a service away: an API consumer, a path prefix, an operator screen.
# my_module.services.yml
services:
my_module.consumer_channel_resolver:
class: Drupal\my_module\ConsumerChannelResolver
arguments: ['@my_module.consumer_context']
tags:
- { name: yoyaku.booking_channel_resolver, priority: 10 }
final class ConsumerChannelResolver implements BookingChannelResolverInterface {
public function resolveBookingChannelId(): ?string {
// Return a channel id ({tenant}.{name}), or NULL to defer to the next
// resolver.
return $this->consumerContext->current()?->getChannelId();
}
}
Return NULL to defer. A channel of another tenant than the one being served is
skipped, and so is an id naming no channel at all, so a binding that outlives its
channel serves the request through the tenant's default channel rather than
refusing it.
What else hangs off a channel¶
A channel is where a front's own rules live, so other modules attach their configuration to it as third-party settings rather than adding fields to it:
yoyaku_domainrecords the domains the channel is served on.yoyaku_booking_feerecords what the channel charges on top of a booking, and what it calls those fees. See Booking fees.