Let the studio book lessons and record intake collected elsewhere
CI / Tests (PHP 8.1) (pull_request) Successful in 6m39s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m59s
CI / Tests (PHP 8.5) (pull_request) Successful in 3m31s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m28s
CI / Build Plugin Zip (pull_request) Skipped

Two related gaps, closed together because the second is created by the first.

A private lesson could only be booked by the student or their guardian, so a
booking taken over the phone had no way in — where group classes have had "Add
students directly" all along. "Book a lesson for a student" is now a panel on
Scheduler and My Lessons: student, open time, lesson type, with weekly term
reservations and a no-charge option for make-up lessons. The booking core is
extracted to Booking\LessonBooker and shared with POST /bookings, so the two
paths cannot drift on offering rules, slot claiming, or billing.

That leaves a registration with no intake answers and no policy acceptances,
because nobody was at a keyboard to give them — already true of every directly
added group-class student. Ticking the boxes on a student's behalf would be an
audit trail that says something untrue, so instead the answers are collected
another way and recorded afterwards, from a lesson's or an enrolment's detail
page. Every recording must say how it was collected, which is stamped on each
row along with who typed it and shown in a new "How it was given" column: a
policy ticked online and one transcribed from paper must never look alike.

Only staff-made registrations qualify (us_lessons.booked_by,
us_group_enrollments.enrolled_by) — one the student made already holds their
own answers. Only what is still missing can be recorded, re-checked at write
time, so a stale or double-posted form cannot duplicate or overwrite. No IP is
stored for a transcription, and accepted_by stays the student while recorded_by
names the staff member.

Intake is now generic over Registration\IntakeSubject, which Lesson and
Enrollment both implement; LessonDetail became Registration\IntakeAudit and is
shared by both detail views rather than duplicated.

