Let the studio book lessons and record intake collected elsewhere #183

Merged
thatguygriff merged 1 commits from feature/182-admin-booking-and-intake-recording into main 2026-08-24 17:50:46 +00:00
Owner

Closes #182.

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

Book a lesson for a student

A collapsed panel at the top of Scheduler (every instructor's open times)
and My Lessons (the instructor's own — re-checked server-side, not trusted
from the posted slot id). Pick student → open time → lesson type, with
Reserve this time weekly for a term and No charge for a make-up or
goodwill lesson.

Rather than duplicate the booking logic, the core of BookingEndpoint::book()
moved to Booking\LessonBookerresolveOffering() (the rules that stop a
cheaper offering being substituted to dodge payment), reserve() (atomic slot
claim + lesson rows), settle() (payment, or confirm when nothing is owed).
Both POST /bookings and the admin form go through it, so they cannot drift.
The endpoint lost 165 lines and keeps its exact ordering.

A weekly reservation on a slot that does not repeat is refused
(not_weekly) rather than quietly booking one lesson, as the student-facing
flow does — whoever asked for a term should be told they are not getting one.

Record intake collected elsewhere

A staff-made registration has no intake answers and no policy acceptances,
because nobody was at a keyboard to give them. Ticking the boxes on the
student's behalf would be an audit trail that says something untrue, so the
answers are instead collected another way and recorded afterwards — from a
lesson's detail page, or from a new enrolment detail page reached by the
Intake → View link on each roster row.

Every recording must say how it was collected — paper form, in person,
phone, email, or some other way (which must be explained). That, plus who typed
it, is stamped on every row and shown in a new How it was given column
reading either "Given online when booking" or "On a signed paper form — Filed
in the studio binder — recorded by Jane Doe". That column is the point of the
feature.

Three guards:

  • Only staff-made registrations qualify (booked_by / enrolled_by). One
    the student made already holds their own answers, and adding to those would
    make the record editable after the event.
  • Only what is still missing can be recorded, re-checked at write time, so a
    stale or double-posted form can neither duplicate nor overwrite.
  • No IP is stored for a transcription (the student was never at a browser),
    and accepted_by stays the student — they did agree, on paper — while
    recorded_by names whoever entered it.

Nothing but the provenance is compulsory: a studio holding half the answers
records that half and comes back for the rest.

Shared, not duplicated

Intake is generic over Registration\IntakeSubject, implemented by
Booking\Lesson and GroupClass\Enrollment. The two differ where they should:
a lesson's intakeRegistrationId() returns series_id ?? id (a weekly series
is answered for once, at its anchor), an enrolment returns its own id — one
enrolment, one intake record, however many sessions the term holds.
Booking\LessonDetail became Registration\IntakeAudit and now serves both
detail views. The interface's intake prefix is not decoration: both models
already carry $studentId/$offeringId properties, and PHP 8.1 cannot declare
a property on an interface.

Schema

us_lessons.booked_by, us_group_enrollments.enrolled_by, and
collected_via / collected_note / recorded_by on both
us_question_answers and us_policy_acceptances. All default to null/0,
meaning "given online", so every existing row keeps its current meaning.

No version bump: 1.5.3 is untagged, so every site reaches it from ≤1.5.2
with a version change that re-runs dbDelta. Worth knowing if a 1.5.3 build is
already deployed somewhere — that install would need a bump to pick the columns
up.

Verification

composer test — 953 tests, 2746 assertions, green (from 915 on main).
composer lint (PHPStan) and composer cs (PHPCS) both clean.

New: AdminBookingTest (12), Registration\IntakeRecordingTest (9, including
an Enrollment subject proving the recorder is genuinely generic rather than
lesson code with a wider signature), plus controller tests for both detail
pages, both instructor scopes, and the refusal paths.

Not covered

Group-class enrolment still has no equivalent of the lesson booking panel
beyond the existing Add students directly — that already worked, and this
PR only gives it the intake half.

