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
+38 -1
View File
@@ -15,6 +15,7 @@ A group class can be marked **invite-only** (`us_offerings.access_mode = invite_
| `instructor_id`| BIGINT UNSIGNED | WordPress user ID (denormalised from the offering) |
| `status` | VARCHAR(20) | `active` / `cancelled` / `completed` |
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
| `enrolled_by` | BIGINT UNSIGNED | Staff member who added the student from wp-admin; 0 when the student (or their guardian) enrolled themselves |
| `enrolled_at` | DATETIME | Insertion time |
## Class Dates, Time, and Instructor
@@ -180,6 +181,8 @@ controls beneath it:
settled at once by `PaymentService`). No access grant is needed — this writes straight
to `us_group_enrollments` + `us_payments`. It bypasses the enrolment deadline and
capacity, so it doubles as the **late-enrolment** path after a class has closed.
The enrolment records who added them (`enrolled_by`), which is what later allows
its intake to be recorded — see **Recording Intake Collected Elsewhere**.
2. **Make available** — the selected registered students get an `invited` grant so the
class appears in their own group-class list; they then self-enrol through the normal
paid flow. Each is emailed a "you've been added" notice.
@@ -230,12 +233,46 @@ class becomes enrollable for them — they choose whether to enrol.
instructor. The summary (`templates/admin/my-group-classes.php`) and the details page
(`templates/admin/my-group-class-detail.php`) are separate templates.
## Recording Intake Collected Elsewhere
A student the studio added with **Add students directly** has no intake answers
and no policy acceptances: they were never shown the enrolment form. The answers
are collected another way — a paper form at the first class, a phone call to a
parent — and recorded afterwards from the **enrolment detail page**, reached from
the **Intake → View** link on each roster row.
The page shows who and what the enrolment is, the audit trail of everything
answered and agreed to so far, and — for a studio-made enrolment only — a
**Record intake collected elsewhere** panel offering whatever is still missing.
Every recording must say **how** it was collected (signed paper form / in person /
over the phone / by email / some other way, the last requiring an explanation),
and that is stamped on every row along with who entered it. Both audit tables
carry a **How it was given** column, so a policy ticked online and one transcribed
from paper never look alike.
**Only a studio-made enrolment qualifies** (`Enrollment::isStaffRegistered()`,
i.e. `enrolled_by > 0`). An enrolment the student made already holds their own
answers, and letting staff add to it would make the record editable after the
event. Nothing already recorded can be overwritten: the submission is narrowed to
what is genuinely still pending before anything is written, so a stale or
double-posted form is harmless.
This is the same mechanism the Scheduler uses for lessons it booked, and the
reasoning behind each rule — why no IP is stored, why `accepted_by` stays the
student while `recorded_by` names the staff member — is set out once in
**Recording Intake Collected Elsewhere** in `lesson-booking.md`. An enrolment is
its own registration, so unlike a weekly lesson series there is no anchor to
follow: one enrolment, one intake record, however many sessions the term holds.
Scoping matches the rest of the detail pages: an instructor may only open
enrolments in their own classes, a `view_all_lessons` studio admin any.
## Implementation
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
- Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`)
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
- Sessions: `Unsupervised\Schedular\GroupClass\SessionSchedule` (`upcomingForStudent`, `upcomingForInstructor`) — consumed by `Booking\BookingEndpoint::myLessons()` and `Auth\StudentController`
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController``renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`)
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController``renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`). Both also route `?enrollment_id=` to the enrolment detail view (`maybeRenderEnrollmentDetail`, template `templates/admin/enrollment-detail.php`)
- Intake audit + late recording: `Unsupervised\Schedular\Registration\IntakeAudit` and `IntakeRecording`, shared with lesson bookings. `Enrollment` implements `Registration\IntakeSubject` to take part
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute). In single-class mode `assets/js/group-classes.js` leaves the class description out of the card, since the page it is embedded on already describes the class; the schedule, instructor, schedule note, price and enrolment controls are still shown.
- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
+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
+11 -1
View File
@@ -36,7 +36,10 @@ The studio admin drafts, versions, and publishes policies (e.g. cancellation, pa
| `registration_type` | VARCHAR(20) | `lesson` or `enrollment` |
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.id` |
| `accepted_at` | DATETIME | Timestamp of acceptance |
| `ip_address` | VARCHAR(45) | IP captured at acceptance (audit trail) |
| `ip_address` | VARCHAR(45) | IP captured at acceptance (audit trail); NULL when not given online |
| `collected_via` | VARCHAR(20) | How the acceptance reached the studio when it was not given online (`paper` / `in_person` / `phone` / `email` / `other`); NULL means online |
| `collected_note` | VARCHAR(191) | Free-text detail for the above; required for `other` |
| `recorded_by` | BIGINT UNSIGNED | Staff member who typed a collected-elsewhere acceptance in; 0 otherwise |
## Versioning & Acceptance Rules
- Editing a published policy creates a new `draft` version; the old version stays `published` until the draft is published.
@@ -91,3 +94,10 @@ cover every policy's current version or the registration is rejected.
box, where that differs from `student_id` — a guardian agreeing on a child's
behalf. It defaults to 0, read back as "the student agreed for themselves"
(`PolicyAcceptance::acceptorOrStudent()`). See `parent-guardian-accounts.md`.
`recorded_by` answers a different question: who *entered* the acceptance, for one
collected on paper or over the phone and typed in afterwards. The student still
agreed, so `accepted_by` stays theirs; `collected_via` says how, and no IP is
stored because they were never at a browser. Only lessons the studio booked can
be recorded against — see **Recording Intake Collected Elsewhere** in
`lesson-booking.md`.
+3
View File
@@ -57,6 +57,9 @@ account-holder form to differ from.
| `question_id` | BIGINT UNSIGNED | FK → `us_questions.id` |
| `registration_type` | VARCHAR(20) | `lesson`, `enrollment`, or `account` |
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id`, `us_group_enrollments.id`, or the user ID (account scope) |
| `collected_via` | VARCHAR(20) | How the answer reached the studio when it was not given online (`paper` / `in_person` / `phone` / `email` / `other`); NULL means online |
| `collected_note` | VARCHAR(191) | Free-text detail for the above; required for `other` |
| `recorded_by` | BIGINT UNSIGNED | Staff member who typed a collected-elsewhere answer in; 0 otherwise |
| `student_id` | BIGINT UNSIGNED | WordPress user ID (denormalised for fast lookup) |
| `answer_value` | TEXT | The submitted answer (checkbox stored as `0`/`1`) |
| `created_at` | DATETIME | Insertion time |