Not every open time 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. Students had no way to see that before clicking a time. The booking calendar now carries a lesson-type filter — a checkbox per active private-lesson type, fetched once from GET /offerings?kind=private_lesson. Ticking types narrows the calendar to the times bookable as one of them and re-anchors the week view on the earliest match. The registration form's Lesson type picker is narrowed the same way, and a lone remaining type is pre-selected with its intake questions loaded. Bookability is decided by offeringFitsSlot(), the client-side mirror of the rule POST /bookings enforces; the filter is a browsing aid and the server still validates every booking. No ticks means no filter, and the whole control is hidden when the studio offers fewer than two private-lesson types. Closes #117 Co-Authored-By: Claude Opus 5 <[email protected]>
11 KiB
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
- 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 lesson-type filter above the calendar narrows the open times to those bookable as the chosen types (see Lesson-Type Filter). - 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.
- For a
weeklyreservation, the same weekday/time is held for the rest of the offering's term. - Student answers the offering's questions (
GET /offerings/{id}/questions). - Student accepts the current published policy versions (
GET /policies) — required to continue. - Payment is taken per the student's billing method (card by default;
pendingfor e-transfer; skipped for comp). Seepayments.md. POST /bookingscreates the lesson row(s) (status = pending), records answers and policy acceptances, marksus_availability.is_booked = 1, and links the payment. A booking with nothing owed (a free offering) creates no payment and isconfirmedimmediately.- On successful payment (or comp) the lesson is
confirmedand a receipt is emailed. - Instructor sees the booking under My Lessons and may update status via
PATCH /bookings/{id}/status. - 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 /bookingsincludesoffering_titleandduration_minutesfor 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 above
the view toggle: 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 filter is 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. 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 holdview_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; requiresbook_lessoncapability[us_student_login]— front-end login form for students
Implementation
- Repository:
Unsupervised\Schedular\Booking\BookingRepository(insertSeries()builds a weekly series sharing aseries_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), templatetemplates/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 = pendingand its payment linked viapayment_id; the lesson is confirmed when the payment is settled (seepayments.md) or manually viaPATCH /bookings/{id}/status. Unpriced bookings skip the seam entirely and are confirmed at creation.GET /policies?scope=bookingreturns just the booking-gate policies the form must collect.
Tests
tests/Unit/Booking/BookingRepositoryTest.phptests/Unit/Booking/LessonTest.phptests/Unit/Booking/LessonControllerTest.phptests/Unit/Booking/LessonDetailTest.phptests/Unit/Booking/BookingEndpointTest.php