CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / Tests (PHP 8.2) (pull_request) Successful in 54s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m52s
CI / PHPStan (pull_request) Successful in 3m3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
The filter took a row of the booking calendar before a student had asked for it. The view toggle and a new "Show Only" button now share one control row, and the lesson-type list is revealed between that row and the calendar. The list stays open across re-renders once revealed, and collapsing it leaves the filter applied — the button keeps its active styling and carries the number of ticked types, so a collapsed filter is never invisible. Closes #119 Co-Authored-By: Claude Opus 5 <[email protected]>
148 lines
11 KiB
Markdown
148 lines
11 KiB
Markdown
# 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. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||
7. `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.
|
||
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||
10. 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.
|
||
|
||
## 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
|
||
- `[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`
|
||
|
||
> **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.
|
||
|
||
## 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`
|