Files
unsupervised-scheduler/docs/features/lesson-booking.md
T
thatguygriffandClaude Opus 4.8 32619a1b75
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
Show booked lesson info on upcoming lists and add admin booking detail
Front end: the student "upcoming lessons" panel now shows each booked
offering's name and length next to the time, and renders only the soonest
five lessons with a "Show all" reveal. GET /bookings returns offering_title
and duration_minutes so the list needs no extra request.

Admin: the Scheduler and My Lessons week/list views now show the booked
offering, and each lesson links to a detail view showing the policy versions
the student accepted (with acceptance time and IP) and their intake answers.
On My Lessons an instructor may only open their own lessons; the studio
Scheduler may open any.

composer test / composer lint / composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 11:07:06 -03:00

121 lines
9.1 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).
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. 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.
## 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`