Closes #182

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QfHt6CyJHz6KkA4RuaS7WK
This commit is contained in:
2026-08-24 14:06:16 -03:00
co-authored by Claude Opus 5
parent 8a34ec41e9
commit 8c21a3fa9d
46 changed files with 3020 additions and 306 deletions
+94 -4
View File
@@ -17,6 +17,7 @@ Students register for a private lesson by choosing an offering, picking a time (
| `status` | VARCHAR(20) | `pending` / `confirmed` / `cancelled` |
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
| `notes` | TEXT | Optional student notes |
| `booked_by` | BIGINT UNSIGNED | Staff member who booked it for the student; 0 when the student (or their guardian) booked it themselves |
| `created_at` | DATETIME | Insertion time |
## Registration Flow
@@ -82,6 +83,90 @@ 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.
## Booking For A Student (Admin)
A guardian can book for their children, but nobody else can book for anyone —
which leaves the studio unable to take a booking over the phone, and an
instructor unable to slot in a make-up lesson. **Book a lesson for a student**,
a collapsed panel at the top of both **Scheduler** and **My Lessons**, is the
private-lesson counterpart to the group class's **Add students directly**.
Pick the student, an open time, and (for a general time) the lesson type; tick
**Reserve this time weekly** for a term, **No charge** for a make-up or goodwill
lesson. The times offered are the open slots of the next eight weeks — every
instructor's on the studio **Scheduler**, only the instructor's own on **My
Lessons**, which `AdminBooking::book()` re-checks rather than trusting the
posted slot id. The result is reported as a notice above the panel saying what
was booked and what it left owing; a refusal reopens the panel with the reason.
It is the same booking a student makes — `LessonBooker` claims the slot(s),
writes the lesson row(s), and raises the payment exactly as `POST /bookings`
does — and differs in three deliberate ways:
1. **No intake answers or policy acceptances are recorded at booking time.**
Those are the student's to give; staff ticking the boxes for them would be an
audit trail that says something untrue. They can instead be collected some
other way and recorded afterwards — see **Recording Intake Collected
Elsewhere**.
2. **It is not bounded by what the student could book themselves**, the way a
direct group-class enrolment bypasses the enrolment deadline.
3. **It can be booked at no charge** — no payment at all, and the lesson (or
whole series) is `confirmed` at once. Without the tick a pending payment is
raised at the lesson type's price, per-occurrence for a weekly reservation,
and the lesson confirms when it settles like any other.
A weekly reservation needs a time that actually repeats: asked for one on a
one-off slot, the form refuses (`not_weekly`) rather than quietly booking a
single lesson, since the person booking asked for a term and would otherwise
find out from the roster.
## Recording Intake Collected Elsewhere
A lesson the studio booked has no intake answers and no policy acceptances,
because nobody was at a keyboard to give them. The studio collects them another
way — a paper form at the first lesson, a phone call — and records them
afterwards from the lesson's **detail page**: a **Record intake collected
elsewhere** panel below the two audit tables.
**Only a staff-booked lesson has the panel** (`Lesson::isStaffRegistered()`, i.e.
`booked_by > 0`). A lesson the student booked already carries their own answers,
and letting staff add to them would make the record editable after the fact. The
same instructor/studio scoping as the rest of the detail page applies: an
instructor may only open their own lessons, the studio **Scheduler** any.
The panel offers **only what is still missing** — questions with no answer,
current policy versions with no acceptance — and narrows the submission to that
set again before writing, so a stale or double-posted form can neither duplicate
a row nor overwrite one. Nothing is compulsory except the provenance: a studio
holding half the answers records the half it has and comes back for the rest.
### How they were collected
Every recording must say **how** the answers reached the studio — on a signed
paper form, in person, over the phone, by email, or some other way (which must be
explained in the accompanying note). The method and note are stamped on every row
the recording writes, alongside **who typed it in**, and both audit tables carry a
**How it was given** column reading either "Given online when booking" or, say,
"On a signed paper form — Filed in the studio binder — recorded by Jane Doe".
That column is the point of the feature. "Accepted on 24 Aug" means one thing
when a student ticked a box and quite another when a staff member transcribed it,
and an audit trail that cannot tell them apart is worse than none, because it
looks like one.
Two details keep the record honest:
- **No IP address is stored.** The student was never at a browser; borrowing the
staff member's would put a false location in the trail.
- **The acceptance stays in the student's name** (`accepted_by`) — they did agree,
on paper or over the phone. `recorded_by` is who entered it, which is a
different question and gets a different column.
A weekly reservation is answered for once, so a recording made against any
occurrence lands on the series anchor (`Lesson::intakeRegistrationId()`) and
shows on every occurrence — the same rule the display side already follows.
The whole mechanism is shared with group-class enrolments, which have the same
gap for the same reason; see **Recording Intake Collected Elsewhere** in
`group-classes.md`.
## Cancellation
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
Cancelling marks the lesson `cancelled`, frees the availability slot for
@@ -138,8 +223,8 @@ 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 — and upcoming sessions of the instructor's own group classes — 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.
- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors, plus the **Book a lesson for a student** panel (see below), which reaches every instructor's open times
- **My Lessons** (`view_own_lessons`): upcoming lessons — and upcoming sessions of the instructor's own group classes — for the logged-in instructor, plus the same **Book a lesson for a student** panel scoped to their own open times. 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
@@ -157,10 +242,14 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
## Implementation
- Repository: `Unsupervised\Schedular\Booking\BookingRepository` (`insertSeries()` builds a weekly series sharing a `series_id`)
- Booking core: `Unsupervised\Schedular\Booking\LessonBooker``resolveOffering()` (which offering a slot may be booked as), `reserve()` (claim the slot(s), write the lesson row(s)), `settle()` (raise the payment, or confirm when nothing is owed). Shared by `BookingEndpoint` and `AdminBooking` so the two paths cannot drift on price, payment routing, or double-booking.
- Admin booking: `Unsupervised\Schedular\Booking\AdminBooking``book()` (guards, then the booker) and `formData()` (the panel's student / time / lesson-type choices)
- Late intake: `Unsupervised\Schedular\Registration\IntakeRecording``pending()` (what is still unrecorded) and `record()` (the staff-registered guard, the dedup, then `RegistrationGate::record()` with an `IntakeProvenance`). Generic over `Registration\IntakeSubject`, which `Booking\Lesson` and `GroupClass\Enrollment` both implement
- Provenance: `Unsupervised\Schedular\Registration\IntakeProvenance` — the collection-method vocabulary, its validation, and how a stored row reads on screen. Persisted as `collected_via` / `collected_note` / `recorded_by` on both `us_question_answers` and `us_policy_acceptances`; all null/0 for anything given online.
- 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`. A weekly series is answered for and agreed to once, against the anchor lesson, so the presenter reads `series_id ?? id` — every occurrence shows the same intake and audit trail, not just the first.
- Admin lesson detail presenter: `Unsupervised\Schedular\Registration\IntakeAudit` (a registration's intake answers + policy acceptances), template `templates/admin/lesson-detail.php`. Shared with the group-class enrolment detail view. A weekly series is answered for and agreed to once, against the anchor lesson, so `Lesson::intakeRegistrationId()` reads `series_id ?? id` — every occurrence shows the same intake and audit trail, not just the first.
- 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.
@@ -181,10 +270,11 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
> inline default. New booking-page rules should follow both conventions.
## Tests
- `tests/Unit/Booking/AdminBookingTest.php`
- `tests/Unit/Registration/IntakeRecordingTest.php`, `tests/Unit/Registration/IntakeAuditTest.php`
- `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