Closes #182. Two gaps closed together, because the second is created by the first. ## Book a lesson for a student A collapsed panel at the top of **Scheduler** (every instructor's open times) and **My Lessons** (the instructor's own — re-checked server-side, not trusted from the posted slot id). Pick student → open time → lesson type, with **Reserve this time weekly** for a term and **No charge** for a make-up or goodwill lesson. Rather than duplicate the booking logic, the core of `BookingEndpoint::book()` moved to `Booking\LessonBooker` — `resolveOffering()` (the rules that stop a cheaper offering being substituted to dodge payment), `reserve()` (atomic slot claim + lesson rows), `settle()` (payment, or confirm when nothing is owed). Both `POST /bookings` and the admin form go through it, so they cannot drift. The endpoint lost 165 lines and keeps its exact ordering. A weekly reservation on a slot that does not repeat is **refused** (`not_weekly`) rather than quietly booking one lesson, as the student-facing flow does — whoever asked for a term should be told they are not getting one. ## Record intake collected elsewhere A staff-made registration has no intake answers and no policy acceptances, because nobody was at a keyboard to give them. Ticking the boxes on the student's behalf would be an audit trail that says something untrue, so the answers are instead collected another way and recorded afterwards — from a lesson's detail page, or from a new enrolment detail page reached by the **Intake → View** link on each roster row. Every recording must say **how** it was collected — paper form, in person, phone, email, or some other way (which must be explained). That, plus who typed it, is stamped on every row and shown in a new **How it was given** column reading either "Given online when booking" or "On a signed paper form — Filed in the studio binder — recorded by Jane Doe". That column is the point of the feature. Three guards: - **Only staff-made registrations qualify** (`booked_by` / `enrolled_by`). One the student made already holds their own answers, and adding to those would make the record editable after the event. - **Only what is still missing can be recorded**, re-checked at write time, so a stale or double-posted form can neither duplicate nor overwrite. - **No IP is stored** for a transcription (the student was never at a browser), and `accepted_by` stays the student — they did agree, on paper — while `recorded_by` names whoever entered it. Nothing but the provenance is compulsory: a studio holding half the answers records that half and comes back for the rest. ## Shared, not duplicated Intake is generic over `Registration\IntakeSubject`, implemented by `Booking\Lesson` and `GroupClass\Enrollment`. The two differ where they should: a lesson's `intakeRegistrationId()` returns `series_id ?? id` (a weekly series is answered for once, at its anchor), an enrolment returns its own id — one enrolment, one intake record, however many sessions the term holds. `Booking\LessonDetail` became `Registration\IntakeAudit` and now serves both detail views. The interface's `intake` prefix is not decoration: both models already carry `$studentId`/`$offeringId` properties, and PHP 8.1 cannot declare a property on an interface. ## Schema `us_lessons.booked_by`, `us_group_enrollments.enrolled_by`, and `collected_via` / `collected_note` / `recorded_by` on both `us_question_answers` and `us_policy_acceptances`. All default to null/0, meaning "given online", so every existing row keeps its current meaning. **No version bump**: 1.5.3 is untagged, so every site reaches it from ≤1.5.2 with a version change that re-runs `dbDelta`. Worth knowing if a 1.5.3 build is already deployed somewhere — that install would need a bump to pick the columns up. ## Verification `composer test` — 953 tests, 2746 assertions, green (from 915 on main). `composer lint` (PHPStan) and `composer cs` (PHPCS) both clean. New: `AdminBookingTest` (12), `Registration\IntakeRecordingTest` (9, including an `Enrollment` subject proving the recorder is genuinely generic rather than lesson code with a wider signature), plus controller tests for both detail pages, both instructor scopes, and the refusal paths. ## Not covered Group-class **enrolment** still has no equivalent of the lesson booking panel beyond the existing **Add students directly** — that already worked, and this PR only gives it the intake half.
thatguygriff added 1 commit 2026-08-24 17:06:55 +00:00
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
8c21a3fa9d
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
thatguygriff merged commit 41843e5253 into main 2026-08-24 17:50:46 +00:00
thatguygriff deleted branch feature/182-admin-booking-and-intake-recording 2026-08-24 17:50:47 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Unsupervised/unsupervised-scheduler#183