A parent registers once and manages lessons for one or more children, who need no login of their own. A child is a real wp_users row with the student role but no usable login — so student_id keeps meaning "a WordPress user" on every table, and booking, credits, policies and enrolments work unchanged. A us_guardians link table maps guardian to child. The signup form gains a parent/guardian tick that reveals a block per child, with the account-signup questions asked per child rather than per guardian — they describe the student, not the account holder. Signup policies are recorded once per child with the guardian as the acceptor, which is the record that actually means something. A family that half-creates is rolled back entirely rather than leaving a guardian who cannot re-register. The booking and enrolment forms gain a "Who is this for?" picker listing children first, so the default selection is never the parent — booking for the wrong child is correctable, quietly billing a parent for their kid's lesson is not. POST /bookings and POST /enrollments take an optional student_id honoured only for that child's guardian; anything else is a 403. That check is the authorisation boundary of the feature. Payments and credits gain a payer: the charge names the child it was for and the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step reach the parent. Credit is held by the payer, so one child's cancellation can settle a sibling's charge, and the daily billing scan sends a guardian one notice covering every child. Closes #132 Co-Authored-By: Claude Opus 5 <[email protected]>
189 lines
14 KiB
Markdown
189 lines
14 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. 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`
|
||
|
||
## Booking For Someone Else
|
||
A guardian books for their children from their own account. `POST /bookings`
|
||
accepts an optional **`student_id`**, honoured only when
|
||
`Guardian\GuardianService::canActFor()` confirms the caller is that student's
|
||
guardian — anything else is a `403`. The booking form's "Who is this for?" picker
|
||
lists **children first**, so the default selection is never the parent.
|
||
`GET /bookings` returns the whole household, and a guardian may cancel any of
|
||
their children's lessons. See `parent-guardian-accounts.md`.
|