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
+15
View File
@@ -25,6 +25,15 @@ class Answer {
public readonly int $registrationId,
public readonly int $studentId,
public readonly ?string $answerValue = null,
/**
* How this answer reached the studio when it did not come from the booking
* form — see {@see IntakeProvenance}. Null is the ordinary case: the student
* typed it in themselves.
*/
public readonly ?string $collectedVia = null,
public readonly ?string $collectedNote = null,
/** The staff member who typed it in, when somebody did. */
public readonly int $recordedBy = 0,
public readonly ?int $id = null,
) {}
@@ -35,6 +44,9 @@ class Answer {
registrationId: Val::int( $row->registration_id ),
studentId: Val::int( $row->student_id ),
answerValue: Val::stringOrNull( $row->answer_value ),
collectedVia: Val::stringOrNull( $row->collected_via ?? null ),
collectedNote: Val::stringOrNull( $row->collected_note ?? null ),
recordedBy: Val::int( $row->recorded_by ?? 0 ),
id: Val::int( $row->id ),
);
}
@@ -52,6 +64,9 @@ class Answer {
'registration_id' => $this->registrationId,
'student_id' => $this->studentId,
'answer_value' => $this->answerValue,
'collected_via' => $this->collectedVia,
'collected_note' => $this->collectedNote,
'recorded_by' => $this->recordedBy,
];
}
}
+4 -1
View File
@@ -20,9 +20,12 @@ class AnswerRepository {
'registration_id' => $answer->registrationId,
'student_id' => $answer->studentId,
'answer_value' => $answer->answerValue,
'collected_via' => $answer->collectedVia,
'collected_note' => $answer->collectedNote,
'recorded_by' => $answer->recordedBy,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%s', '%d', '%d', '%s', '%s' ]
[ '%d', '%s', '%d', '%d', '%s', '%s', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
/**
* Builds the display rows for one registration's audit trail: the intake answers
* the student gave and the policy versions they accepted. Used by the admin
* lesson detail view and the group-class enrolment detail view alike, mirroring
* the per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
*
* Which rows belong to the registration is the subject's own business
* ({@see IntakeSubject::intakeRegistrationId()}) — notably, a weekly lesson
* series is answered for and agreed to once, against its anchor, so every
* occurrence reads the same trail rather than only the first looking answered.
*/
class IntakeAudit {
public function __construct(
private AnswerRepository $answers,
private QuestionRepository $questions,
private AcceptanceRepository $acceptances,
private PolicyRepository $policies,
private PolicyVersionRepository $versions,
) {}
/**
* The intake-question answers recorded for this registration, in submission
* order.
*
* @return list<array{question: string, answer: string, source: string}>
*/
public function answers( IntakeSubject $subject ): array {
return array_map(
function ( Answer $answer ): array {
$question = $this->questions->findById( $answer->questionId );
$value = $answer->answerValue ?? '';
return [
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
'answer' => '' === $value ? '—' : $value,
'source' => $this->source( $answer->collectedVia, $answer->collectedNote, $answer->recordedBy ),
];
},
$this->answers->findByRegistration( $subject->intakeRegistrationType(), $subject->intakeRegistrationId() )
);
}
/**
* Where a recorded row came from: given online by the student, or collected
* some other way and typed in — in which case who typed it is named, since an
* unattributed transcription is worth much less than an attributed one.
*/
private function source( ?string $collectedVia, ?string $collectedNote, int $recordedBy ): string {
$described = IntakeProvenance::describe( $collectedVia, $collectedNote );
if ( null === $collectedVia || '' === $collectedVia || $recordedBy <= 0 ) {
return $described;
}
$user = get_userdata( $recordedBy );
return sprintf(
/* translators: 1: how the answer was collected, 2: name of the staff member who recorded it. */
__( '%1$s — recorded by %2$s', 'unsupervised-schedular' ),
$described,
UserName::format( $user instanceof \WP_User ? $user : null, $recordedBy )
);
}
/**
* The policy versions the student accepted for this registration, with the
* captured acceptance time and IP for the audit trail.
*
* @return list<array{policy: string, version: string, accepted_at: string, ip: string, source: string}>
*/
public function acceptances( IntakeSubject $subject ): array {
return array_map(
function ( PolicyAcceptance $acceptance ): array {
$version = $this->versions->findById( $acceptance->policyVersionId );
$policy = $version ? $this->policies->findById( $version->policyId ) : null;
return [
'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ),
'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
'accepted_at' => $acceptance->acceptedAt ?? '',
'ip' => $acceptance->ipAddress ?? '',
'source' => $this->source( $acceptance->collectedVia, $acceptance->collectedNote, $acceptance->recordedBy ),
];
},
$this->acceptances->findByRegistration( $subject->intakeRegistrationType(), $subject->intakeRegistrationId() )
);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
/**
* Where an intake answer or policy acceptance came from, when it did not come
* from the student filling in the booking form.
*
* A lesson the studio booked on someone's behalf has no answers and no
* acceptances — nobody was at a keyboard to give them — so they are collected
* some other way and typed in afterwards. What makes that record worth keeping
* is knowing *how*: "accepted on 24 Aug" means one thing when a student ticked a
* box and quite another when a staff member read it off a signed form, and an
* audit trail that cannot tell them apart is worse than no audit trail, because
* it looks like one.
*
* Absent (null) provenance is therefore meaningful in its own right: it is the
* ordinary case of the student answering online.
*/
class IntakeProvenance {
public const VIA_PAPER = 'paper';
public const VIA_IN_PERSON = 'in_person';
public const VIA_PHONE = 'phone';
public const VIA_EMAIL = 'email';
public const VIA_OTHER = 'other';
/**
* How the answers can have reached the studio. `other` exists so the list
* never forces a lie, and is the one option that must be explained.
*
* @var list<string>
*/
public const VALID_METHODS = [ self::VIA_PAPER, self::VIA_IN_PERSON, self::VIA_PHONE, self::VIA_EMAIL, self::VIA_OTHER ];
/** Longest note the `collected_note` VARCHAR(191) column holds. */
public const MAX_NOTE_LENGTH = 191;
public function __construct(
public readonly string $collectedVia,
public readonly ?string $collectedNote = null,
public readonly int $recordedBy = 0,
) {}
/**
* Build from submitted values, or explain what is wrong with them. A method
* outside the vocabulary is rejected rather than stored: a column that can say
* anything says nothing. `other` requires the note, since "other" on its own
* answers the question with the question.
*/
public static function fromInput( string $collectedVia, string $collectedNote, int $recordedBy ): self|\WP_Error {
if ( ! in_array( $collectedVia, self::VALID_METHODS, true ) ) {
return new \WP_Error( 'invalid_collection_method', __( 'Choose how these were collected.', 'unsupervised-schedular' ) );
}
$note = trim( $collectedNote );
if ( self::VIA_OTHER === $collectedVia && '' === $note ) {
return new \WP_Error( 'collection_note_required', __( 'Say how these were collected.', 'unsupervised-schedular' ) );
}
return new self(
collectedVia: $collectedVia,
collectedNote: '' !== $note ? mb_substr( $note, 0, self::MAX_NOTE_LENGTH ) : null,
recordedBy: $recordedBy,
);
}
/**
* The methods as `value => label`, for the form's picker and for reading a
* stored value back on screen.
*
* @return array<string, string>
*/
public static function choices(): array {
return [
self::VIA_PAPER => __( 'On a signed paper form', 'unsupervised-schedular' ),
self::VIA_IN_PERSON => __( 'In person', 'unsupervised-schedular' ),
self::VIA_PHONE => __( 'Over the phone', 'unsupervised-schedular' ),
self::VIA_EMAIL => __( 'By email', 'unsupervised-schedular' ),
self::VIA_OTHER => __( 'Some other way', 'unsupervised-schedular' ),
];
}
/**
* How a stored row reads on screen: the method's label, plus its note. An
* empty method is the ordinary case — the student answered online — and says
* so rather than showing a blank cell.
*/
public static function describe( ?string $collectedVia, ?string $collectedNote = null ): string {
if ( null === $collectedVia || '' === $collectedVia ) {
return __( 'Given online when booking', 'unsupervised-schedular' );
}
$label = self::choices()[ $collectedVia ] ?? $collectedVia;
$note = null !== $collectedNote ? trim( $collectedNote ) : '';
return '' !== $note ? $label . ' — ' . $note : $label;
}
}
+194
View File
@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
/**
* Recording, after the fact, the intake answers and policy acceptances of a
* registration the studio made on a student's behalf — a lesson booked from the
* Scheduler, a student added straight into a group class.
*
* Such a registration has neither: nobody was at a keyboard to answer the
* questions or tick the boxes, and staff doing it *for* the student at the time
* would be an audit trail that says something untrue. The answers are instead
* collected some other way — a paper form, a phone call — and typed in here, each
* row stamped with how it was obtained ({@see IntakeProvenance}), so a reader can
* always tell a student's own click from a studio's transcription.
*
* Two rules hold this honest:
*
* 1. **Only a staff-made registration qualifies**
* ({@see IntakeSubject::isStaffRegistered()}). One the student made already
* has their real answers, and letting staff add more would let the record be
* edited after the fact.
* 2. **Only what is still missing can be recorded.** Answers and acceptances are
* written once and never overwritten, so a second submission cannot quietly
* replace what a student actually said.
*/
class IntakeRecording {
public function __construct(
private QuestionRepository $questions,
private AnswerRepository $answers,
private PolicyRepository $policies,
private PolicyVersionRepository $versions,
private AcceptanceRepository $acceptances,
private RegistrationGate $gate,
) {}
/**
* What is still unrecorded for this registration: the intake questions with no
* answer, and the current policy versions with no acceptance. An empty pair
* means there is nothing left to collect and the form has nothing to show.
*
* @return array{questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>}
*/
public function pending( IntakeSubject $subject ): array {
$type = $subject->intakeRegistrationType();
$registrationId = $subject->intakeRegistrationId();
$answered = array_map(
static fn( Answer $a ): int => $a->questionId,
$this->answers->findByRegistration( $type, $registrationId )
);
$accepted = array_map(
static fn( PolicyAcceptance $a ): int => $a->policyVersionId,
$this->acceptances->findByRegistration( $type, $registrationId )
);
$questions = [];
foreach ( $this->questions->findByOffering( $subject->intakeOfferingId(), true ) as $question ) {
if ( in_array( (int) $question->id, $answered, true ) ) {
continue;
}
$questions[] = [
'id' => (int) $question->id,
'label' => $question->label,
'required' => $this->isRequiredOf( $question ),
];
}
$policies = [];
foreach ( $this->gate->requiredPolicyVersionIds() as $versionId ) {
if ( in_array( $versionId, $accepted, true ) ) {
continue;
}
$version = $this->versions->findById( $versionId );
$policy = null !== $version ? $this->policies->findById( $version->policyId ) : null;
$policies[] = [
'version_id' => $versionId,
'policy' => null !== $policy ? $policy->title : sprintf( '#%d', $versionId ),
'version' => null !== $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
];
}
return [
'questions' => $questions,
'policies' => $policies,
];
}
/**
* Record what the studio collected elsewhere, returning the notice to show.
*
* Submitted answers and acceptances are narrowed to what is actually still
* pending before anything is written, so a stale form — reloaded, or posted
* twice — can neither duplicate a row nor overwrite one.
*
* @param array<int, string> $answers question_id => answer value
* @param list<int> $versionIds Policy version ids being accepted
*
* @return string|\WP_Error
*/
public function record( IntakeSubject $subject, array $answers, array $versionIds, string $collectedVia, string $collectedNote, int $recordedBy ): string|\WP_Error {
if ( ! $subject->isStaffRegistered() ) {
return new \WP_Error(
'not_recordable',
__( 'Intake can only be recorded for a registration the studio made on the student\'s behalf.', 'unsupervised-schedular' )
);
}
$provenance = IntakeProvenance::fromInput( $collectedVia, $collectedNote, $recordedBy );
if ( $provenance instanceof \WP_Error ) {
return $provenance;
}
$pending = $this->pending( $subject );
$pendingQuestionIds = array_map( static fn( array $q ): int => $q['id'], $pending['questions'] );
$pendingVersionIds = array_map( static fn( array $p ): int => $p['version_id'], $pending['policies'] );
$newAnswers = [];
foreach ( $answers as $questionId => $value ) {
$value = trim( $value );
if ( '' !== $value && in_array( (int) $questionId, $pendingQuestionIds, true ) ) {
$newAnswers[ (int) $questionId ] = $value;
}
}
$newVersionIds = array_values( array_intersect( $versionIds, $pendingVersionIds ) );
if ( [] === $newAnswers && [] === $newVersionIds ) {
return new \WP_Error( 'nothing_to_record', __( 'Nothing was filled in to record.', 'unsupervised-schedular' ) );
}
// No IP address is passed: the student was not at a browser, and borrowing
// the staff member's would put a false location in the audit trail. Nor is
// an acceptor — the student agreed, on paper or over the phone; who typed it
// in is `recorded_by`, which the provenance carries.
$this->gate->record(
$subject->intakeRegistrationType(),
$subject->intakeRegistrationId(),
$subject->intakeStudentId(),
$subject->intakeOfferingId(),
$newAnswers,
$newVersionIds,
null,
0,
$provenance
);
return $this->notice( count( $newAnswers ), count( $newVersionIds ), $provenance );
}
/** What was written, and how it was said to have been collected. */
private function notice( int $answers, int $acceptances, IntakeProvenance $provenance ): string {
$parts = [];
if ( $answers > 0 ) {
/* translators: %d: number of intake answers recorded. */
$parts[] = sprintf( _n( '%d answer', '%d answers', $answers, 'unsupervised-schedular' ), $answers );
}
if ( $acceptances > 0 ) {
/* translators: %d: number of policy acceptances recorded. */
$parts[] = sprintf( _n( '%d policy acceptance', '%d policy acceptances', $acceptances, 'unsupervised-schedular' ), $acceptances );
}
return sprintf(
/* translators: 1: what was recorded, e.g. "2 answers and 1 policy acceptance", 2: how they were collected. */
__( 'Recorded %1$s, collected: %2$s', 'unsupervised-schedular' ),
implode( __( ' and ', 'unsupervised-schedular' ), $parts ),
IntakeProvenance::describe( $provenance->collectedVia, $provenance->collectedNote )
);
}
/**
* Whether the question is one the booking form would have insisted on, of
* either audience. It is shown as a hint only — a studio that has half the
* answers should be able to record the half it has, rather than being made to
* invent the rest to get the form to submit.
*/
private function isRequiredOf( Question $question ): bool {
return $question->isRequired || $question->isRequiredChild;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
/**
* A registration that intake answers and policy acceptances hang off: a booked
* lesson, or a group-class enrolment.
*
* The two are different enough to keep their own tables and their own booking
* flows, but identical in this one respect — somebody registered, questions were
* (or were not) answered, policies were (or were not) agreed to — and the rules
* for reading and recording that are not worth writing twice. Everything about
* intake works against this interface rather than either model.
*
* The `intake` prefix is not decoration: both implementations already carry
* `$studentId` and `$offeringId` properties, and PHP 8.1 has no way to declare a
* property on an interface, so the accessors need names of their own.
*/
interface IntakeSubject {
/**
* Which polymorphic registration table this is — `Answer::REG_LESSON` or
* `Answer::REG_ENROLLMENT`, matching the acceptance constants of the same
* names.
*/
public function intakeRegistrationType(): string;
/**
* The id answers and acceptances are stored against. Not always the row's own
* id: a weekly lesson series is answered for once, against its anchor, so
* every occurrence reads and writes the same registration.
*/
public function intakeRegistrationId(): int;
/** The offering whose questions apply. */
public function intakeOfferingId(): int;
/** Who the answers and acceptances belong to. */
public function intakeStudentId(): int;
/**
* Whether the studio registered this on the student's behalf, rather than the
* student (or their guardian) doing it themselves. Only these can have their
* intake recorded after the fact: one the student made already holds their own
* answers, and adding to those would make the record editable after the event.
*/
public function isStaffRegistered(): bool;
}
+15 -3
View File
@@ -62,10 +62,14 @@ class RegistrationGate {
* guardian booking for a child. It defaults to 0, read back as "the student
* agreed for themselves".
*
* `$provenance` marks answers the studio collected some other way and typed in
* afterwards; null (the default) is the ordinary case of the student giving
* them online. See {@see IntakeProvenance}.
*
* @param array<int, string> $answers question_id => answer value
* @param list<int> $acceptedVersionIds Accepted policy version IDs
*/
public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null, int $acceptedBy = 0 ): void {
public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null, int $acceptedBy = 0, ?IntakeProvenance $provenance = null ): void {
foreach ( $this->questions->findByOffering( $offeringId, true ) as $question ) {
$value = (string) ( $answers[ (int) $question->id ] ?? '' );
if ( '' === $value ) {
@@ -79,6 +83,9 @@ class RegistrationGate {
registrationId: $registrationId,
studentId: $studentId,
answerValue: $value,
collectedVia: $provenance?->collectedVia,
collectedNote: $provenance?->collectedNote,
recordedBy: null !== $provenance ? $provenance->recordedBy : 0,
)
);
}
@@ -96,17 +103,22 @@ class RegistrationGate {
registrationId: $registrationId,
acceptedBy: $acceptedBy > 0 ? $acceptedBy : $studentId,
ipAddress: $ipAddress,
collectedVia: $provenance?->collectedVia,
collectedNote: $provenance?->collectedNote,
recordedBy: null !== $provenance ? $provenance->recordedBy : 0,
)
);
}
}
/**
* Current published version IDs of every booking-scoped policy.
* Current published version IDs of every booking-scoped policy — what a
* booking must accept, and so also what a late, collected-elsewhere recording
* has to offer.
*
* @return list<int>
*/
private function requiredPolicyVersionIds(): array {
public function requiredPolicyVersionIds(): array {
$ids = [];
foreach ( $this->policies->findForScope( Policy::SCOPE_BOOKING ) as $policy ) {