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
+13 -4
View File
@@ -17,9 +17,10 @@ use Unsupervised\Schedular\Auth\StudentActions;
use Unsupervised\Schedular\Auth\StudentController;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Booking\AdminBooking;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonBooker;
use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\Booking\LessonDetail;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController;
@@ -42,6 +43,9 @@ use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\QuestionController;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Registration\IntakeAudit;
use Unsupervised\Schedular\Registration\IntakeRecording;
use Unsupervised\Schedular\Registration\RegistrationGate;
class AdminMenu {
@@ -66,15 +70,20 @@ class AdminMenu {
private PaymentController $paymentController;
private PaymentReportController $paymentReportController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits, GuardianService $guardians ) {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits, GuardianService $guardians, LessonBooker $booker, RegistrationGate $gate ) {
// One audit presenter and one recorder, shared by the lesson and enrolment
// detail views: intake is the same thing whichever registration it hangs off.
$intakeAudit = new IntakeAudit( $answers, $questions, $acceptances, $policies, $policyVersions );
$intakeRecording = new IntakeRecording( $questions, $answers, $policies, $policyVersions, $acceptances, $gate );
$this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) );
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, $intakeAudit, new AdminBooking( $availability, $offerings, $booker ), $intakeRecording );
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
$this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites );
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer, $intakeAudit, $intakeRecording );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
$this->instructorController = new InstructorController();
$this->settings = $settings;
+280
View File
@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
/**
* Booking a private lesson **for** a student, from wp-admin — the studio's
* counterpart to the group class's "Add students directly". The front desk takes
* a phone call, an instructor slots in a make-up lesson; neither of them can log
* in as the student, and only a guardian may book through the student-facing
* flow.
*
* It reuses `LessonBooker` — the same offering rules, the same atomic slot claim,
* the same billing — and differs from a student's own booking in exactly three
* ways, each deliberate:
*
* 1. **No intake questions or policy acceptances are recorded.** They are the
* student's to answer and agree to; a staff member ticking boxes on their
* behalf would be an audit trail that says something untrue. The lesson's
* detail page simply shows none.
* 2. **It is not bounded by what the student could book themselves.** Any open
* slot of the instructor's, including one only reachable past a deadline.
* 3. **It can be booked at no charge**, for a make-up or goodwill lesson, which
* skips the payment entirely and confirms the lesson at once.
*/
class AdminBooking {
/**
* How far ahead the form's list of open times reaches. Long enough to book a
* term ahead, short enough that the select stays a select.
*/
private const HORIZON_DAYS = 56;
public function __construct(
private AvailabilityRepository $availability,
private OfferingRepository $offerings,
private LessonBooker $booker,
) {}
/**
* Book a lesson on a student's behalf, returning the notice to show. When
* `$onlyInstructorId` is non-zero the slot must belong to that instructor —
* how an instructor's own **My Lessons** page is kept to their own schedule,
* where the studio **Scheduler** passes 0 and may book any instructor's time.
*
* @return string|\WP_Error Success notice, or why nothing was booked.
*/
public function book( int $studentId, int $slotId, int $offeringId, string $recurrence, bool $noCharge, string $notes, int $onlyInstructorId = 0 ): string|\WP_Error {
if ( $studentId <= 0 || ! user_can( $studentId, RoleManager::CAP_BOOK_LESSON ) ) {
return new \WP_Error( 'invalid_student', __( 'Choose a student to book for.', 'unsupervised-schedular' ) );
}
$slot = $slotId > 0 ? $this->availability->findById( $slotId ) : null;
if ( null === $slot || ( $onlyInstructorId > 0 && $slot->instructorId !== $onlyInstructorId ) ) {
return new \WP_Error( 'invalid_slot', __( 'Choose a time to book.', 'unsupervised-schedular' ) );
}
if ( $slot->isBooked ) {
return new \WP_Error( 'slot_taken', __( 'That time has already been booked.', 'unsupervised-schedular' ) );
}
$offering = $this->booker->resolveOffering( $slot, $offeringId );
if ( $offering instanceof \WP_Error ) {
return $offering;
}
// A weekly reservation needs a weekly time to reserve. The student-facing
// flow quietly books a single lesson when the slot does not repeat; here the
// staff member asked for a term and must be told they are not getting one,
// rather than discovering it later on the roster.
$weekly = Lesson::RECURRENCE_WEEKLY === $recurrence;
if ( $weekly && null === $slot->recurrenceGroup ) {
return new \WP_Error( 'not_weekly', __( 'That time does not repeat weekly, so it cannot be reserved for the term.', 'unsupervised-schedular' ) );
}
$reservation = $this->booker->reserve(
$slot,
$offering,
$studentId,
$weekly ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
$notes,
// Stamped on the lesson so it can be told apart later: only a lesson the
// studio booked may have its intake recorded after the fact.
get_current_user_id()
);
if ( $reservation instanceof \WP_Error ) {
return $reservation;
}
$settlement = $this->booker->settle(
$reservation['ids'],
$reservation['anchor_id'],
$slot,
$offering,
$studentId,
$noCharge
);
return $this->notice( $studentId, $offering, $slot, count( $reservation['ids'] ), $settlement['status'] );
}
/**
* What was booked and what it left owing, so the notice answers the two things
* the person who booked it needs to know.
*/
private function notice( int $studentId, Offering $offering, AvailabilitySlot $slot, int $count, string $status ): string {
$who = $this->studentName( $studentId );
$what = $count > 1
? sprintf(
/* translators: 1: student name, 2: lesson type, 3: number of weekly occurrences, 4: first lesson date and time. */
__( 'Booked %1$s into %2$s — %3$d weekly lessons from %4$s.', 'unsupervised-schedular' ),
$who,
$offering->title,
$count,
Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) )
)
: sprintf(
/* translators: 1: student name, 2: lesson type, 3: lesson date and time. */
__( 'Booked %1$s into %2$s on %3$s.', 'unsupervised-schedular' ),
$who,
$offering->title,
Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) )
);
$owing = Lesson::STATUS_CONFIRMED === $status
? __( 'Nothing is owed, so it is confirmed.', 'unsupervised-schedular' )
: __( 'A pending payment has been raised; the lesson is confirmed once it settles.', 'unsupervised-schedular' );
return $what . ' ' . $owing;
}
/**
* Everything the form's three selects need. `$onlyInstructorId` scopes both the
* open times and the lesson types to one instructor's, the same way `book()`
* scopes what may be booked.
*
* @return array{students: list<array{id: int, name: string}>, offerings: list<array{id: int, label: string}>, slots: list<array{id: int, label: string, weekly: bool}>}
*/
public function formData( int $onlyInstructorId = 0 ): array {
$slots = $this->openSlots( $onlyInstructorId );
$offerings = array_values(
array_filter(
$this->offerings->findAll( $onlyInstructorId, Offering::KIND_PRIVATE_LESSON, true ),
static fn( Offering $o ): bool => null !== $o->id
)
);
// Whose lesson type / whose time only needs saying when the page spans more
// than one instructor — on an instructor's own page it is noise.
$named = 0 === $onlyInstructorId;
return [
'students' => $this->studentOptions(),
'offerings' => array_map(
fn( Offering $o ): array => [
'id' => (int) $o->id,
'label' => $this->offeringLabel( $o, $named ),
],
$offerings
),
'slots' => array_map(
fn( AvailabilitySlot $s ): array => [
'id' => (int) $s->id,
'label' => $this->slotLabel( $s, $named ),
'weekly' => null !== $s->recurrenceGroup,
],
$slots
),
];
}
/**
* Open slots inside the booking horizon, newest last.
*
* @return list<AvailabilitySlot>
*/
private function openSlots( int $instructorId ): array {
$until = ( new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) ) )
->modify( '+' . self::HORIZON_DAYS . ' days' )
->format( 'Y-m-d H:i:s' );
return array_values(
array_filter(
$this->availability->findAvailable( $instructorId, 0, 0, '', $until ),
static fn( AvailabilitySlot $s ): bool => null !== $s->id
)
);
}
/**
* A lesson type as "60 min piano (60 min) — Jane Doe", the instructor named
* only when the list spans several.
*/
private function offeringLabel( Offering $offering, bool $withInstructor ): string {
$label = $offering->title;
if ( null !== $offering->durationMinutes ) {
/* translators: %d: lesson length in minutes. */
$label .= ' (' . sprintf( __( '%d min', 'unsupervised-schedular' ), $offering->durationMinutes ) . ')';
}
return $withInstructor ? $label . ' — ' . $this->instructorName( $offering->instructorId ) : $label;
}
/**
* An open time as "Mon Sep 2, 4:00 PM (30 min) — Jane Doe — 30 min piano —
* repeats weekly": when it is, how long, whose, and what it is already tied to,
* since all four decide whether a given student can be booked into it.
*/
private function slotLabel( AvailabilitySlot $slot, bool $withInstructor ): string {
/* translators: %d: lesson length in minutes. */
$label = Val::string( mysql2date( 'D M j, Y g:i A', $slot->startDt ) ) . ' (' . sprintf( __( '%d min', 'unsupervised-schedular' ), $slot->durationMinutes ) . ')';
if ( $withInstructor ) {
$label .= ' — ' . $this->instructorName( $slot->instructorId );
}
$tied = null !== $slot->offeringId ? $this->offerings->findById( $slot->offeringId ) : null;
if ( null !== $tied ) {
$label .= ' — ' . $tied->title;
}
if ( null !== $slot->recurrenceGroup ) {
$label .= ' — ' . __( 'repeats weekly', 'unsupervised-schedular' );
}
return $label;
}
private function instructorName( int $instructorId ): string {
return $this->studentName( $instructorId );
}
/** A person's display name, however little the account has on file. */
private function studentName( int $userId ): string {
$user = get_userdata( $userId );
return UserName::format( $user instanceof \WP_User ? $user : null, $userId );
}
/**
* Everyone who can be booked for, by name — students and the children a
* guardian books for alike, since both hold `book_lesson`.
*
* @return list<array{id: int, name: string}>
*/
private function studentOptions(): array {
$users = array_filter(
get_users(
[
'role' => RoleManager::STUDENT,
'orderby' => 'display_name',
'order' => 'ASC',
]
),
static fn( mixed $u ): bool => $u instanceof \WP_User
);
return array_values(
array_map(
static fn( \WP_User $u ): array => [
'id' => (int) $u->ID,
'name' => UserName::format( $u, (int) $u->ID ),
],
$users
)
);
}
}
+18 -147
View File
@@ -7,9 +7,7 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
@@ -17,18 +15,13 @@ use Unsupervised\Schedular\Val;
class BookingEndpoint {
/**
* The most occurrences a single weekly booking may reserve at once, so one
* student cannot lock up an instructor's entire recurring schedule.
*/
private const MAX_WEEKLY_OCCURRENCES = 12;
public function __construct(
private AvailabilityRepository $availability,
private BookingRepository $bookings,
private OfferingRepository $offerings,
private RegistrationGate $gate,
private PaymentService $payments,
private LessonBooker $booker,
private CancellationPolicy $cancellationPolicy,
private GuardianService $guardians,
private SessionSchedule $sessions,
@@ -227,52 +220,12 @@ class BookingEndpoint {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
// Resolve the offering for this booking. A client-supplied offering must
// never override the slot's price or payment routing: when the slot is tied
// to a specific offering that offering is authoritative, and any offering
// used must belong to the slot's instructor. This prevents substituting a
// cheaper/free offering to dodge payment, or another instructor's offering
// to misroute it.
$requestedOfferingId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$slotOfferingId = (int) ( $slot->offeringId ?? 0 );
if ( $slotOfferingId > 0 ) {
if ( $requestedOfferingId > 0 && $requestedOfferingId !== $slotOfferingId ) {
return new \WP_Error( 'offering_mismatch', __( 'This slot is tied to a different offering.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offeringId = $slotOfferingId;
} else {
$offeringId = $requestedOfferingId;
$offering = $this->booker->resolveOffering( $slot, absint( Val::int( $request->get_param( 'offering_id' ) ) ) );
if ( $offering instanceof \WP_Error ) {
return $offering;
}
// Every lesson books against an offering: it carries the price, intake
// questions, and payment routing. Without one the booking would silently
// be free and unquestioned, so generic slots require the student's choice.
if ( $offeringId <= 0 ) {
return new \WP_Error( 'offering_required', __( 'Choose a lesson type to book this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering ) {
return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( $offering->instructorId !== $slot->instructorId ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
// A slot-tied offering was the instructor's explicit choice and is honoured
// as-is; a student-chosen one must be something the catalog actually offers
// for this slot: an active private-lesson type whose length fits the slot.
if ( 0 === $slotOfferingId ) {
if ( ! $offering->isActive || Offering::KIND_PRIVATE_LESSON !== $offering->kind ) {
return new \WP_Error( 'invalid_offering', __( 'That offering cannot be booked as a private lesson.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( null !== $offering->durationMinutes && $offering->durationMinutes !== $slot->durationMinutes ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering does not match this slot\'s lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
}
$offeringId = (int) $offering->id;
$answers = $this->answers( $request );
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) );
@@ -282,93 +235,28 @@ class BookingEndpoint {
return $gateError;
}
$notes = Val::string( $request->get_param( 'notes' ) );
$recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' )
? Lesson::RECURRENCE_WEEKLY
: Lesson::RECURRENCE_SINGLE;
$notes = Val::string( $request->get_param( 'notes' ) );
$template = new Lesson(
slotId: $slotId,
studentId: $studentId,
instructorId: $slot->instructorId,
offeringId: $offeringId,
recurrence: $recurrence,
notes: '' !== $notes ? $notes : null,
$reservation = $this->booker->reserve(
$slot,
$offering,
$studentId,
Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
$notes
);
// Weekly reservation across the slot's recurring group; otherwise a single lesson.
if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) {
// Claim each occurrence atomically (capped so one booking cannot lock an
// instructor's entire schedule), then create a lesson only for the slots
// this request actually won — never for one already taken by someone else.
$candidates = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) );
$candidates = array_slice( $candidates, 0, self::MAX_WEEKLY_OCCURRENCES );
$claimed = array_values( array_filter( $candidates, fn( int $candidateId ): bool => $this->availability->claim( $candidateId ) ) );
if ( [] === $claimed ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$ids = $this->bookings->insertSeries( $template, $claimed );
$anchorId = $ids[0] ?? 0;
} else {
// Claim before inserting: if another request already took the slot, the
// guarded update reports no rows and we reject rather than double-book.
if ( ! $this->availability->claim( $slotId ) ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$anchorId = $this->bookings->insert( $template );
$ids = [ $anchorId ];
if ( $reservation instanceof \WP_Error ) {
return $reservation;
}
$ids = $reservation['ids'];
$anchorId = $reservation['anchor_id'];
// The acceptance binds the student but is attributed to whoever actually
// ticked the boxes — the guardian, when they booked for a child.
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() );
$payment = null;
$status = Lesson::STATUS_PENDING;
// Scheduled billing (weekly / monthly) normally defers payment to the daily
// scan, but a single lesson booked once its scheduled due date has already
// passed — e.g. an extra lesson added to a month that was already billed — is
// charged at booking instead, so it is never missed or billed late.
$chargeAtBooking = $offering->price > 0.0 && (
! $offering->isScheduledBilling()
|| ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) )
);
if ( $chargeAtBooking ) {
// A full-term price already covers the whole reservation; a per-lesson
// (one_time) price is owed once per occurrence actually claimed, so a
// weekly reservation cannot hold a term while paying for one week.
$amount = Offering::BILLING_FULL_TERM === $offering->billingMode
? $offering->price
: $offering->price * count( $ids );
$payment = $this->payments->createForRegistration(
Payment::REG_LESSON,
$anchorId,
$studentId,
$slot->instructorId,
$amount,
$offering->currency,
$offering->etransferEmail,
payerId: $this->guardians->payerFor( $studentId )
);
if ( null !== $payment && $payment->isPaid() ) {
$status = Lesson::STATUS_CONFIRMED;
}
} else {
// Either a free offering, or scheduled billing (weekly / monthly) whose
// payment is deferred to the daily billing scan. Either way there is no
// payment step now to confirm the lessons, so the reserved slots are
// confirmed at booking time; the billing scan bills them when they come due.
foreach ( $ids as $lessonId ) {
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
}
$status = Lesson::STATUS_CONFIRMED;
}
[ 'status' => $status, 'payment' => $payment ] = $this->booker->settle( $ids, $anchorId, $slot, $offering, $studentId );
// `payment: null` tells the front end to skip the payment step entirely.
return new \WP_REST_Response(
@@ -424,23 +312,6 @@ class BookingEndpoint {
return $out;
}
/**
* Whether a scheduled-billing offering's due date for a given session has
* already passed at booking time. Weekly bills 24 hours before the lesson;
* monthly bills on the 1st, so its due moment has passed once "now" is in the
* lesson's month or later. Only meaningful for weekly / monthly offerings.
*/
private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool {
$now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) );
$start = new \DateTimeImmutable( $slotStart );
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' );
}
return $now >= $start->modify( '-1 day' );
}
private function clientIp(): ?string {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
+3 -1
View File
@@ -24,9 +24,10 @@ class BookingRepository {
'status' => $lesson->status,
'payment_id' => $lesson->paymentId,
'notes' => $lesson->notes,
'booked_by' => $lesson->bookedBy,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s' ]
[ '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
@@ -54,6 +55,7 @@ class BookingRepository {
seriesId: $seriesId > 0 ? $seriesId : null,
status: $template->status,
notes: $template->notes,
bookedBy: $template->bookedBy,
)
);
+43 -1
View File
@@ -3,9 +3,11 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\IntakeSubject;
use Unsupervised\Schedular\Val;
class Lesson {
class Lesson implements IntakeSubject {
public const STATUS_PENDING = 'pending';
public const STATUS_CONFIRMED = 'confirmed';
@@ -38,9 +40,47 @@ class Lesson {
public readonly string $status = self::STATUS_PENDING,
public readonly ?int $paymentId = null,
public readonly ?string $notes = null,
/**
* The staff member who booked this lesson on the student's behalf, from
* wp-admin; 0 when it was booked through the student-facing flow, by the
* student or their guardian. It is what marks a lesson whose intake answers
* and policy acceptances may be recorded after the fact — nobody was at a
* keyboard to give them at booking time.
*/
public readonly int $bookedBy = 0,
public readonly ?int $id = null,
) {}
public function intakeRegistrationType(): string {
return Answer::REG_LESSON;
}
/**
* The lesson id this booking's intake answers and policy acceptances hang
* off: the series anchor for a weekly reservation, the lesson itself
* otherwise. A series is answered for and agreed to once, so every occurrence
* reads and writes the same registration.
*/
public function intakeRegistrationId(): int {
return $this->seriesId ?? (int) $this->id;
}
public function intakeOfferingId(): int {
return (int) $this->offeringId;
}
public function intakeStudentId(): int {
return $this->studentId;
}
/**
* Whether the studio booked this lesson on the student's behalf, rather than
* the student (or their guardian) booking it themselves.
*/
public function isStaffRegistered(): bool {
return $this->bookedBy > 0;
}
public static function fromRow( \stdClass $row ): self {
return new self(
slotId: Val::int( $row->slot_id ),
@@ -52,6 +92,7 @@ class Lesson {
status: Val::string( $row->status ),
paymentId: Val::intOrNull( $row->payment_id ),
notes: Val::stringOrNull( $row->notes ),
bookedBy: Val::int( $row->booked_by ?? 0 ),
id: Val::int( $row->id ),
);
}
@@ -73,6 +114,7 @@ class Lesson {
'status' => $this->status,
'payment_id' => $this->paymentId,
'notes' => $this->notes,
'booked_by' => $this->bookedBy,
];
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Val;
/**
* The booking core shared by the student-facing REST endpoint and the admin
* "book a lesson for a student" form: which offering a slot may be booked as,
* claiming the slot(s) and writing the lesson row(s), and raising the payment
* that confirms them.
*
* It deliberately knows nothing about who is asking. Authorisation — a guardian
* booking for their own child, a studio admin booking for anyone — is settled by
* the caller before anything here is touched, and so are the intake answers and
* policy acceptances that gate a student's own booking (an admin booking on
* someone's behalf has none to collect). What must not diverge between the two
* paths is everything below: the offering rules that decide a slot's price and
* payment routing, the atomic claim that stops a double-booking, and the billing
* that follows.
*/
class LessonBooker {
/**
* The most occurrences a single weekly booking may reserve at once, so one
* student cannot lock up an instructor's entire recurring schedule.
*/
public const MAX_WEEKLY_OCCURRENCES = 12;
public function __construct(
private AvailabilityRepository $availability,
private BookingRepository $bookings,
private OfferingRepository $offerings,
private PaymentService $payments,
private GuardianService $guardians,
) {}
/**
* Resolve the offering a slot is to be booked as. A caller-supplied offering
* must never override the slot's price or payment routing: when the slot is
* tied to a specific offering that offering is authoritative, and any offering
* used must belong to the slot's instructor. This prevents substituting a
* cheaper/free offering to dodge payment, or another instructor's offering to
* misroute it.
*/
public function resolveOffering( AvailabilitySlot $slot, int $requestedOfferingId ): Offering|\WP_Error {
$requestedOfferingId = absint( $requestedOfferingId );
$slotOfferingId = (int) ( $slot->offeringId ?? 0 );
if ( $slotOfferingId > 0 ) {
if ( $requestedOfferingId > 0 && $requestedOfferingId !== $slotOfferingId ) {
return new \WP_Error( 'offering_mismatch', __( 'This slot is tied to a different offering.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offeringId = $slotOfferingId;
} else {
$offeringId = $requestedOfferingId;
}
// Every lesson books against an offering: it carries the price, intake
// questions, and payment routing. Without one the booking would silently
// be free and unquestioned, so generic slots require an explicit choice.
if ( $offeringId <= 0 ) {
return new \WP_Error( 'offering_required', __( 'Choose a lesson type to book this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering ) {
return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( $offering->instructorId !== $slot->instructorId ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
// A slot-tied offering was the instructor's explicit choice and is honoured
// as-is; a chosen one must be something the catalog actually offers for this
// slot: an active private-lesson type whose length fits the slot.
if ( 0 === $slotOfferingId ) {
if ( ! $offering->isActive || Offering::KIND_PRIVATE_LESSON !== $offering->kind ) {
return new \WP_Error( 'invalid_offering', __( 'That offering cannot be booked as a private lesson.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( null !== $offering->durationMinutes && $offering->durationMinutes !== $slot->durationMinutes ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering does not match this slot\'s lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
}
return $offering;
}
/**
* Claim the slot(s) and write the lesson row(s) — a single lesson, or one per
* remaining occurrence of the slot's weekly group. The rows are created
* `pending`; `settle()` decides what confirms them.
*
* `$bookedBy` is the staff member booking on the student's behalf, and 0 for a
* booking made through the student-facing flow. It is recorded on every lesson
* of a series, since a series is booked once.
*
* @return array{ids: list<int>, anchor_id: int}|\WP_Error
*/
public function reserve( AvailabilitySlot $slot, Offering $offering, int $studentId, string $recurrence, ?string $notes = null, int $bookedBy = 0 ): array|\WP_Error {
$slotId = (int) $slot->id;
$recurrence = Lesson::RECURRENCE_WEEKLY === $recurrence ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE;
$template = new Lesson(
slotId: $slotId,
studentId: $studentId,
instructorId: $slot->instructorId,
offeringId: (int) $offering->id,
recurrence: $recurrence,
notes: null !== $notes && '' !== $notes ? $notes : null,
bookedBy: $bookedBy,
);
// Weekly reservation across the slot's recurring group; otherwise a single lesson.
if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) {
// Claim each occurrence atomically (capped so one booking cannot lock an
// instructor's entire schedule), then create a lesson only for the slots
// this request actually won — never for one already taken by someone else.
$candidates = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) );
$candidates = array_slice( $candidates, 0, self::MAX_WEEKLY_OCCURRENCES );
$claimed = array_values( array_filter( $candidates, fn( int $candidateId ): bool => $this->availability->claim( $candidateId ) ) );
if ( [] === $claimed ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$ids = $this->bookings->insertSeries( $template, $claimed );
return [
'ids' => $ids,
'anchor_id' => $ids[0] ?? 0,
];
}
// Claim before inserting: if another request already took the slot, the
// guarded update reports no rows and we reject rather than double-book.
if ( ! $this->availability->claim( $slotId ) ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$anchorId = $this->bookings->insert( $template );
return [
'ids' => [ $anchorId ],
'anchor_id' => $anchorId,
];
}
/**
* Raise the payment for a reservation and report the status its lessons end up
* in. A priced booking stays `pending` until its payment settles; anything with
* nothing to charge now — a free offering, scheduled billing, or a booking the
* caller marked `$noCharge` — is confirmed here and then.
*
* @param list<int> $ids
*
* @return array{status: string, payment: ?Payment}
*/
public function settle( array $ids, int $anchorId, AvailabilitySlot $slot, Offering $offering, int $studentId, bool $noCharge = false ): array {
// Scheduled billing (weekly / monthly) normally defers payment to the daily
// scan, but a single lesson booked once its scheduled due date has already
// passed — e.g. an extra lesson added to a month that was already billed — is
// charged at booking instead, so it is never missed or billed late.
$chargeAtBooking = ! $noCharge && $offering->price > 0.0 && (
! $offering->isScheduledBilling()
|| ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) )
);
if ( $chargeAtBooking ) {
// A full-term price already covers the whole reservation; a per-lesson
// (one_time) price is owed once per occurrence actually claimed, so a
// weekly reservation cannot hold a term while paying for one week.
$amount = Offering::BILLING_FULL_TERM === $offering->billingMode
? $offering->price
: $offering->price * count( $ids );
$payment = $this->payments->createForRegistration(
Payment::REG_LESSON,
$anchorId,
$studentId,
$slot->instructorId,
$amount,
$offering->currency,
$offering->etransferEmail,
payerId: $this->guardians->payerFor( $studentId )
);
return [
'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING,
'payment' => $payment,
];
}
// Either nothing is owed — a free offering, or a booking the studio comped —
// or scheduled billing (weekly / monthly) whose payment is deferred to the
// daily billing scan. Either way there is no payment step now to confirm the
// lessons, so the reserved slots are confirmed at booking time; the billing
// scan bills the scheduled ones when they come due.
foreach ( $ids as $lessonId ) {
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
}
return [
'status' => Lesson::STATUS_CONFIRMED,
'payment' => null,
];
}
/**
* Whether a scheduled-billing offering's due date for a lesson has already
* gone by — monthly bills on the first of the lesson's month, weekly the day
* before the lesson.
*/
private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool {
$now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) );
$start = new \DateTimeImmutable( $slotStart );
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' );
}
return $now >= $start->modify( '-1 day' );
}
}
+148 -14
View File
@@ -10,6 +10,9 @@ use Unsupervised\Schedular\Availability\WeekCalendar;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Registration\IntakeAudit;
use Unsupervised\Schedular\Registration\IntakeProvenance;
use Unsupervised\Schedular\Registration\IntakeRecording;
use Unsupervised\Schedular\Val;
class LessonController {
@@ -19,7 +22,9 @@ class LessonController {
private PaymentRepository $payments,
private AvailabilityRepository $availability,
private OfferingRepository $offerings,
private LessonDetail $detail,
private IntakeAudit $detail,
private AdminBooking $adminBooking,
private IntakeRecording $intake,
) {}
public function renderAdminDashboard(): void {
@@ -31,11 +36,11 @@ class LessonController {
return;
}
$this->handleEtransferUpdate( false );
[ $notice, $error ] = $this->handleFormAction( false, 0 );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() );
$this->renderLessonsPage( $rows, 'us-scheduler' );
$this->renderLessonsPage( $rows, 'us-scheduler', 0, $notice, $error );
}
public function renderInstructorLessons(): void {
@@ -47,11 +52,13 @@ class LessonController {
return;
}
$this->handleEtransferUpdate( true );
$instructorId = get_current_user_id();
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
[ $notice, $error ] = $this->handleFormAction( true, $instructorId );
$this->renderLessonsPage( $rows, 'us-my-lessons' );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( $instructorId ) );
$this->renderLessonsPage( $rows, 'us-my-lessons', $instructorId, $notice, $error );
}
/**
@@ -68,15 +75,23 @@ class LessonController {
$lesson = $this->repository->findById( $lessonId );
$backUrl = admin_url( 'admin.php?page=' . $pageSlug );
$notice = '';
$error = '';
if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) {
$row = null;
$answers = [];
$accepts = [];
$intake = $this->emptyIntake();
} else {
// Recorded before the tables are read, so what was just entered appears
// on the page that reports it.
[ $notice, $error ] = $this->recordIntake( $lesson );
$row = $this->row( $lesson );
$answers = $this->detail->answers( $lesson );
$accepts = $this->detail->acceptances( $lesson );
$intake = $this->intakeForm( $lesson );
}
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
@@ -84,13 +99,86 @@ class LessonController {
return true;
}
/**
* Handle a submitted "record intake collected elsewhere" form.
*
* @return array{string, string} Success notice and error message.
*/
private function recordIntake( Lesson $lesson ): array {
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) {
return [ '', '' ];
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
if ( 'record_intake' !== sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
return [ '', '' ];
}
$answers = [];
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized below.
foreach ( (array) ( $_POST['answers'] ?? [] ) as $questionId => $value ) {
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each element is coerced to a positive int; slashes cannot survive integer coercion.
$rawVersionIds = (array) ( $_POST['accepted_policy_version_ids'] ?? [] );
$versionIds = array_values( array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $rawVersionIds ) ) );
$result = $this->intake->record(
$lesson,
$answers,
$versionIds,
sanitize_key( Val::string( wp_unslash( $_POST['collected_via'] ?? '' ) ) ),
sanitize_text_field( Val::string( wp_unslash( $_POST['collected_note'] ?? '' ) ) ),
get_current_user_id()
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
return $result instanceof \WP_Error
? [ '', $result->get_error_message() ]
: [ $result, '' ];
}
/**
* What the detail template needs to offer the recording form: whether this
* lesson qualifies at all, what is still missing, and the collection methods
* to choose between.
*
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
*/
private function intakeForm( Lesson $lesson ): array {
if ( ! $lesson->isStaffRegistered() ) {
return $this->emptyIntake();
}
return [ 'recordable' => true ] + $this->intake->pending( $lesson ) + [ 'methods' => IntakeProvenance::choices() ];
}
/**
* The form data for a lesson that cannot be recorded against — one the student
* booked, or one that could not be opened at all.
*
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
*/
private function emptyIntake(): array {
return [
'recordable' => false,
'questions' => [],
'policies' => [],
'methods' => [],
];
}
/**
* Render the lessons template with its calendar view state: week (default)
* or list, plus which week the week view shows.
* or list, plus which week the week view shows, and the choices the
* book-for-a-student form offers — scoped to one instructor's own schedule on
* **My Lessons**, studio-wide (0) on the **Scheduler**.
*
* @param list<array<string, mixed>> $rows
*/
private function renderLessonsPage( array $rows, string $pageSlug ): void {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- $notice and $error are read by the included template.
private function renderLessonsPage( array $rows, string $pageSlug, int $onlyInstructorId, string $notice, string $error ): void {
// View-state query params only (which view, which week) — nothing is
// mutated from them, so no nonce applies.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
@@ -103,21 +191,67 @@ class LessonController {
$prevWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '-7 days' )->format( 'Y-m-d' );
$nextWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '+7 days' )->format( 'Y-m-d' );
$baseUrl = admin_url( 'admin.php?page=' . $pageSlug );
$bookForm = $this->adminBooking->formData( $onlyInstructorId );
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
}
/**
* Handle a per-lesson payment override (e-transfer email or HST rate). When
* $onlyOwn, the payment must belong to the current instructor.
* Run the submitted action and report what happened: a per-lesson payment
* override (e-transfer email or HST rate), or a lesson booked for a student.
* When $onlyOwn, the payment or slot must belong to the current instructor.
*
* @return array{string, string} Success notice and error message; each is
* empty when it does not apply.
*/
private function handleEtransferUpdate( bool $onlyOwn ): void {
private function handleFormAction( bool $onlyOwn, int $instructorId ): array {
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) {
return;
return [ '', '' ];
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
if ( 'book_for_student' === $action ) {
return $this->bookForStudent( $onlyOwn ? $instructorId : 0 );
}
$this->updatePayment( $action, $onlyOwn );
return [ '', '' ];
}
/**
* Book a lesson on a student's behalf from the submitted form. The slot is
* scoped to the instructor's own schedule on **My Lessons** ($onlyInstructorId
* non-zero) and studio-wide on the **Scheduler**.
*
* @return array{string, string}
*/
private function bookForStudent( int $onlyInstructorId ): array {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$result = $this->adminBooking->book(
absint( Val::int( $_POST['student_id'] ?? 0 ) ),
absint( Val::int( $_POST['slot_id'] ?? 0 ) ),
absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
isset( $_POST['recurrence_weekly'] ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
isset( $_POST['no_charge'] ),
sanitize_text_field( Val::string( wp_unslash( $_POST['notes'] ?? '' ) ) ),
$onlyInstructorId
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
return $result instanceof \WP_Error
? [ '', $result->get_error_message() ]
: [ $result, '' ];
}
/**
* Apply a per-lesson payment override. When $onlyOwn, the payment must belong
* to the current instructor.
*/
private function updatePayment( string $action, bool $onlyOwn ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) );
$email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion.
-87
View File
@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\QuestionRepository;
/**
* Builds the display rows for the admin lesson detail view: the intake answers
* the student submitted and the policy versions they accepted when booking.
*
* Scoped to a single lesson (the `lesson` registration type), mirroring the
* per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
*
* A weekly reservation is answered for and agreed to once, so its answers and
* acceptances hang off the series anchor. Every occurrence therefore reads its
* series' registration, not its own id — otherwise only the first lesson of a
* series showed the intake and the audit trail, and the rest looked as though
* nothing had been accepted.
*/
class LessonDetail {
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 lesson, in submission order.
*
* @return list<array{question: string, answer: string}>
*/
public function answers( Lesson $lesson ): 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,
];
},
$this->answers->findByRegistration( Answer::REG_LESSON, $this->registrationId( $lesson ) )
);
}
/**
* The policy versions the student accepted when booking this lesson, with the
* captured acceptance time and IP for the audit trail.
*
* @return list<array{policy: string, version: string, accepted_at: string, ip: string}>
*/
public function acceptances( Lesson $lesson ): 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 ?? '',
];
},
$this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $this->registrationId( $lesson ) )
);
}
/**
* The lesson id the booking's answers and acceptances were recorded against:
* the series anchor for a weekly reservation, the lesson itself otherwise.
*/
private function registrationId( Lesson $lesson ): int {
return $lesson->seriesId ?? (int) $lesson->id;
}
}
+41 -1
View File
@@ -3,9 +3,11 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\IntakeSubject;
use Unsupervised\Schedular\Val;
class Enrollment {
class Enrollment implements IntakeSubject {
public const STATUS_ACTIVE = 'active';
public const STATUS_CANCELLED = 'cancelled';
@@ -24,9 +26,45 @@ class Enrollment {
public readonly int $instructorId,
public readonly string $status = self::STATUS_ACTIVE,
public readonly ?int $paymentId = null,
/**
* The staff member who enrolled this student from wp-admin — the class
* detail page's **Add students directly**; 0 when the student or their
* guardian enrolled themselves. It is what marks an enrolment whose intake
* answers and policy acceptances may be recorded after the fact, nobody
* having been at a keyboard to give them at the time.
*/
public readonly int $enrolledBy = 0,
public readonly ?int $id = null,
) {}
public function intakeRegistrationType(): string {
return Answer::REG_ENROLLMENT;
}
/**
* An enrolment is registered once and is its own registration — there is no
* series anchor to follow, as a term of classes is one enrolment.
*/
public function intakeRegistrationId(): int {
return (int) $this->id;
}
public function intakeOfferingId(): int {
return $this->offeringId;
}
public function intakeStudentId(): int {
return $this->studentId;
}
/**
* Whether the studio enrolled this student, rather than the student (or their
* guardian) enrolling themselves.
*/
public function isStaffRegistered(): bool {
return $this->enrolledBy > 0;
}
public static function fromRow( \stdClass $row ): self {
return new self(
offeringId: Val::int( $row->offering_id ),
@@ -34,6 +72,7 @@ class Enrollment {
instructorId: Val::int( $row->instructor_id ),
status: Val::string( $row->status ),
paymentId: Val::intOrNull( $row->payment_id ),
enrolledBy: Val::int( $row->enrolled_by ?? 0 ),
id: Val::int( $row->id ),
);
}
@@ -51,6 +90,7 @@ class Enrollment {
'instructor_id' => $this->instructorId,
'status' => $this->status,
'payment_id' => $this->paymentId,
'enrolled_by' => $this->enrolledBy,
];
}
}
+2 -1
View File
@@ -20,9 +20,10 @@ class EnrollmentRepository {
'instructor_id' => $enrollment->instructorId,
'status' => $enrollment->status,
'payment_id' => $enrollment->paymentId,
'enrolled_by' => $enrollment->enrolledBy,
'enrolled_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%d', '%s', '%d', '%s' ]
[ '%d', '%d', '%d', '%s', '%d', '%d', '%s' ]
);
return $this->db->insert_id;
+153 -2
View File
@@ -14,6 +14,9 @@ use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Registration\IntakeAudit;
use Unsupervised\Schedular\Registration\IntakeProvenance;
use Unsupervised\Schedular\Registration\IntakeRecording;
use Unsupervised\Schedular\Val;
class GroupClassController {
@@ -26,6 +29,8 @@ class GroupClassController {
private PaymentService $paymentService,
private InviteRepository $invites,
private RegistrationMailer $mailer,
private IntakeAudit $audit,
private IntakeRecording $intake,
) {}
/**
@@ -41,13 +46,18 @@ class GroupClassController {
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
}
$baseUrl = admin_url( 'admin.php?page=us-group-classes' );
if ( $this->maybeRenderEnrollmentDetail( $baseUrl, 0 ) ) {
return;
}
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
$notice = $this->handleFormAction( get_current_user_id() );
}
$offerings = $this->offerings->findAll( 0, Offering::KIND_GROUP_CLASS );
$baseUrl = admin_url( 'admin.php?page=us-group-classes' );
// View-state query param only (which class to drill into) — nothing is
// mutated from it, so no nonce applies.
@@ -104,6 +114,10 @@ class GroupClassController {
$instructorId = get_current_user_id();
if ( $this->maybeRenderEnrollmentDetail( admin_url( 'admin.php?page=us-my-group-classes' ), $instructorId ) ) {
return;
}
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
$notice = $this->handleFormAction( $instructorId );
@@ -144,6 +158,138 @@ class GroupClassController {
include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php';
}
/**
* When the request targets a single enrolment (`?enrollment_id=`), render its
* detail view — the audit trail of what the student answered and agreed to,
* and, for an enrolment the studio made, the form to record intake collected
* elsewhere. Reports whether the page has been handled.
*
* `$onlyInstructorId` scopes it the way the pages themselves are scoped: an
* instructor may only open enrolments in their own classes, while the studio
* **Group Classes** page passes 0 and may open any.
*/
private function maybeRenderEnrollmentDetail( string $baseUrl, int $onlyInstructorId ): bool {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only enrolment selector.
$enrollmentId = absint( Val::int( $_GET['enrollment_id'] ?? 0 ) );
if ( $enrollmentId <= 0 ) {
return false;
}
$enrollment = $this->enrollments->findById( $enrollmentId );
$notice = '';
$error = '';
if ( null === $enrollment || ( $onlyInstructorId > 0 && $enrollment->instructorId !== $onlyInstructorId ) ) {
$row = null;
$answers = [];
$accepts = [];
$intake = $this->emptyIntake();
} else {
// Recorded before the tables are read, so what was just entered appears
// on the page that reports it.
[ $notice, $error ] = $this->recordIntake( $enrollment );
$row = $this->enrollmentRow( $enrollment );
$answers = $this->audit->answers( $enrollment );
$accepts = $this->audit->acceptances( $enrollment );
$intake = $this->intakeForm( $enrollment );
}
include USC_PLUGIN_DIR . 'templates/admin/enrollment-detail.php';
return true;
}
/**
* Who and what one enrolment is, for the head of its detail view.
*
* @return array{enrollment_id: int, student: string, class: string, instructor: string, status: string, payment: string}
*/
private function enrollmentRow( Enrollment $enrollment ): array {
$student = get_userdata( $enrollment->studentId );
$offering = $this->offerings->findById( $enrollment->offeringId );
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
return [
'enrollment_id' => (int) $enrollment->id,
'student' => UserName::format( $student instanceof \WP_User ? $student : null, $enrollment->studentId ),
'class' => null !== $offering ? $offering->title : '—',
'instructor' => null !== $offering ? $this->instructorName( $offering ) : '—',
'status' => $enrollment->status,
'payment' => null !== $payment ? $payment->status : '—',
];
}
/**
* Handle a submitted "record intake collected elsewhere" form.
*
* @return array{string, string} Success notice and error message.
*/
private function recordIntake( Enrollment $enrollment ): array {
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_group_action' ) ) {
return [ '', '' ];
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
if ( 'record_intake' !== sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
return [ '', '' ];
}
$answers = [];
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized below.
foreach ( (array) ( $_POST['answers'] ?? [] ) as $questionId => $value ) {
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each element is coerced to a positive int; slashes cannot survive integer coercion.
$rawVersionIds = (array) ( $_POST['accepted_policy_version_ids'] ?? [] );
$versionIds = array_values( array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $rawVersionIds ) ) );
$result = $this->intake->record(
$enrollment,
$answers,
$versionIds,
sanitize_key( Val::string( wp_unslash( $_POST['collected_via'] ?? '' ) ) ),
sanitize_text_field( Val::string( wp_unslash( $_POST['collected_note'] ?? '' ) ) ),
get_current_user_id()
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
return $result instanceof \WP_Error
? [ '', $result->get_error_message() ]
: [ $result, '' ];
}
/**
* What the detail template needs to offer the recording form: whether this
* enrolment qualifies at all, what is still missing, and the collection
* methods to choose between.
*
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
*/
private function intakeForm( Enrollment $enrollment ): array {
if ( ! $enrollment->isStaffRegistered() ) {
return $this->emptyIntake();
}
return [ 'recordable' => true ] + $this->intake->pending( $enrollment ) + [ 'methods' => IntakeProvenance::choices() ];
}
/**
* The form data for an enrolment that cannot be recorded against — one the
* student made, or one that could not be opened at all.
*
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
*/
private function emptyIntake(): array {
return [
'recordable' => false,
'questions' => [],
'policies' => [],
'methods' => [],
];
}
/**
* Summary row for one class in the instructor overview: its identity, when it
* meets, and how many active enrolments it holds against capacity.
@@ -176,7 +322,7 @@ class GroupClassController {
* invite-only classes — the list of people invited but not yet enrolled.
*
* @param list<Enrollment> $enrollments
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{id: int, student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
*/
private function classDetail( Offering $offering, array $enrollments ): array {
$roster = [];
@@ -189,6 +335,7 @@ class GroupClassController {
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
$roster[] = [
'id' => (int) $enrollment->id,
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
'status' => $enrollment->status,
'payment' => $payment?->status,
@@ -334,6 +481,10 @@ class GroupClassController {
offeringId: (int) $offering->id,
studentId: $studentId,
instructorId: $offering->instructorId,
// Stamped so this enrolment can be told apart later: only one the
// studio made may have its intake recorded after the fact, the
// student never having been asked the questions.
enrolledBy: get_current_user_id(),
)
);
+7 -2
View File
@@ -16,6 +16,7 @@ use Unsupervised\Schedular\Auth\StudentAdminGuard;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonBooker;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
@@ -101,6 +102,10 @@ class Plugin {
$stripe = new StripeGateway( $settings );
$paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe, $creditRepo );
// The booking core is shared by the REST endpoint students book through and
// the admin form staff book on their behalf with.
$lessonBooker = new LessonBooker( $availability, $bookings, $offerings, $paymentService, $guardians );
// The shortcode and block wrappers share the same page objects so
// front-end output is identical whichever way a page embeds them.
$registrationMailer = new RegistrationMailer();
@@ -121,8 +126,8 @@ class Plugin {
( new StudentAdminGuard() )->register();
( new DeletedUserCleanup( $bookings, $availability, $enrollments, $paymentService, $guardianRepo, $guardians ) )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians, $lessonBooker, $registrationGate ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians, $lessonBooker ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
}
+4 -1
View File
@@ -21,9 +21,12 @@ class AcceptanceRepository {
'registration_type' => $acceptance->registrationType,
'registration_id' => $acceptance->registrationId,
'ip_address' => $acceptance->ipAddress,
'collected_via' => $acceptance->collectedVia,
'collected_note' => $acceptance->collectedNote,
'recorded_by' => $acceptance->recordedBy,
'accepted_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%d', '%s', '%d', '%s', '%s' ]
[ '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
+19
View File
@@ -32,6 +32,19 @@ class PolicyAcceptance {
*/
public readonly int $acceptedBy = 0,
public readonly ?string $ipAddress = null,
/**
* How this acceptance reached the studio when it was not given online — see
* {@see \Unsupervised\Schedular\Registration\IntakeProvenance}. Null is the
* ordinary case: the student ticked the box themselves.
*/
public readonly ?string $collectedVia = null,
public readonly ?string $collectedNote = null,
/**
* The staff member who typed it in, when somebody did. Distinct from
* `acceptedBy`: the student still agreed, on paper or over the phone — this
* is only who entered the record of it.
*/
public readonly int $recordedBy = 0,
public readonly ?string $acceptedAt = null,
public readonly ?int $id = null,
) {}
@@ -44,6 +57,9 @@ class PolicyAcceptance {
registrationId: Val::int( $row->registration_id ),
acceptedBy: Val::int( $row->accepted_by ?? 0 ),
ipAddress: Val::stringOrNull( $row->ip_address ),
collectedVia: Val::stringOrNull( $row->collected_via ?? null ),
collectedNote: Val::stringOrNull( $row->collected_note ?? null ),
recordedBy: Val::int( $row->recorded_by ?? 0 ),
acceptedAt: Val::stringOrNull( $row->accepted_at ),
id: Val::int( $row->id ),
);
@@ -81,6 +97,9 @@ class PolicyAcceptance {
'registration_type' => $this->registrationType,
'registration_id' => $this->registrationId,
'ip_address' => $this->ipAddress,
'collected_via' => $this->collectedVia,
'collected_note' => $this->collectedNote,
'recorded_by' => $this->recordedBy,
'accepted_at' => $this->acceptedAt,
];
}
+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 ) {
+3 -2
View File
@@ -9,6 +9,7 @@ use Unsupervised\Schedular\Availability\WindowValidator;
use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\Booking\LessonBooker;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
@@ -39,9 +40,9 @@ class RestRegistrar {
private EnrollmentEndpoint $enrollmentEndpoint;
private PaymentEndpoint $paymentEndpoint;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians ) {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians, LessonBooker $booker ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, $booker, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
+8
View File
@@ -40,6 +40,7 @@ class Schema {
status VARCHAR(20) NOT NULL DEFAULT 'pending',
payment_id BIGINT UNSIGNED DEFAULT NULL,
notes TEXT,
booked_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY slot_id (slot_id),
@@ -104,6 +105,9 @@ class Schema {
registration_id BIGINT UNSIGNED NOT NULL,
student_id BIGINT UNSIGNED NOT NULL,
answer_value TEXT,
collected_via VARCHAR(20) DEFAULT NULL,
collected_note VARCHAR(191) DEFAULT NULL,
recorded_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY question_id (question_id),
@@ -145,6 +149,9 @@ class Schema {
registration_id BIGINT UNSIGNED NOT NULL,
accepted_at DATETIME NOT NULL,
ip_address VARCHAR(45) DEFAULT NULL,
collected_via VARCHAR(20) DEFAULT NULL,
collected_note VARCHAR(191) DEFAULT NULL,
recorded_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY policy_version_id (policy_version_id),
KEY student_id (student_id),
@@ -210,6 +217,7 @@ class Schema {
instructor_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
payment_id BIGINT UNSIGNED DEFAULT NULL,
enrolled_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
enrolled_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY offering_id (offering_id),