Appointment Booking
Overview
Appointment booking is a multi-step flow that admins design in the CMS as a "journey". The standard sequence is: pick a service, pick a provider, pick a date and time, fill in contact details, then confirm. Each step can be enabled, disabled, and reordered independently. The web page reads that configuration and assembles the screens accordingly, so the same flow serves many kinds of business — clinics, salons, or any service that requires an appointment.
The interface is an accordion: completed steps collapse into a green summary line that can be tapped to go back and edit, the current step is expanded, and upcoming steps are dimmed. Users always see the shape of the whole flow.
Business Flow
- The user opens
/{hash}/booking/{token}, wheretokenidentifies the journey. - The app loads OA information from the hash, initializes LIFF, signs the user in if needed, and retrieves the ID token, access token, and profile.
- It loads the journey configuration from the API. The code accepts both a nested shape (journey, location, services, and staff as separate parts) and a flat one, for flexibility while the API's data shape is still settling.
- It computes which steps to show: if the configuration provides a list of objects, only enabled steps are kept and they are sorted by their specified order; if it provides a list of step names, those are used as-is; and if it provides nothing, a default sequence applies — service, date/time, information, and confirmation.
- Step by step:
- Service — choose from the services defined on the journey.
- Provider — choose a specific staff member, or leave it blank to let the system assign whoever is available.
- Date and time — the app fetches available dates, then the time slots for the chosen date. Once both are selected it advances automatically after a brief delay, so the user can see what was picked.
- Information — a form generated from the fields defined on the journey, able to prefill from the LINE profile. The next button stays disabled until every required field is filled.
- Confirmation — a summary of every selection with the confirm button.
- On confirm, the booking request is sent with the authentication headers, carrying the service, provider, date, start time, and the form data.
- On success, a confirmation screen summarizes the booking and offers a button to close the LIFF window.
- If the chosen slot was taken in the meantime (a 409), the app clears the selected date and time and returns the user to the date/time step with an explanatory message. Other errors route to the error screen.
Key Screens & Components
The booking page lives at /{hash}/booking/{token}, with components under
src/app/[hash]/booking/[token]/:
- Main page (
page.tsx) — drives the entire flow: computing the step sequence, determining which steps are complete, producing each step's summary text, choosing which component to render, and handling confirmation. Page state is one of loading, ready, success, or error. - Progress bar (
BookingProgress) — shows how far along the user is. - Per-step components —
ServiceStep,StaffStep,DateTimeStep,InfoStep, andConfirmStep. - Booking confirmation (
BookingConfirmation) — summarizes the booking and closes the LIFF window.
API calls are consolidated in src/service/appointment-booking.service.ts, covering
journey retrieval, available dates, time slots, and booking creation.
Endpoints Used
| Method | Path |
|---|---|
| GET | /appointment/public/{token} |
| GET | /appointment/public/{token}/dates?serviceId=&staffId=&daysAhead= |
| GET | /appointment/public/{token}/slots?date=&serviceId=&staffId= |
| POST | /appointment/public/{token}/book |
Dependencies
- LIFF SDK — this feature calls LIFF directly rather than going through the project's shared authentication hook, the same way the friend-tracking feature does.
- Ant Design — uses
Spinand the Ant Design icon set. - Styling is largely inline, using the LINE brand color as the primary accent so the page blends into the LINE context it opens in.
- The journey configuration has no formal type definitions yet, because the API's data shape is still changing. This is acknowledged technical debt to clean up once the API settles.
- The booking confirmation screen is the origin of the LIFF window-close pattern that was later adopted by the form thank-you page (see form-thank-you).
Backend Details (Client API)
The first three endpoints require no authentication
GET /appointment/public/:token, /slots, and /dates are read-only and
unauthenticated, unlike the booking endpoint. Each begins by resolving an active journey
from its public_token — not found → 404 "Journey not found" — then loads the location
(not found → 404 "Location not found") along with that location's active services and
staff.
A serviceId that cannot be parsed becomes 0, which matches no row and yields an empty
list rather than an error. The available-dates endpoint defaults daysAhead to 30,
counting from today inclusive, and formats dates as YYYY-MM-DD in UTC.
The slot engine
This is the heart of the read side — pure logic, testable independently of HTTP, running in seven steps:
- Load the location's configuration (working hours, blocked dates) — no row → empty list.
- The date falls on a blocked date → empty list.
- Determine the weekday by parsing the date as UTC midnight.
- Read the open and close times from a configuration that supports two shapes: an array of per-day entries, or an object keyed by day name, tolerating alternate field names. A day with no configuration, or one marked disabled → empty list.
- Load the service duration and the maximum bookings per slot — missing → empty list.
- Generate slots by stepping from the opening time in increments of the service duration, emitting only slots that finish before closing time (so a 90-minute service never produces a slot straddling closing).
- Count bookings that overlap each slot — not merely those starting at the same time. A full slot comes back marked unavailable with a reason; an open one carries the number of remaining places.
An important note: the engine ignores staff entirely. The staffId parameter is passed
in but never used. Whether a particular staff member is free is decided only at the
auto-assign step during booking — so a slot the page shows as "available" is available at
the location level, not for the chosen provider.
POST /appointment/public/:token/book (rate limited 5/60s per IP)
- Authentication — with an
x-liff-tokenpresent, the service verifies an access token (trying the value from the access-token header first, then falling back to the value inx-liff-token); with onlyx-liff-access-token, it uses that; with neither → 401"No authentication token provided".- Security note worth knowing: this path has no channel binding, unlike the bulletin board and loyalty card, which always bind the token to the OA's LINE Login channel first. Here the token is verified directly against the LINE Platform, matching the behaviour of the previous system.
- An empty body is not an error (every field takes its zero value), but malformed JSON →
400
"Invalid request body"— this route carries no DTO validation, matching the previous system. - Resolve the journey (not found → 404), then load the service by
serviceId(not found → 404"Service not found"). - Auto-assign a staff member when the booker chose none and the service requires one: fetch the staff eligible for the location, filter to those who perform this service (the service-list check accepts both numeric and string values, because the legacy data mixes the two), then take the first with no overlapping booking that day. If nobody is free, the booking still goes through with no staff attached rather than being refused.
- Re-check that the slot is still free using the same engine — taken → 409
"Selected time slot is no longer available". This is the guard against two people booking simultaneously, and the origin of the 409 the web page uses to send the user back to the date/time step. - Compute the end time from the start time plus the service duration.
- The initial status depends on the journey's auto-confirm setting: on →
confirmed, otherwisepending. - Insert the booking with its full context (journey, location, service, staff, user, OA, organization, date, start and end times, status, notes, and form data).
Side effects — two RabbitMQ events
After a successful insert, two messages are published best-effort, swallowing every error, so a RabbitMQ outage never fails a booking:
- The booking notification queue — carries the booking id and core context to the system that messages the customer.
- The event trigger queue — carries a
booking_createdevent type with richer detail (including the service name and the time window) to the automation system that binds workflows to bookings.
Both queues are consumed by the worker service, so a booking that succeeds while the customer receives no message is a symptom to chase on the worker side, not at this endpoint.
Other notable points
- The appointment routes have no per-organization app toggle middleware, unlike the
bulletin board and loyalty card, because the route is keyed by the journey's
:tokenrather than the OA's:hash— there is no point at which the middleware could resolve the organization. Disabling the appointment app from platform admin therefore does not close the public booking links. - The booking endpoint returns the stored booking entity with status 201.