Files
unsupervised-scheduler/docs/features/lesson-booking.md
T
thatguygriff b508ab92f8
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / PHPStan (pull_request) Successful in 2m54s
CI / Coding Standards (pull_request) Successful in 2m57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 2s
Stop the upcoming-lessons row collapsing onto itself
The lesson details and the actions rendered on top of each other. Three
things left the panel fragile, all fixed here.

The rules were bare class selectors while the visually identical
.us-slot row next to them was written as `#us-booking-app .us-slot`.
That inconsistency looks accidental, and it means a theme rule on
div/span/strong outranks the panel's layout and flattens it. Every
booking-page rule is now scoped under #us-booking-app.

The row's two columns were spans carrying display:flex, so the layout
only held while that declaration won. They are divs now — the layout no
longer depends on overriding the inline default.

The row had no flex-wrap and its title column no min-width:0, so a long
offering title could not shrink and shoved the status pill and Cancel
button out of the row. The 640px media query covered only the week
grid, leaving the busier lesson rows with no narrow-viewport handling at
all; they now stack details above actions.

BlockPreview mirrors the markup change so the editor preview matches.

Closes #133
2026-07-28 22:55:11 -03:00

180 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Feature: Lesson Booking
## Overview
Students register for a private lesson by choosing an offering, picking a time (or reserving a weekly slot for the term), answering the offering's intake questions, accepting current policies, and paying. Instructors confirm or cancel from wp-admin or via the REST API. A lesson becomes `confirmed` only once its payment is `paid` (or the student is comp'd).
## Data Model — `{prefix}us_lessons`
| Column | Type | Notes |
|----------------|------------------|-------------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `slot_id` | BIGINT UNSIGNED | FK → `us_availability.id` |
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` (the private-lesson type booked) |
| `student_id` | BIGINT UNSIGNED | WordPress user ID |
| `instructor_id`| BIGINT UNSIGNED | WordPress user ID (denormalised for fast queries) |
| `recurrence` | VARCHAR(10) | `single` or `weekly` |
| `series_id` | BIGINT UNSIGNED | Nullable — groups the lesson rows of one weekly reservation |
| `status` | VARCHAR(20) | `pending` / `confirmed` / `cancelled` |
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
| `notes` | TEXT | Optional student notes |
| `created_at` | DATETIME | Insertion time |
## Registration Flow
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form). A **Show Only** button beside the view toggle opens a lesson-type filter that narrows the open times to those bookable as the chosen types (see **Lesson-Type Filter**).
2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot, narrowed to the filtered types. When exactly one type remains it is pre-selected (its intake questions load immediately). Every booking requires an offering — a generic slot with no fitting offering cannot be booked online.
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
6. Student is shown what the booking costs — the offering's price with its **cadence** (at booking / up front / weekly / monthly), plus HST — and must tick a second, separate agreement to pay that amount before the form will submit. A weekly reservation quotes the per-lesson fee and the ceiling on the total it can claim. A free offering shows no price block. See **Price Display and the Pay Agreement** in `payments.md`.
7. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
8. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
10. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
11. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
## Lesson-Type Filter
Not every open slot can be booked as every private-lesson type — a slot tied to
an offering takes that offering only, and a generic slot only takes types whose
length fits. The booking calendar therefore carries a lesson-type filter,
collapsed behind a **Show Only** button that sits in the calendar's control row
beside the List/Week toggle. Opening it reveals the type list between that row
and the calendar: a checkbox per active private-lesson type (from
`GET /offerings?kind=private_lesson`, fetched once per page load), showing the
instructor's name alongside the title when the catalog spans more than one
instructor. The button carries the number of ticked types and stays highlighted
while the filter is on, so a collapsed filter is never invisible. Both button and
list are hidden when there is only one bookable type.
Ticking one or more types narrows the calendar to the slots bookable as one of
them; no ticks means no filter, and collapsing the list leaves the filter
applied. Picking a filtered slot narrows the registration form's **Lesson type**
picker the same way, and when exactly one type remains it is pre-selected and its
intake questions load immediately. Changing the filter re-anchors the week view
on the earliest matching slot, so the student never lands on an empty week.
**Show all types** clears the filter.
Bookability is decided client-side by `offeringFitsSlot()` in
`assets/js/booking.js` — the mirror of the rule `POST /bookings` enforces (same
instructor, the tied offering when there is one, otherwise a matching
`duration_minutes`). The filter is a browsing aid only: the server re-checks
every booking regardless.
Two block/shortcode options change what the filter has to work with (see
`editor-blocks.md`), passed to the script as data attributes on
`#us-booking-app`:
- **A pinned lesson type** (`data-lesson-type`) narrows the catalog to that one
offering, so the page lists only the times bookable as it and books nothing
else — the filter control hides itself, there being one type left. A pinned
type that is no longer offered shows "This lesson type is not available for
booking right now" rather than an empty calendar.
- **Filter off** (`data-type-filter="0"`) drops the **Show Only** button
entirely; every open time is listed, as before the filter existed.
## Embedding Halves of the Page
The page has two halves — the booking calendar and the student's upcoming
lessons — and the block/shortcode can embed either on its own (`displayMode` /
`show`: `both` (default), `booking`, `upcoming`). The template simply omits the
containers of the half that is not wanted, and the script skips the work that
belongs to a missing container: an upcoming-only embed never requests
availability or the offering catalog, and a booking-only embed never requests
`GET /bookings`. An unrecognised value renders the whole page, so a typo cannot
silently hide half of it.
## Cancellation
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
Cancelling marks the lesson `cancelled`, frees the availability slot for
rebooking, and voids a still-pending payment (marked `failed` so it leaves the
admin confirmation queue). A `paid` payment is never touched — refunds are a
manual, admin-side decision. Instructors cancelling via
`PATCH /bookings/{id}/status` get the same slot release and payment voiding;
reinstating a cancelled lesson re-claims its slot and fails with `409
slot_taken` if the freed time was booked by someone else in the meantime.
## Weekly Reservations
A weekly reservation creates one `series_id` shared across N lesson rows (one per
week in the term) and reserves the matching availability windows. It is billed
**upfront as a single payment** linked to the series' first (anchor) lesson:
- `billing_mode = full_term` — the offering's price already covers the term and is charged once.
- `billing_mode = one_time` — the per-lesson price is charged **once per occurrence actually claimed** (price × N).
Settling that payment (Stripe webhook, e-transfer confirmation, comp) confirms
**every non-cancelled lesson in the series**
(`BookingRepository::updateStatusForSeries()`), not just the anchor row.
## REST API
| Method | Endpoint | Permission |
|-----------|-------------------------------------------------|--------------------------------|
| `GET` | `/wp-json/us-scheduler/v1/bookings` | Any logged-in user |
| `POST` | `/wp-json/us-scheduler/v1/bookings` | `book_lesson` |
| `POST` | `/wp-json/us-scheduler/v1/bookings/{id}/cancel` | Logged-in owner of the lesson |
| `PATCH` | `/wp-json/us-scheduler/v1/bookings/{id}/status` | `manage_availability` or admin |
`POST /bookings` body: `offering_id`, `slot_id`, `recurrence`, `answers[]`
(`question_id` → value), `accepted_policy_version_ids[]`, and payment data
(see `payments.md`). The response includes `ids`, the resulting lesson
`status`, and `payment` — a `{id, method, status}` summary, or `null` when
nothing is owed (the front end then skips the payment step).
An offering is always required (`400 offering_required` otherwise): a slot tied
to an offering uses that offering regardless of the request, while a generic
slot uses the student's `offering_id`, which must be one of the instructor's
active `private_lesson` offerings whose `duration_minutes` matches the slot.
`GET /bookings` returns the caller's upcoming, non-cancelled lessons (their own
for students; the instructor's for callers with `manage_availability`), each
with the slot's `start_dt`/`end_dt`.
Group classes follow the same registration flow but enrol against an offering of
kind `group_class`; see `group-classes.md`.
## Admin Interface
- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors
- **My Lessons** (`view_own_lessons`): upcoming lessons for the logged-in instructor. Hidden for users who also hold `view_all_lessons` — Scheduler is a superset, so the menu item would only duplicate it.
Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week`
query params, same pattern as the availability page, bucketed via
`Availability\WeekCalendar`), with the original table available as the **List**
view — the list is where the per-lesson HST and e-transfer edit forms live. Both
views show the booked offering's name, and each lesson links through (`?lesson_id=`)
to a **detail view** (`LessonController::maybeRenderDetail()`) that shows the
offering, time, status, notes, the policy versions the student accepted (with
acceptance time and IP), and their intake-question answers. On **My Lessons** an
instructor may only open their own lessons; the studio **Scheduler** may open any.
## Frontend Shortcodes
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability. Attributes: `login_page_id`, `lesson_type` (pin one private-lesson offering), `show_filter` (`no` hides the **Show Only** filter), `show` (`both` / `booking` / `upcoming`)
- `[us_student_login]` — front-end login form for students
## Implementation
- Repository: `Unsupervised\Schedular\Booking\BookingRepository` (`insertSeries()` builds a weekly series sharing a `series_id`)
- Model: `Unsupervised\Schedular\Booking\Lesson`
- Registration gate: `Unsupervised\Schedular\Registration\RegistrationGate` — validates and records intake answers + booking-scoped policy acceptances; shared with group enrolment
- Admin controller: `Unsupervised\Schedular\Booking\LessonController`
- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
- Upcoming-lessons panel: rendered client-side into `#us-my-lessons` by `assets/js/booking.js` (`lessonRowHtml`/`renderMyLessons`), mirrored for the editor by `BlockPreview::upcomingLessons()` — keep the two markup shapes in step.
> **Payment seam:** a priced booking is created with `status = pending` and its
> payment linked via `payment_id`; the lesson is confirmed when the payment is
> settled (see `payments.md`) or manually via `PATCH /bookings/{id}/status`.
> Unpriced bookings skip the seam entirely and are confirmed at creation.
> `GET /policies?scope=booking` returns just the booking-gate policies the form
> must collect.
>
> **Frontend CSS scoping:** every rule for the booking page's own markup is
> written under `#us-booking-app` (`assets/css/frontend.css`). These panels sit
> inside whatever layout the active theme provides, and bare class selectors lose
> to theme rules on `div`/`span`/`strong` — which flattens the flex layout and
> renders the lesson details on top of the actions. The row's two columns are
> `div`s for the same reason: the layout must not depend on overriding the
> inline default. New booking-page rules should follow both conventions.
## Tests
- `tests/Unit/Booking/BookingRepositoryTest.php`
- `tests/Unit/Booking/LessonTest.php`
- `tests/Unit/Booking/LessonControllerTest.php`
- `tests/Unit/Booking/LessonDetailTest.php`
- `tests/Unit/Booking/BookingEndpointTest.php`