Merge pull request 'Let the studio book lessons and record intake collected elsewhere' (#183) from feature/182-admin-booking-and-intake-recording into main
Release / Build and Publish Release (push) Successful in 4m43s
Release / Open next-version bump PR (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 51s
CI / Tests (PHP 8.2) (push) Successful in 56s
CI / Tests (PHP 8.3) (push) Successful in 4m41s
CI / Tests (PHP 8.5) (push) Successful in 3m6s
CI / No Debug Code (push) Successful in 4s
CI / Coding Standards & Static Analysis (push) Successful in 8m23s
CI / Build Plugin Zip (push) Successful in 2m39s
Release / Build and Publish Release (push) Successful in 4m43s
Release / Open next-version bump PR (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 51s
CI / Tests (PHP 8.2) (push) Successful in 56s
CI / Tests (PHP 8.3) (push) Successful in 4m41s
CI / Tests (PHP 8.5) (push) Successful in 3m6s
CI / No Debug Code (push) Successful in 4s
CI / Coding Standards & Static Analysis (push) Successful in 8m23s
CI / Build Plugin Zip (push) Successful in 2m39s
Reviewed-on: #183
This commit was merged in pull request #183.
This commit is contained in:
@@ -13,6 +13,10 @@ each change under the current top section as you work.
|
||||
|
||||
## [1.5.3]
|
||||
|
||||
### Added
|
||||
- **Intake answers and policy agreements can now be recorded after the fact for a lesson the studio booked, or a student it added straight into a group class.** A lesson booked from wp-admin has no answers and no signed policies — nobody was at a keyboard to give them — and until now there was nowhere to put them once the studio did collect them at the first lesson or over the phone. The lesson's detail page now carries **Record intake collected elsewhere**, offering whatever is still outstanding: the unanswered questions and the policy versions with no acceptance on file. Fill in what you have, leave the rest, come back later — nothing already recorded can be overwritten, and a form posted twice cannot duplicate anything. Every recording must say **how** it was collected — a signed paper form, in person, over the phone, by email, or some other way you describe — and that answer is stamped on each entry along with your name. Both audit tables gained a **How it was given** column, so a policy accepted online and one transcribed from paper can never again look like the same thing. Only registrations the studio made have the panel: one a student made already holds their own answers, and those stay theirs alone. Group classes work the same way, reached from the new **Intake → View** link on each roster row of a class's detail page — a student added with **Add students directly** was never shown the enrolment form, and this is where what you collect instead now goes.
|
||||
- **You can now book a lesson for a student yourself, from Scheduler or My Lessons.** Group classes have always had **Add students directly**, but a private lesson could only be booked by the student — or by their parent, for a child — so a booking taken over the phone, or a make-up lesson an instructor wanted to slot in, had no way in short of asking the family to go and do it themselves. **Book a lesson for a student**, a panel at the top of both lesson pages, takes the student, an open time and the lesson type and books it there and then. Tick **Reserve this time weekly** to hold the same time for the rest of the term, or **No charge** for a make-up or goodwill lesson — that one skips payment entirely and confirms the lesson immediately, where an ordinary booking raises a pending payment at the lesson type's price and confirms when it settles, exactly as a student's own booking does. The **Scheduler** reaches every instructor's open times; **My Lessons** shows an instructor only their own. Booking this way does not ask the intake questions or record the policy agreements the student would give themselves — those stay theirs to answer, so a lesson booked for someone simply shows none on its detail page.
|
||||
|
||||
## [1.5.2]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -15,6 +15,7 @@ A group class can be marked **invite-only** (`us_offerings.access_mode = invite_
|
||||
| `instructor_id`| BIGINT UNSIGNED | WordPress user ID (denormalised from the offering) |
|
||||
| `status` | VARCHAR(20) | `active` / `cancelled` / `completed` |
|
||||
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
|
||||
| `enrolled_by` | BIGINT UNSIGNED | Staff member who added the student from wp-admin; 0 when the student (or their guardian) enrolled themselves |
|
||||
| `enrolled_at` | DATETIME | Insertion time |
|
||||
|
||||
## Class Dates, Time, and Instructor
|
||||
@@ -180,6 +181,8 @@ controls beneath it:
|
||||
settled at once by `PaymentService`). No access grant is needed — this writes straight
|
||||
to `us_group_enrollments` + `us_payments`. It bypasses the enrolment deadline and
|
||||
capacity, so it doubles as the **late-enrolment** path after a class has closed.
|
||||
The enrolment records who added them (`enrolled_by`), which is what later allows
|
||||
its intake to be recorded — see **Recording Intake Collected Elsewhere**.
|
||||
2. **Make available** — the selected registered students get an `invited` grant so the
|
||||
class appears in their own group-class list; they then self-enrol through the normal
|
||||
paid flow. Each is emailed a "you've been added" notice.
|
||||
@@ -230,12 +233,46 @@ class becomes enrollable for them — they choose whether to enrol.
|
||||
instructor. The summary (`templates/admin/my-group-classes.php`) and the details page
|
||||
(`templates/admin/my-group-class-detail.php`) are separate templates.
|
||||
|
||||
## Recording Intake Collected Elsewhere
|
||||
A student the studio added with **Add students directly** has no intake answers
|
||||
and no policy acceptances: they were never shown the enrolment form. The answers
|
||||
are collected another way — a paper form at the first class, a phone call to a
|
||||
parent — and recorded afterwards from the **enrolment detail page**, reached from
|
||||
the **Intake → View** link on each roster row.
|
||||
|
||||
The page shows who and what the enrolment is, the audit trail of everything
|
||||
answered and agreed to so far, and — for a studio-made enrolment only — a
|
||||
**Record intake collected elsewhere** panel offering whatever is still missing.
|
||||
Every recording must say **how** it was collected (signed paper form / in person /
|
||||
over the phone / by email / some other way, the last requiring an explanation),
|
||||
and that is stamped on every row along with who entered it. Both audit tables
|
||||
carry a **How it was given** column, so a policy ticked online and one transcribed
|
||||
from paper never look alike.
|
||||
|
||||
**Only a studio-made enrolment qualifies** (`Enrollment::isStaffRegistered()`,
|
||||
i.e. `enrolled_by > 0`). An enrolment the student made already holds their own
|
||||
answers, and letting staff add to it would make the record editable after the
|
||||
event. Nothing already recorded can be overwritten: the submission is narrowed to
|
||||
what is genuinely still pending before anything is written, so a stale or
|
||||
double-posted form is harmless.
|
||||
|
||||
This is the same mechanism the Scheduler uses for lessons it booked, and the
|
||||
reasoning behind each rule — why no IP is stored, why `accepted_by` stays the
|
||||
student while `recorded_by` names the staff member — is set out once in
|
||||
**Recording Intake Collected Elsewhere** in `lesson-booking.md`. An enrolment is
|
||||
its own registration, so unlike a weekly lesson series there is no anchor to
|
||||
follow: one enrolment, one intake record, however many sessions the term holds.
|
||||
|
||||
Scoping matches the rest of the detail pages: an instructor may only open
|
||||
enrolments in their own classes, a `view_all_lessons` studio admin any.
|
||||
|
||||
## Implementation
|
||||
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
|
||||
- Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`)
|
||||
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
|
||||
- Sessions: `Unsupervised\Schedular\GroupClass\SessionSchedule` (`upcomingForStudent`, `upcomingForInstructor`) — consumed by `Booking\BookingEndpoint::myLessons()` and `Auth\StudentController`
|
||||
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` — `renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`)
|
||||
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` — `renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`). Both also route `?enrollment_id=` to the enrolment detail view (`maybeRenderEnrollmentDetail`, template `templates/admin/enrollment-detail.php`)
|
||||
- Intake audit + late recording: `Unsupervised\Schedular\Registration\IntakeAudit` and `IntakeRecording`, shared with lesson bookings. `Enrollment` implements `Registration\IntakeSubject` to take part
|
||||
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
|
||||
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute). In single-class mode `assets/js/group-classes.js` leaves the class description out of the card, since the page it is embedded on already describes the class; the schedule, instructor, schedule note, price and enrolment controls are still shown.
|
||||
- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
|
||||
|
||||
@@ -17,6 +17,7 @@ Students register for a private lesson by choosing an offering, picking a time (
|
||||
| `status` | VARCHAR(20) | `pending` / `confirmed` / `cancelled` |
|
||||
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
|
||||
| `notes` | TEXT | Optional student notes |
|
||||
| `booked_by` | BIGINT UNSIGNED | Staff member who booked it for the student; 0 when the student (or their guardian) booked it themselves |
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
|
||||
## Registration Flow
|
||||
@@ -82,6 +83,90 @@ availability or the offering catalog, and a booking-only embed never requests
|
||||
`GET /bookings`. An unrecognised value renders the whole page, so a typo cannot
|
||||
silently hide half of it.
|
||||
|
||||
## Booking For A Student (Admin)
|
||||
A guardian can book for their children, but nobody else can book for anyone —
|
||||
which leaves the studio unable to take a booking over the phone, and an
|
||||
instructor unable to slot in a make-up lesson. **Book a lesson for a student**,
|
||||
a collapsed panel at the top of both **Scheduler** and **My Lessons**, is the
|
||||
private-lesson counterpart to the group class's **Add students directly**.
|
||||
|
||||
Pick the student, an open time, and (for a general time) the lesson type; tick
|
||||
**Reserve this time weekly** for a term, **No charge** for a make-up or goodwill
|
||||
lesson. The times offered are the open slots of the next eight weeks — every
|
||||
instructor's on the studio **Scheduler**, only the instructor's own on **My
|
||||
Lessons**, which `AdminBooking::book()` re-checks rather than trusting the
|
||||
posted slot id. The result is reported as a notice above the panel saying what
|
||||
was booked and what it left owing; a refusal reopens the panel with the reason.
|
||||
|
||||
It is the same booking a student makes — `LessonBooker` claims the slot(s),
|
||||
writes the lesson row(s), and raises the payment exactly as `POST /bookings`
|
||||
does — and differs in three deliberate ways:
|
||||
|
||||
1. **No intake answers or policy acceptances are recorded at booking time.**
|
||||
Those are the student's to give; staff ticking the boxes for them would be an
|
||||
audit trail that says something untrue. They can instead be collected some
|
||||
other way and recorded afterwards — see **Recording Intake Collected
|
||||
Elsewhere**.
|
||||
2. **It is not bounded by what the student could book themselves**, the way a
|
||||
direct group-class enrolment bypasses the enrolment deadline.
|
||||
3. **It can be booked at no charge** — no payment at all, and the lesson (or
|
||||
whole series) is `confirmed` at once. Without the tick a pending payment is
|
||||
raised at the lesson type's price, per-occurrence for a weekly reservation,
|
||||
and the lesson confirms when it settles like any other.
|
||||
|
||||
A weekly reservation needs a time that actually repeats: asked for one on a
|
||||
one-off slot, the form refuses (`not_weekly`) rather than quietly booking a
|
||||
single lesson, since the person booking asked for a term and would otherwise
|
||||
find out from the roster.
|
||||
|
||||
## Recording Intake Collected Elsewhere
|
||||
A lesson the studio booked has no intake answers and no policy acceptances,
|
||||
because nobody was at a keyboard to give them. The studio collects them another
|
||||
way — a paper form at the first lesson, a phone call — and records them
|
||||
afterwards from the lesson's **detail page**: a **Record intake collected
|
||||
elsewhere** panel below the two audit tables.
|
||||
|
||||
**Only a staff-booked lesson has the panel** (`Lesson::isStaffRegistered()`, i.e.
|
||||
`booked_by > 0`). A lesson the student booked already carries their own answers,
|
||||
and letting staff add to them would make the record editable after the fact. The
|
||||
same instructor/studio scoping as the rest of the detail page applies: an
|
||||
instructor may only open their own lessons, the studio **Scheduler** any.
|
||||
|
||||
The panel offers **only what is still missing** — questions with no answer,
|
||||
current policy versions with no acceptance — and narrows the submission to that
|
||||
set again before writing, so a stale or double-posted form can neither duplicate
|
||||
a row nor overwrite one. Nothing is compulsory except the provenance: a studio
|
||||
holding half the answers records the half it has and comes back for the rest.
|
||||
|
||||
### How they were collected
|
||||
Every recording must say **how** the answers reached the studio — on a signed
|
||||
paper form, in person, over the phone, by email, or some other way (which must be
|
||||
explained in the accompanying note). The method and note are stamped on every row
|
||||
the recording writes, alongside **who typed it in**, and both audit tables carry a
|
||||
**How it was given** column reading either "Given online when booking" or, say,
|
||||
"On a signed paper form — Filed in the studio binder — recorded by Jane Doe".
|
||||
|
||||
That column is the point of the feature. "Accepted on 24 Aug" means one thing
|
||||
when a student ticked a box and quite another when a staff member transcribed it,
|
||||
and an audit trail that cannot tell them apart is worse than none, because it
|
||||
looks like one.
|
||||
|
||||
Two details keep the record honest:
|
||||
|
||||
- **No IP address is stored.** The student was never at a browser; borrowing the
|
||||
staff member's would put a false location in the trail.
|
||||
- **The acceptance stays in the student's name** (`accepted_by`) — they did agree,
|
||||
on paper or over the phone. `recorded_by` is who entered it, which is a
|
||||
different question and gets a different column.
|
||||
|
||||
A weekly reservation is answered for once, so a recording made against any
|
||||
occurrence lands on the series anchor (`Lesson::intakeRegistrationId()`) and
|
||||
shows on every occurrence — the same rule the display side already follows.
|
||||
|
||||
The whole mechanism is shared with group-class enrolments, which have the same
|
||||
gap for the same reason; see **Recording Intake Collected Elsewhere** in
|
||||
`group-classes.md`.
|
||||
|
||||
## Cancellation
|
||||
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
|
||||
Cancelling marks the lesson `cancelled`, frees the availability slot for
|
||||
@@ -138,8 +223,8 @@ Group classes follow the same registration flow but enrol against an offering of
|
||||
kind `group_class`; see `group-classes.md`.
|
||||
|
||||
## Admin Interface
|
||||
- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors
|
||||
- **My Lessons** (`view_own_lessons`): upcoming lessons — and upcoming sessions of the instructor's own group classes — for the logged-in instructor. Hidden for users who also hold `view_all_lessons` — Scheduler is a superset, so the menu item would only duplicate it.
|
||||
- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors, plus the **Book a lesson for a student** panel (see below), which reaches every instructor's open times
|
||||
- **My Lessons** (`view_own_lessons`): upcoming lessons — and upcoming sessions of the instructor's own group classes — for the logged-in instructor, plus the same **Book a lesson for a student** panel scoped to their own open times. Hidden for users who also hold `view_all_lessons` — Scheduler is a superset, so the menu item would only duplicate it.
|
||||
|
||||
Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week`
|
||||
query params, same pattern as the availability page, bucketed via
|
||||
@@ -157,10 +242,14 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
|
||||
|
||||
## Implementation
|
||||
- Repository: `Unsupervised\Schedular\Booking\BookingRepository` (`insertSeries()` builds a weekly series sharing a `series_id`)
|
||||
- Booking core: `Unsupervised\Schedular\Booking\LessonBooker` — `resolveOffering()` (which offering a slot may be booked as), `reserve()` (claim the slot(s), write the lesson row(s)), `settle()` (raise the payment, or confirm when nothing is owed). Shared by `BookingEndpoint` and `AdminBooking` so the two paths cannot drift on price, payment routing, or double-booking.
|
||||
- Admin booking: `Unsupervised\Schedular\Booking\AdminBooking` — `book()` (guards, then the booker) and `formData()` (the panel's student / time / lesson-type choices)
|
||||
- Late intake: `Unsupervised\Schedular\Registration\IntakeRecording` — `pending()` (what is still unrecorded) and `record()` (the staff-registered guard, the dedup, then `RegistrationGate::record()` with an `IntakeProvenance`). Generic over `Registration\IntakeSubject`, which `Booking\Lesson` and `GroupClass\Enrollment` both implement
|
||||
- Provenance: `Unsupervised\Schedular\Registration\IntakeProvenance` — the collection-method vocabulary, its validation, and how a stored row reads on screen. Persisted as `collected_via` / `collected_note` / `recorded_by` on both `us_question_answers` and `us_policy_acceptances`; all null/0 for anything given online.
|
||||
- Model: `Unsupervised\Schedular\Booking\Lesson`
|
||||
- Registration gate: `Unsupervised\Schedular\Registration\RegistrationGate` — validates and records intake answers + booking-scoped policy acceptances; shared with group enrolment
|
||||
- Admin controller: `Unsupervised\Schedular\Booking\LessonController`
|
||||
- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`. A weekly series is answered for and agreed to once, against the anchor lesson, so the presenter reads `series_id ?? id` — every occurrence shows the same intake and audit trail, not just the first.
|
||||
- Admin lesson detail presenter: `Unsupervised\Schedular\Registration\IntakeAudit` (a registration's intake answers + policy acceptances), template `templates/admin/lesson-detail.php`. Shared with the group-class enrolment detail view. A weekly series is answered for and agreed to once, against the anchor lesson, so `Lesson::intakeRegistrationId()` reads `series_id ?? id` — every occurrence shows the same intake and audit trail, not just the first.
|
||||
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
|
||||
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
|
||||
- Upcoming-lessons panel: rendered client-side into `#us-my-lessons` by `assets/js/booking.js` (`lessonRowHtml`/`renderMyLessons`), mirrored for the editor by `BlockPreview::upcomingLessons()` — keep the two markup shapes in step.
|
||||
@@ -181,10 +270,11 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
|
||||
> inline default. New booking-page rules should follow both conventions.
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/Booking/AdminBookingTest.php`
|
||||
- `tests/Unit/Registration/IntakeRecordingTest.php`, `tests/Unit/Registration/IntakeAuditTest.php`
|
||||
- `tests/Unit/Booking/BookingRepositoryTest.php`
|
||||
- `tests/Unit/Booking/LessonTest.php`
|
||||
- `tests/Unit/Booking/LessonControllerTest.php`
|
||||
- `tests/Unit/Booking/LessonDetailTest.php`
|
||||
- `tests/Unit/Booking/BookingEndpointTest.php`
|
||||
|
||||
## Booking For Someone Else
|
||||
|
||||
@@ -36,7 +36,10 @@ The studio admin drafts, versions, and publishes policies (e.g. cancellation, pa
|
||||
| `registration_type` | VARCHAR(20) | `lesson` or `enrollment` |
|
||||
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.id` |
|
||||
| `accepted_at` | DATETIME | Timestamp of acceptance |
|
||||
| `ip_address` | VARCHAR(45) | IP captured at acceptance (audit trail) |
|
||||
| `ip_address` | VARCHAR(45) | IP captured at acceptance (audit trail); NULL when not given online |
|
||||
| `collected_via` | VARCHAR(20) | How the acceptance reached the studio when it was not given online (`paper` / `in_person` / `phone` / `email` / `other`); NULL means online |
|
||||
| `collected_note` | VARCHAR(191) | Free-text detail for the above; required for `other` |
|
||||
| `recorded_by` | BIGINT UNSIGNED | Staff member who typed a collected-elsewhere acceptance in; 0 otherwise |
|
||||
|
||||
## Versioning & Acceptance Rules
|
||||
- Editing a published policy creates a new `draft` version; the old version stays `published` until the draft is published.
|
||||
@@ -91,3 +94,10 @@ cover every policy's current version or the registration is rejected.
|
||||
box, where that differs from `student_id` — a guardian agreeing on a child's
|
||||
behalf. It defaults to 0, read back as "the student agreed for themselves"
|
||||
(`PolicyAcceptance::acceptorOrStudent()`). See `parent-guardian-accounts.md`.
|
||||
|
||||
`recorded_by` answers a different question: who *entered* the acceptance, for one
|
||||
collected on paper or over the phone and typed in afterwards. The student still
|
||||
agreed, so `accepted_by` stays theirs; `collected_via` says how, and no IP is
|
||||
stored because they were never at a browser. Only lessons the studio booked can
|
||||
be recorded against — see **Recording Intake Collected Elsewhere** in
|
||||
`lesson-booking.md`.
|
||||
|
||||
@@ -57,6 +57,9 @@ account-holder form to differ from.
|
||||
| `question_id` | BIGINT UNSIGNED | FK → `us_questions.id` |
|
||||
| `registration_type` | VARCHAR(20) | `lesson`, `enrollment`, or `account` |
|
||||
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id`, `us_group_enrollments.id`, or the user ID (account scope) |
|
||||
| `collected_via` | VARCHAR(20) | How the answer reached the studio when it was not given online (`paper` / `in_person` / `phone` / `email` / `other`); NULL means online |
|
||||
| `collected_note` | VARCHAR(191) | Free-text detail for the above; required for `other` |
|
||||
| `recorded_by` | BIGINT UNSIGNED | Staff member who typed a collected-elsewhere answer in; 0 otherwise |
|
||||
| `student_id` | BIGINT UNSIGNED | WordPress user ID (denormalised for fast lookup) |
|
||||
| `answer_value` | TEXT | The submitted answer (checkbox stored as `0`/`1`) |
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
|
||||
+13
-4
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+16
-145
@@ -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' ) ) );
|
||||
@@ -283,92 +236,27 @@ class BookingEndpoint {
|
||||
}
|
||||
|
||||
$notes = Val::string( $request->get_param( 'notes' ) );
|
||||
$recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' )
|
||||
? Lesson::RECURRENCE_WEEKLY
|
||||
: Lesson::RECURRENCE_SINGLE;
|
||||
|
||||
$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 ] );
|
||||
if ( $reservation instanceof \WP_Error ) {
|
||||
return $reservation;
|
||||
}
|
||||
|
||||
$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 ];
|
||||
}
|
||||
$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'] ?? '' ) ) );
|
||||
|
||||
@@ -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
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' );
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
// 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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array{enrollment_id: int, student: string, class: string, instructor: string, status: string, payment: string}|null $row
|
||||
* @var list<array{question: string, answer: string, source: string}> $answers
|
||||
* @var list<array{policy: string, version: string, accepted_at: string, ip: string, source: string}> $accepts
|
||||
* @var string $baseUrl
|
||||
* @var string $notice
|
||||
* @var string $error
|
||||
* @var 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>} $intake
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Enrolment details', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<p><a href="<?php echo esc_url($baseUrl); ?>">« <?php esc_html_e('Back to group classes', 'unsupervised-schedular'); ?></a></p>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-success"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ('' !== $error) : ?>
|
||||
<div class="notice notice-error"><p><?php echo esc_html($error); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (null === $row) : ?>
|
||||
<p><?php esc_html_e('This enrolment could not be found.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="form-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['class']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Enrolment', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['status']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Payment', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['payment']); ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Policies accepted', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($accepts)) : ?>
|
||||
<p><?php esc_html_e('None recorded for this enrolment.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Policy', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Version', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Accepted', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('IP address', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('How it was given', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($accepts as $acceptance) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($acceptance['policy']); ?></td>
|
||||
<td><?php echo esc_html($acceptance['version']); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['accepted_at'] ? (string) mysql2date('M j, Y g:i A', $acceptance['accepted_at']) : '—'); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['ip'] ? $acceptance['ip'] : '—'); ?></td>
|
||||
<td><?php echo esc_html($acceptance['source']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($answers)) : ?>
|
||||
<p><?php esc_html_e('None recorded for this enrolment.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('How it was given', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($answers as $answer) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($answer['question']); ?></td>
|
||||
<td><?php echo esc_html($answer['answer']); ?></td>
|
||||
<td><?php echo esc_html($answer['source']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($intake['recordable']) : ?>
|
||||
<h2><?php esc_html_e('Record intake collected elsewhere', 'unsupervised-schedular'); ?></h2>
|
||||
<p class="description" style="max-width:45em;">
|
||||
<?php esc_html_e('The studio enrolled this student, so they were never asked these questions online. Anything collected another way — on paper, in person, over the phone — can be entered here. Each entry is stamped with how it was collected and who entered it, and nothing already recorded can be overwritten.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
|
||||
<?php if (empty($intake['questions']) && empty($intake['policies'])) : ?>
|
||||
<p><?php esc_html_e('Everything has been recorded for this enrolment.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_group_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="record_intake">
|
||||
|
||||
<?php if (! empty($intake['policies'])) : ?>
|
||||
<h3><?php esc_html_e('Policies accepted elsewhere', 'unsupervised-schedular'); ?></h3>
|
||||
<?php foreach ($intake['policies'] as $policy) : ?>
|
||||
<p style="margin:0 0 6px;">
|
||||
<label>
|
||||
<input type="checkbox" name="accepted_policy_version_ids[]" value="<?php echo esc_attr((string) $policy['version_id']); ?>">
|
||||
<?php echo esc_html($policy['policy'] . ' (' . $policy['version'] . ')'); ?>
|
||||
</label>
|
||||
</p>
|
||||
<?php endforeach; ?>
|
||||
<p class="description"><?php esc_html_e('Tick only what the student actually agreed to. The acceptance is recorded in their name, against the version shown.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (! empty($intake['questions'])) : ?>
|
||||
<h3><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h3>
|
||||
<table class="form-table" role="presentation">
|
||||
<?php foreach ($intake['questions'] as $question) : ?>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="usc-answer-<?php echo esc_attr((string) $question['id']); ?>">
|
||||
<?php echo esc_html($question['label']); ?>
|
||||
<?php if ($question['required']) : ?>
|
||||
<span class="description">(<?php esc_html_e('required of students', 'unsupervised-schedular'); ?>)</span>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text" class="regular-text"
|
||||
id="usc-answer-<?php echo esc_attr((string) $question['id']); ?>"
|
||||
name="answers[<?php echo esc_attr((string) $question['id']); ?>]">
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<p class="description"><?php esc_html_e('Leave blank anything you do not have. You can come back and record the rest later.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3><?php esc_html_e('How were these collected?', 'unsupervised-schedular'); ?></h3>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-collected-via"><?php esc_html_e('Collected', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="collected_via" id="usc-collected-via" required>
|
||||
<option value=""><?php esc_html_e('Choose one', 'unsupervised-schedular'); ?></option>
|
||||
<?php foreach ($intake['methods'] as $value => $label) : ?>
|
||||
<option value="<?php echo esc_attr($value); ?>"><?php echo esc_html($label); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e('Recorded against every entry below, so the audit trail never mistakes a transcription for something the student typed.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-collected-note"><?php esc_html_e('Details', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="text" class="regular-text" id="usc-collected-note" name="collected_note" maxlength="191">
|
||||
<p class="description"><?php esc_html_e('Optional — where the paper form is filed, who took the call. Required if you chose "Some other way".', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
<button type="submit" class="button button-primary"><?php esc_html_e('Record intake', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -7,9 +7,12 @@ if (! defined('ABSPATH')) {
|
||||
|
||||
/**
|
||||
* @var array{lesson_id: int, student: string, instructor: string, offering: string, duration: int, recurrence: string, time: string, status: string, notes: string, payment_id: int, currency: string, total: float}|null $row
|
||||
* @var list<array{question: string, answer: string}> $answers
|
||||
* @var list<array{policy: string, version: string, accepted_at: string, ip: string}> $accepts
|
||||
* @var list<array{question: string, answer: string, source: string}> $answers
|
||||
* @var list<array{policy: string, version: string, accepted_at: string, ip: string, source: string}> $accepts
|
||||
* @var string $backUrl
|
||||
* @var string $notice
|
||||
* @var string $error
|
||||
* @var 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>} $intake
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
@@ -17,6 +20,14 @@ if (! defined('ABSPATH')) {
|
||||
|
||||
<p><a href="<?php echo esc_url($backUrl); ?>">« <?php esc_html_e('Back to lessons', 'unsupervised-schedular'); ?></a></p>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-success"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ('' !== $error) : ?>
|
||||
<div class="notice notice-error"><p><?php echo esc_html($error); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (null === $row) : ?>
|
||||
<p><?php esc_html_e('This lesson could not be found.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
@@ -81,6 +92,7 @@ if (! defined('ABSPATH')) {
|
||||
<th><?php esc_html_e('Version', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Accepted', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('IP address', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('How it was given', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -90,6 +102,7 @@ if (! defined('ABSPATH')) {
|
||||
<td><?php echo esc_html($acceptance['version']); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['accepted_at'] ? (string) mysql2date('M j, Y g:i A', $acceptance['accepted_at']) : '—'); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['ip'] ? $acceptance['ip'] : '—'); ?></td>
|
||||
<td><?php echo esc_html($acceptance['source']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
@@ -105,6 +118,7 @@ if (! defined('ABSPATH')) {
|
||||
<tr>
|
||||
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('How it was given', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -112,10 +126,90 @@ if (! defined('ABSPATH')) {
|
||||
<tr>
|
||||
<td><?php echo esc_html($answer['question']); ?></td>
|
||||
<td><?php echo esc_html($answer['answer']); ?></td>
|
||||
<td><?php echo esc_html($answer['source']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($intake['recordable']) : ?>
|
||||
<h2><?php esc_html_e('Record intake collected elsewhere', 'unsupervised-schedular'); ?></h2>
|
||||
<p class="description" style="max-width:45em;">
|
||||
<?php esc_html_e('The studio booked this lesson, so the student was never asked these questions online. Anything collected another way — on paper, in person, over the phone — can be entered here. Each entry is stamped with how it was collected and who entered it, and nothing already recorded can be overwritten.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
|
||||
<?php if (empty($intake['questions']) && empty($intake['policies'])) : ?>
|
||||
<p><?php esc_html_e('Everything has been recorded for this booking.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_lesson_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="record_intake">
|
||||
|
||||
<?php if (! empty($intake['policies'])) : ?>
|
||||
<h3><?php esc_html_e('Policies accepted elsewhere', 'unsupervised-schedular'); ?></h3>
|
||||
<?php foreach ($intake['policies'] as $policy) : ?>
|
||||
<p style="margin:0 0 6px;">
|
||||
<label>
|
||||
<input type="checkbox" name="accepted_policy_version_ids[]" value="<?php echo esc_attr((string) $policy['version_id']); ?>">
|
||||
<?php echo esc_html($policy['policy'] . ' (' . $policy['version'] . ')'); ?>
|
||||
</label>
|
||||
</p>
|
||||
<?php endforeach; ?>
|
||||
<p class="description"><?php esc_html_e('Tick only what the student actually agreed to. The acceptance is recorded in their name, against the version shown.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (! empty($intake['questions'])) : ?>
|
||||
<h3><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h3>
|
||||
<table class="form-table" role="presentation">
|
||||
<?php foreach ($intake['questions'] as $question) : ?>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="usc-answer-<?php echo esc_attr((string) $question['id']); ?>">
|
||||
<?php echo esc_html($question['label']); ?>
|
||||
<?php if ($question['required']) : ?>
|
||||
<span class="description">(<?php esc_html_e('required of students', 'unsupervised-schedular'); ?>)</span>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<input type="text" class="regular-text"
|
||||
id="usc-answer-<?php echo esc_attr((string) $question['id']); ?>"
|
||||
name="answers[<?php echo esc_attr((string) $question['id']); ?>]">
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<p class="description"><?php esc_html_e('Leave blank anything you do not have. You can come back and record the rest later.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3><?php esc_html_e('How were these collected?', 'unsupervised-schedular'); ?></h3>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-collected-via"><?php esc_html_e('Collected', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="collected_via" id="usc-collected-via" required>
|
||||
<option value=""><?php esc_html_e('Choose one', 'unsupervised-schedular'); ?></option>
|
||||
<?php foreach ($intake['methods'] as $value => $label) : ?>
|
||||
<option value="<?php echo esc_attr($value); ?>"><?php echo esc_html($label); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e('Recorded against every entry below, so the audit trail never mistakes a transcription for something the student typed.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-collected-note"><?php esc_html_e('Details', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="text" class="regular-text" id="usc-collected-note" name="collected_note" maxlength="191">
|
||||
<p class="description"><?php esc_html_e('Optional — where the paper form is filed, who took the call. Required if you chose "Some other way".', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
<button type="submit" class="button button-primary"><?php esc_html_e('Record intake', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -13,11 +13,102 @@ if (! defined('ABSPATH')) {
|
||||
* @var string $prevWeek
|
||||
* @var string $nextWeek
|
||||
* @var string $baseUrl
|
||||
* @var string $notice
|
||||
* @var string $error
|
||||
* @var array{students: list<array{id: int, name: string}>, offerings: list<array{id: int, label: string}>, slots: list<array{id: int, label: string, weekly: bool}>} $bookForm
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Lessons', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-success"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ('' !== $error) : ?>
|
||||
<div class="notice notice-error"><p><?php echo esc_html($error); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<details class="usc-book-for-student" style="margin:12px 0;" <?php echo '' !== $error ? 'open' : ''; ?>>
|
||||
<summary style="cursor:pointer; font-weight:600;"><?php esc_html_e('Book a lesson for a student', 'unsupervised-schedular'); ?></summary>
|
||||
|
||||
<?php if (empty($bookForm['students']) || empty($bookForm['slots'])) : ?>
|
||||
<p class="description" style="margin-top:8px;">
|
||||
<?php
|
||||
echo empty($bookForm['students'])
|
||||
? esc_html__('There are no students to book for yet.', 'unsupervised-schedular')
|
||||
: esc_html__('There are no open times in the next eight weeks. Add availability first.', 'unsupervised-schedular');
|
||||
?>
|
||||
</p>
|
||||
<?php else : ?>
|
||||
<form method="post" style="margin-top:8px;">
|
||||
<?php wp_nonce_field('usc_lesson_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="book_for_student">
|
||||
<p class="description">
|
||||
<?php esc_html_e('Books on the student\'s behalf, without the intake questions and policy agreements they would answer themselves.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
<table class="form-table" role="presentation">
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-book-student"><?php esc_html_e('Student', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="student_id" id="usc-book-student" required>
|
||||
<option value=""><?php esc_html_e('Choose a student', 'unsupervised-schedular'); ?></option>
|
||||
<?php foreach ($bookForm['students'] as $student) : ?>
|
||||
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-book-slot"><?php esc_html_e('Time', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="slot_id" id="usc-book-slot" required style="max-width:100%;">
|
||||
<option value=""><?php esc_html_e('Choose an open time', 'unsupervised-schedular'); ?></option>
|
||||
<?php foreach ($bookForm['slots'] as $slot) : ?>
|
||||
<option value="<?php echo esc_attr((string) $slot['id']); ?>"><?php echo esc_html($slot['label']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-book-offering"><?php esc_html_e('Lesson type', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="offering_id" id="usc-book-offering" style="max-width:100%;">
|
||||
<option value="0"><?php esc_html_e('Use the time\'s own lesson type', 'unsupervised-schedular'); ?></option>
|
||||
<?php foreach ($bookForm['offerings'] as $offering) : ?>
|
||||
<option value="<?php echo esc_attr((string) $offering['id']); ?>"><?php echo esc_html($offering['label']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e('A time already tied to a lesson type is booked as that type; a general time needs one chosen here.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Options', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" name="recurrence_weekly" value="1">
|
||||
<?php esc_html_e('Reserve this time weekly for the rest of the term', 'unsupervised-schedular'); ?>
|
||||
</label>
|
||||
<p class="description"><?php esc_html_e('Only for a time that repeats weekly. Billed upfront as one payment.', 'unsupervised-schedular'); ?></p>
|
||||
<label>
|
||||
<input type="checkbox" name="no_charge" value="1">
|
||||
<?php esc_html_e('No charge — book it free and confirm it now', 'unsupervised-schedular'); ?>
|
||||
</label>
|
||||
<p class="description"><?php esc_html_e('For a make-up or goodwill lesson. Otherwise a pending payment is raised at the lesson type\'s price.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="usc-book-notes"><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></label></th>
|
||||
<td><input type="text" name="notes" id="usc-book-notes" class="regular-text" maxlength="500"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
<button type="submit" class="button button-primary"><?php esc_html_e('Book lesson', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</details>
|
||||
|
||||
<ul class="subsubsub" style="margin-bottom:12px;">
|
||||
<li>
|
||||
<a href="<?php echo esc_url($baseUrl); ?>" <?php echo 'week' === $view ? 'class="current"' : ''; ?>><?php esc_html_e('Week', 'unsupervised-schedular'); ?></a> |
|
||||
|
||||
@@ -6,7 +6,7 @@ if (! defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @var 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}>} $class
|
||||
* @var 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}>} $class
|
||||
* @var list<array{id: int, name: string}> $students
|
||||
* @var string $notice
|
||||
* @var string $baseUrl
|
||||
@@ -122,6 +122,7 @@ if (! defined('ABSPATH')) {
|
||||
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Enrolment', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Payment', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Intake', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -130,6 +131,9 @@ if (! defined('ABSPATH')) {
|
||||
<td><?php echo esc_html($entry['student']); ?></td>
|
||||
<td><?php echo esc_html($entry['status']); ?></td>
|
||||
<td><?php echo esc_html($entry['payment'] ?? '—'); ?></td>
|
||||
<td>
|
||||
<a href="<?php echo esc_url(add_query_arg('enrollment_id', (string) $entry['id'], $baseUrl)); ?>"><?php esc_html_e('View', 'unsupervised-schedular'); ?></a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
||||
@@ -98,7 +98,7 @@ class StudentHistoryTest extends TestCase
|
||||
public function testIntakeAnswersResolveQuestionLabels(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||
new Answer(4, Answer::REG_ENROLLMENT, 3, 5, 'Beginner', 1),
|
||||
new Answer(4, Answer::REG_ENROLLMENT, 3, 5, 'Beginner', id: 1),
|
||||
]);
|
||||
$this->questions->shouldReceive('findById')->with(4)
|
||||
->andReturn(new Question(1, 'Experience level', id: 4));
|
||||
@@ -120,7 +120,7 @@ class StudentHistoryTest extends TestCase
|
||||
public function testIntakeAnswersFallBackWhenQuestionIsGone(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, null, 1),
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, null, id: 1),
|
||||
]);
|
||||
$this->questions->shouldReceive('findById')->with(4)->andReturn(null);
|
||||
|
||||
@@ -133,8 +133,8 @@ class StudentHistoryTest extends TestCase
|
||||
public function testIntakeAnswersExcludeAccountScopeAnswers(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||
new Answer(9, Answer::REG_ACCOUNT, 5, 5, 'By a friend', 2),
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, 'Beginner', 1),
|
||||
new Answer(9, Answer::REG_ACCOUNT, 5, 5, 'By a friend', id: 2),
|
||||
new Answer(4, Answer::REG_LESSON, 12, 5, 'Beginner', id: 1),
|
||||
]);
|
||||
// Only the booking-scoped answer is resolved; the account answer is dropped.
|
||||
$this->questions->shouldReceive('findById')->with(4)
|
||||
@@ -150,7 +150,7 @@ class StudentHistoryTest extends TestCase
|
||||
public function testRegistrationInfoPairsAccountQuestionsWithAnswers(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_ACCOUNT, 5)->andReturn([
|
||||
new Answer(4, Answer::REG_ACCOUNT, 5, 5, 'Yes', 1),
|
||||
new Answer(4, Answer::REG_ACCOUNT, 5, 5, 'Yes', id: 1),
|
||||
]);
|
||||
$this->questions->shouldReceive('findByScope')->once()->with(Question::SCOPE_ACCOUNT)->andReturn([
|
||||
new Question(null, 'Consent to email', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 4),
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\AdminBooking;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonBooker;
|
||||
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\Tests\Unit\TestCase;
|
||||
|
||||
class AdminBookingTest extends TestCase
|
||||
{
|
||||
private AvailabilityRepository&Mockery\MockInterface $availability;
|
||||
private BookingRepository&Mockery\MockInterface $bookings;
|
||||
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||
private PaymentService&Mockery\MockInterface $payments;
|
||||
private GuardianService&Mockery\MockInterface $guardians;
|
||||
private AdminBooking $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
Functions\when('current_time')->justReturn('2026-06-01 10:00:00');
|
||||
Functions\when('mysql2date')->alias(
|
||||
static fn (string $format, string $date): string => date($format, (int) strtotime($date))
|
||||
);
|
||||
Functions\when('get_userdata')->justReturn(false);
|
||||
Functions\when('get_users')->justReturn([]);
|
||||
// Everyone offered in the picker can book; the guard is exercised on its own.
|
||||
Functions\when('user_can')->justReturn(true);
|
||||
// The staff member doing the booking; stamped on the lesson as booked_by.
|
||||
Functions\when('get_current_user_id')->justReturn(3);
|
||||
|
||||
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->payments = Mockery::mock(PaymentService::class);
|
||||
$this->guardians = Mockery::mock(GuardianService::class);
|
||||
$this->guardians->shouldReceive('payerFor')->andReturnUsing(static fn (int $id): int => $id)->byDefault();
|
||||
|
||||
// The real booker over mocked repositories: an admin booking must go
|
||||
// through exactly the machinery a student's own booking does.
|
||||
$this->admin = new AdminBooking(
|
||||
$this->availability,
|
||||
$this->offerings,
|
||||
new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians)
|
||||
);
|
||||
}
|
||||
|
||||
public function testBooksASingleLessonAndRaisesAPendingPayment(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||
|
||||
$this->bookings->shouldReceive('insert')->once()->with(Mockery::on(
|
||||
static fn (Lesson $l): bool => 7 === $l->slotId
|
||||
&& 42 === $l->studentId
|
||||
&& 9 === $l->instructorId
|
||||
&& 3 === $l->offeringId
|
||||
&& Lesson::RECURRENCE_SINGLE === $l->recurrence
|
||||
&& 'Booked by phone' === $l->notes
|
||||
// Stamped with who booked it, which is what later lets the studio
|
||||
// record the intake it never had a chance to collect.
|
||||
&& 3 === $l->bookedBy
|
||||
))->andReturn(100);
|
||||
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 100, 42, 9, 40.0, 'CAD', null, null, null, 42)
|
||||
->andReturn($this->pendingPayment());
|
||||
|
||||
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, 'Booked by phone');
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('30 min piano', $notice);
|
||||
self::assertStringContainsString('Jul 1, 2026 10:00 AM', $notice);
|
||||
self::assertStringContainsString('pending payment', $notice);
|
||||
}
|
||||
|
||||
public function testNoChargeSkipsThePaymentAndConfirmsTheLesson(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->andReturn(100);
|
||||
|
||||
// The whole point of the no-charge tick: a priced offering raises nothing.
|
||||
$this->payments->shouldReceive('createForRegistration')->never();
|
||||
$this->bookings->shouldReceive('updateStatus')->once()->with(100, Lesson::STATUS_CONFIRMED)->andReturn(true);
|
||||
|
||||
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, true, '');
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('Nothing is owed', $notice);
|
||||
}
|
||||
|
||||
public function testWeeklyReservesEveryRemainingOccurrenceAndBillsForAllOfThem(): void
|
||||
{
|
||||
$slot = $this->slot(recurrenceGroup: 55);
|
||||
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($slot);
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('findUnbookedInGroup')->once()->with(55)->andReturn([
|
||||
$slot,
|
||||
$this->slot(id: 8, startDt: '2026-07-08 10:00:00', recurrenceGroup: 55),
|
||||
$this->slot(id: 9, startDt: '2026-07-15 10:00:00', recurrenceGroup: 55),
|
||||
]);
|
||||
$this->availability->shouldReceive('claim')->times(3)->andReturn(true);
|
||||
$this->bookings->shouldReceive('insertSeries')->once()
|
||||
->with(Mockery::type(Lesson::class), [7, 8, 9])
|
||||
->andReturn([100, 101, 102]);
|
||||
|
||||
// A per-lesson (one_time) price is owed once per occurrence claimed.
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 100, 42, 9, 120.0, 'CAD', null, null, null, 42)
|
||||
->andReturn($this->pendingPayment());
|
||||
|
||||
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_WEEKLY, false, '');
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('3 weekly lessons', $notice);
|
||||
}
|
||||
|
||||
public function testWeeklyIsRefusedOnATimeThatDoesNotRepeat(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
|
||||
// Nothing is claimed or written: the staff member asked for a term and is
|
||||
// told they cannot have one, rather than silently getting one lesson.
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
$this->bookings->shouldReceive('insert')->never();
|
||||
$this->bookings->shouldReceive('insertSeries')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_WEEKLY, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('not_weekly', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testInstructorScopeRefusesAnotherInstructorsTime(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
|
||||
// Slot belongs to instructor 9; My Lessons is scoped to instructor 4.
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '', 4);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_slot', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testAnAlreadyBookedTimeIsRefused(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(isBooked: true));
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('slot_taken', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testSomeoneWhoCannotBookLessonsIsRefused(): void
|
||||
{
|
||||
Functions\when('user_can')->justReturn(false);
|
||||
$this->availability->shouldReceive('findById')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_student', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testATiedTimeCannotBeBookedAsADifferentLessonType(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3));
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 4, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('offering_mismatch', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testATiedTimeBooksAsItsOwnLessonTypeWhenNoneIsChosen(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3));
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||
$this->bookings->shouldReceive('insert')->once()->with(Mockery::on(
|
||||
static fn (Lesson $l): bool => 3 === $l->offeringId
|
||||
))->andReturn(100);
|
||||
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
|
||||
|
||||
self::assertIsString($this->admin->book(42, 7, 0, Lesson::RECURRENCE_SINGLE, false, ''));
|
||||
}
|
||||
|
||||
public function testAGeneralTimeNeedsALessonTypeChosen(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||
$this->availability->shouldReceive('claim')->never();
|
||||
|
||||
$result = $this->admin->book(42, 7, 0, Lesson::RECURRENCE_SINGLE, false, '');
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('offering_required', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testFormDataScopesTimesAndTypesToOneInstructorAndLeavesTheirNameOff(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findAvailable')
|
||||
->once()
|
||||
->with(9, 0, 0, '', '2026-07-27 10:00:00')
|
||||
->andReturn([$this->slot(recurrenceGroup: 55)]);
|
||||
$this->offerings->shouldReceive('findAll')
|
||||
->once()
|
||||
->with(9, Offering::KIND_PRIVATE_LESSON, true)
|
||||
->andReturn([$this->offering()]);
|
||||
|
||||
$data = $this->admin->formData(9);
|
||||
|
||||
self::assertSame([['id' => 3, 'label' => '30 min piano (30 min)']], $data['offerings']);
|
||||
self::assertSame(1, count($data['slots']));
|
||||
self::assertTrue($data['slots'][0]['weekly']);
|
||||
self::assertStringContainsString('Wed Jul 1, 2026 10:00 AM (30 min)', $data['slots'][0]['label']);
|
||||
self::assertStringContainsString('repeats weekly', $data['slots'][0]['label']);
|
||||
}
|
||||
|
||||
public function testStudioWideFormDataNamesTheInstructorAndTheTimesTiedLessonType(): void
|
||||
{
|
||||
$this->availability->shouldReceive('findAvailable')->once()->andReturn([$this->slot(offeringId: 3)]);
|
||||
$this->offerings->shouldReceive('findAll')->once()->with(0, Offering::KIND_PRIVATE_LESSON, true)->andReturn([]);
|
||||
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||
Functions\when('get_userdata')->justReturn($this->user(9, 'Jane Doe'));
|
||||
|
||||
$data = $this->admin->formData(0);
|
||||
|
||||
self::assertStringContainsString('Jane Doe', $data['slots'][0]['label']);
|
||||
self::assertStringContainsString('30 min piano', $data['slots'][0]['label']);
|
||||
}
|
||||
|
||||
private function slot(
|
||||
int $id = 7,
|
||||
string $startDt = '2026-07-01 10:00:00',
|
||||
bool $isBooked = false,
|
||||
?int $offeringId = null,
|
||||
?int $recurrenceGroup = null
|
||||
): AvailabilitySlot {
|
||||
return new AvailabilitySlot(
|
||||
instructorId: 9,
|
||||
startDt: $startDt,
|
||||
endDt: date('Y-m-d H:i:s', (int) strtotime($startDt) + 1800),
|
||||
durationMinutes: 30,
|
||||
offeringId: $offeringId,
|
||||
isBooked: $isBooked,
|
||||
recurrenceGroup: $recurrenceGroup,
|
||||
id: $id,
|
||||
);
|
||||
}
|
||||
|
||||
private function offering(): Offering
|
||||
{
|
||||
return new Offering(
|
||||
instructorId: 9,
|
||||
kind: Offering::KIND_PRIVATE_LESSON,
|
||||
title: '30 min piano',
|
||||
price: 40.0,
|
||||
durationMinutes: 30,
|
||||
isActive: true,
|
||||
id: 3,
|
||||
);
|
||||
}
|
||||
|
||||
private function pendingPayment(): Payment
|
||||
{
|
||||
return new Payment(
|
||||
studentId: 42,
|
||||
instructorId: 9,
|
||||
registrationType: Payment::REG_LESSON,
|
||||
registrationId: 100,
|
||||
amount: 40.0,
|
||||
status: Payment::STATUS_PENDING,
|
||||
id: 500,
|
||||
);
|
||||
}
|
||||
|
||||
private function user(int $id, string $name): \WP_User
|
||||
{
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user->ID = $id;
|
||||
$user->first_name = '';
|
||||
$user->last_name = '';
|
||||
$user->nickname = $name;
|
||||
$user->display_name = $name;
|
||||
$user->user_login = 'jane';
|
||||
$user->user_email = '[email protected]';
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonBooker;
|
||||
use Unsupervised\Schedular\GroupClass\SessionSchedule;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
@@ -76,6 +77,10 @@ class BookingEndpointTest extends TestCase
|
||||
$this->offerings,
|
||||
$this->gate,
|
||||
$this->payments,
|
||||
// The real booker over the same mocked repositories: these tests are
|
||||
// about what a booking does end to end, and the booker is where most
|
||||
// of that now lives.
|
||||
new LessonBooker($this->availability, $this->bookings, $this->offerings, $this->payments, $this->guardians),
|
||||
new CancellationPolicy($this->settings),
|
||||
$this->guardians,
|
||||
$this->sessions,
|
||||
|
||||
@@ -36,9 +36,11 @@ class BookingRepositoryTest extends TestCase
|
||||
&& $data['student_id'] === 5
|
||||
&& $data['offering_id'] === 7
|
||||
&& $data['recurrence'] === Lesson::RECURRENCE_SINGLE
|
||||
&& $data['status'] === Lesson::STATUS_PENDING;
|
||||
&& $data['status'] === Lesson::STATUS_PENDING
|
||||
// Booked through the student-facing flow: no staff booker.
|
||||
&& $data['booked_by'] === 0;
|
||||
}),
|
||||
['%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s']
|
||||
['%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%d', '%s']
|
||||
);
|
||||
|
||||
$this->db->insert_id = 77;
|
||||
|
||||
@@ -9,8 +9,11 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonBooker;
|
||||
use Unsupervised\Schedular\Booking\AdminBooking;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\Registration\IntakeAudit;
|
||||
use Unsupervised\Schedular\Registration\IntakeRecording;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
@@ -21,7 +24,9 @@ class LessonControllerTest extends TestCase
|
||||
private PaymentRepository&Mockery\MockInterface $payments;
|
||||
private AvailabilityRepository&Mockery\MockInterface $availability;
|
||||
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||
private LessonDetail&Mockery\MockInterface $detail;
|
||||
private IntakeAudit&Mockery\MockInterface $detail;
|
||||
private AdminBooking&Mockery\MockInterface $adminBooking;
|
||||
private IntakeRecording&Mockery\MockInterface $intake;
|
||||
private LessonController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -32,8 +37,17 @@ class LessonControllerTest extends TestCase
|
||||
$this->payments = Mockery::mock(PaymentRepository::class);
|
||||
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->detail = Mockery::mock(LessonDetail::class);
|
||||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail);
|
||||
$this->detail = Mockery::mock(IntakeAudit::class);
|
||||
$this->adminBooking = Mockery::mock(AdminBooking::class);
|
||||
// The book-for-a-student panel has its own tests; here it is an empty form.
|
||||
$this->adminBooking->shouldReceive('formData')
|
||||
->andReturn(['students' => [], 'offerings' => [], 'slots' => []])->byDefault();
|
||||
$this->intake = Mockery::mock(IntakeRecording::class);
|
||||
// Most lessons here were booked by the student, so nothing is recordable;
|
||||
// the intake tests set up their own staff-booked lesson.
|
||||
$this->intake->shouldReceive('pending')
|
||||
->andReturn(['questions' => [], 'policies' => []])->byDefault();
|
||||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail, $this->adminBooking, $this->intake);
|
||||
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
@@ -50,6 +64,17 @@ class LessonControllerTest extends TestCase
|
||||
Functions\when('current_time')->justReturn('2026-07-06');
|
||||
Functions\when('admin_url')->alias(static fn (string $path) => 'https://example.test/wp-admin/' . $path);
|
||||
Functions\when('add_query_arg')->alias(static fn ($key, $value, $url) => $url . '&' . $key . '=' . $value);
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// The form-post tests fill $_POST; left behind it makes every later test
|
||||
// in the suite look like a form submission.
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAdminDashboardShowsSlotDateTimeInsteadOfSlotId(): void
|
||||
@@ -236,10 +261,10 @@ class LessonControllerTest extends TestCase
|
||||
// The lesson itself is handed over, so the presenter can follow a series
|
||||
// occurrence back to the anchor its answers and acceptances hang off.
|
||||
$this->detail->shouldReceive('answers')->once()->with($lesson)->andReturn([
|
||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||
['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking'],
|
||||
]);
|
||||
$this->detail->shouldReceive('acceptances')->once()->with($lesson)->andReturn([
|
||||
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'],
|
||||
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4', 'source' => 'Given online when booking'],
|
||||
]);
|
||||
|
||||
// The list of lessons must never be queried when routing to a detail view.
|
||||
@@ -274,6 +299,215 @@ class LessonControllerTest extends TestCase
|
||||
self::assertStringNotContainsString('Skill level', $html);
|
||||
}
|
||||
|
||||
public function testTheBookForAStudentPanelOffersTheOpenTimesAndStudents(): void
|
||||
{
|
||||
$this->adminBooking->shouldReceive('formData')->once()->with(0)->andReturn([
|
||||
'students' => [['id' => 42, 'name' => 'Ada Lovelace']],
|
||||
'offerings' => [['id' => 3, 'label' => '30 min piano (30 min)']],
|
||||
'slots' => [['id' => 7, 'label' => 'Wed Jul 1, 2026 10:00 AM (30 min)', 'weekly' => false]],
|
||||
]);
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Book a lesson for a student', $html);
|
||||
self::assertStringContainsString('Ada Lovelace', $html);
|
||||
self::assertStringContainsString('Wed Jul 1, 2026 10:00 AM (30 min)', $html);
|
||||
self::assertStringContainsString('name="usc_action" value="book_for_student"', $html);
|
||||
}
|
||||
|
||||
public function testTheStudioSchedulerBooksAgainstAnyInstructorsTime(): void
|
||||
{
|
||||
$this->postBooking();
|
||||
|
||||
// Scope 0: the studio Scheduler may book any instructor's open time.
|
||||
$this->adminBooking->shouldReceive('book')
|
||||
->once()
|
||||
->with(42, 7, 3, Lesson::RECURRENCE_WEEKLY, true, 'Make-up lesson', 0)
|
||||
->andReturn('Booked Ada Lovelace into 30 min piano.');
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Booked Ada Lovelace into 30 min piano.', $html);
|
||||
self::assertStringContainsString('notice-success', $html);
|
||||
self::assertStringNotContainsString(' open>', $html);
|
||||
}
|
||||
|
||||
public function testAnInstructorBooksOnlyAgainstTheirOwnTimes(): void
|
||||
{
|
||||
$this->postBooking();
|
||||
Functions\when('get_current_user_id')->justReturn(9);
|
||||
|
||||
// Scope 9: My Lessons must not reach another instructor's schedule.
|
||||
$this->adminBooking->shouldReceive('book')
|
||||
->once()
|
||||
->with(42, 7, 3, Lesson::RECURRENCE_WEEKLY, true, 'Make-up lesson', 9)
|
||||
->andReturn('Booked.');
|
||||
$this->adminBooking->shouldReceive('formData')->once()->with(9)->andReturn(
|
||||
['students' => [], 'offerings' => [], 'slots' => []]
|
||||
);
|
||||
$this->bookings->shouldReceive('findUpcomingForInstructor')->once()->with(9)->andReturn([]);
|
||||
|
||||
ob_start();
|
||||
$this->controller->renderInstructorLessons();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
self::assertStringContainsString('Booked.', $html);
|
||||
}
|
||||
|
||||
public function testARefusedBookingShowsWhyAndReopensTheForm(): void
|
||||
{
|
||||
$this->postBooking();
|
||||
|
||||
$this->adminBooking->shouldReceive('book')->once()
|
||||
->andReturn(new \WP_Error('slot_taken', 'That time has already been booked.'));
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('That time has already been booked.', $html);
|
||||
self::assertStringContainsString('notice-error', $html);
|
||||
// The panel is a collapsed <details>; an error opens it so the message is
|
||||
// not hidden behind the summary.
|
||||
self::assertStringContainsString(' open>', $html);
|
||||
}
|
||||
|
||||
/** Fill $_POST as the book-for-a-student form does. */
|
||||
private function postBooking(): void
|
||||
{
|
||||
$_POST = [
|
||||
'usc_action' => 'book_for_student',
|
||||
'student_id' => '42',
|
||||
'slot_id' => '7',
|
||||
'offering_id' => '3',
|
||||
'recurrence_weekly' => '1',
|
||||
'no_charge' => '1',
|
||||
'notes' => 'Make-up lesson',
|
||||
];
|
||||
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
}
|
||||
|
||||
public function testAStaffBookedLessonOffersTheRecordIntakeForm(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
|
||||
// booked_by 7: the studio booked this one, so its intake can be recorded.
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1);
|
||||
$this->expectDetail($lesson);
|
||||
|
||||
$this->intake->shouldReceive('pending')->once()->with($lesson)->andReturn([
|
||||
'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => true]],
|
||||
'policies' => [['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']],
|
||||
]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Record intake collected elsewhere', $html);
|
||||
self::assertStringContainsString('Anything we should know?', $html);
|
||||
self::assertStringContainsString('Cancellation', $html);
|
||||
self::assertStringContainsString('How were these collected?', $html);
|
||||
self::assertStringContainsString('On a signed paper form', $html);
|
||||
}
|
||||
|
||||
public function testALessonTheStudentBookedOffersNoRecordingForm(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
|
||||
// booked_by 0: the student booked it and gave their own answers.
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
|
||||
$this->expectDetail($lesson);
|
||||
|
||||
// Not even asked what is outstanding — the form is not on offer at all.
|
||||
$this->intake->shouldNotReceive('pending');
|
||||
|
||||
self::assertStringNotContainsString('Record intake collected elsewhere', $this->render());
|
||||
}
|
||||
|
||||
public function testSubmittedIntakeIsRecordedAndReported(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
$_POST = [
|
||||
'usc_action' => 'record_intake',
|
||||
'answers' => ['9' => 'Nut allergy'],
|
||||
'accepted_policy_version_ids' => ['6'],
|
||||
'collected_via' => 'paper',
|
||||
'collected_note' => 'Filed in the studio binder',
|
||||
];
|
||||
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
Functions\when('sanitize_textarea_field')->returnArg();
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1);
|
||||
$this->expectDetail($lesson);
|
||||
$this->intake->shouldReceive('pending')->andReturn(['questions' => [], 'policies' => []]);
|
||||
|
||||
$this->intake->shouldReceive('record')
|
||||
->once()
|
||||
->with($lesson, [9 => 'Nut allergy'], [6], 'paper', 'Filed in the studio binder', 7)
|
||||
->andReturn('Recorded 1 answer and 1 policy acceptance, collected: On a signed paper form');
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Recorded 1 answer and 1 policy acceptance', $html);
|
||||
self::assertStringContainsString('notice-success', $html);
|
||||
// Nothing left outstanding, so the form gives way to a plain statement.
|
||||
self::assertStringContainsString('Everything has been recorded for this booking.', $html);
|
||||
}
|
||||
|
||||
public function testARefusedRecordingSaysWhy(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
$_POST = [
|
||||
'usc_action' => 'record_intake',
|
||||
'answers' => ['9' => 'Nut allergy'],
|
||||
'collected_via' => 'other',
|
||||
];
|
||||
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
Functions\when('sanitize_textarea_field')->returnArg();
|
||||
Functions\when('get_current_user_id')->justReturn(7);
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: 7, id: 1);
|
||||
$this->expectDetail($lesson);
|
||||
$this->intake->shouldReceive('pending')->andReturn([
|
||||
'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => false]],
|
||||
'policies' => [],
|
||||
]);
|
||||
$this->intake->shouldReceive('record')->once()
|
||||
->andReturn(new \WP_Error('collection_note_required', 'Say how these were collected.'));
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Say how these were collected.', $html);
|
||||
self::assertStringContainsString('notice-error', $html);
|
||||
}
|
||||
|
||||
/** The lookups the detail view makes for one lesson, with an empty audit trail. */
|
||||
private function expectDetail(Lesson $lesson): void
|
||||
{
|
||||
$slot = new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-07-06 09:00:00',
|
||||
endDt: '2026-07-06 10:00:00',
|
||||
id: 10
|
||||
);
|
||||
|
||||
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($slot);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(null);
|
||||
$this->detail->shouldReceive('answers')->with($lesson)->andReturn([]);
|
||||
$this->detail->shouldReceive('acceptances')->with($lesson)->andReturn([]);
|
||||
}
|
||||
|
||||
private function render(): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class LessonTest extends TestCase
|
||||
@@ -76,4 +77,21 @@ class LessonTest extends TestCase
|
||||
self::assertArrayHasKey($key, $arr);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAWeeklySeriesSharesOneIntakeRegistration(): void
|
||||
{
|
||||
// Occurrence 12 of a series anchored on lesson 7: answered for once.
|
||||
$occurrence = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, seriesId: 7, id: 12);
|
||||
|
||||
self::assertSame(Answer::REG_LESSON, $occurrence->intakeRegistrationType());
|
||||
self::assertSame(7, $occurrence->intakeRegistrationId());
|
||||
// A single lesson is its own registration.
|
||||
self::assertSame(12, (new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 12))->intakeRegistrationId());
|
||||
}
|
||||
|
||||
public function testOnlyAStudioBookedLessonIsStaffRegistered(): void
|
||||
{
|
||||
self::assertFalse((new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 12))->isStaffRegistered());
|
||||
self::assertTrue((new Lesson(slotId: 10, studentId: 5, instructorId: 3, bookedBy: 9, id: 12))->isStaffRegistered());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,11 @@ class EnrollmentRepositoryTest extends TestCase
|
||||
return $d['offering_id'] === 7
|
||||
&& $d['student_id'] === 5
|
||||
&& $d['instructor_id'] === 3
|
||||
&& $d['status'] === Enrollment::STATUS_ACTIVE;
|
||||
&& $d['status'] === Enrollment::STATUS_ACTIVE
|
||||
// Enrolled through the student-facing flow: no staff enroller.
|
||||
&& $d['enrolled_by'] === 0;
|
||||
}),
|
||||
['%d', '%d', '%d', '%s', '%d', '%s']
|
||||
['%d', '%d', '%d', '%s', '%d', '%d', '%s']
|
||||
);
|
||||
$this->db->insert_id = 12;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class EnrollmentTest extends TestCase
|
||||
@@ -45,8 +46,25 @@ class EnrollmentTest extends TestCase
|
||||
{
|
||||
$arr = (new Enrollment(7, 5, 3, id: 12))->toArray();
|
||||
|
||||
foreach (['id', 'offering_id', 'student_id', 'instructor_id', 'status', 'payment_id'] as $key) {
|
||||
foreach (['id', 'offering_id', 'student_id', 'instructor_id', 'status', 'payment_id', 'enrolled_by'] as $key) {
|
||||
self::assertArrayHasKey($key, $arr);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAnEnrolmentIsItsOwnIntakeRegistration(): void
|
||||
{
|
||||
$enrollment = new Enrollment(7, 5, 3, id: 12);
|
||||
|
||||
self::assertSame(Answer::REG_ENROLLMENT, $enrollment->intakeRegistrationType());
|
||||
// No series anchor to follow: a term of classes is one enrolment.
|
||||
self::assertSame(12, $enrollment->intakeRegistrationId());
|
||||
self::assertSame(7, $enrollment->intakeOfferingId());
|
||||
self::assertSame(5, $enrollment->intakeStudentId());
|
||||
}
|
||||
|
||||
public function testOnlyAStudioMadeEnrolmentIsStaffRegistered(): void
|
||||
{
|
||||
self::assertFalse((new Enrollment(7, 5, 3, id: 12))->isStaffRegistered());
|
||||
self::assertTrue((new Enrollment(7, 5, 3, enrolledBy: 9, id: 12))->isStaffRegistered());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ 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\IntakeRecording;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class GroupClassControllerTest extends TestCase
|
||||
@@ -27,6 +29,8 @@ class GroupClassControllerTest extends TestCase
|
||||
private PaymentService&Mockery\MockInterface $paymentService;
|
||||
private InviteRepository&Mockery\MockInterface $invites;
|
||||
private RegistrationMailer&Mockery\MockInterface $mailer;
|
||||
private IntakeAudit&Mockery\MockInterface $audit;
|
||||
private IntakeRecording&Mockery\MockInterface $intake;
|
||||
private GroupClassController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -40,6 +44,8 @@ class GroupClassControllerTest extends TestCase
|
||||
$this->paymentService = Mockery::mock(PaymentService::class);
|
||||
$this->invites = Mockery::mock(InviteRepository::class);
|
||||
$this->mailer = Mockery::mock(RegistrationMailer::class);
|
||||
$this->audit = Mockery::mock(IntakeAudit::class);
|
||||
$this->intake = Mockery::mock(IntakeRecording::class);
|
||||
$this->controller = new GroupClassController(
|
||||
$this->enrollments,
|
||||
$this->offerings,
|
||||
@@ -48,6 +54,8 @@ class GroupClassControllerTest extends TestCase
|
||||
$this->paymentService,
|
||||
$this->invites,
|
||||
$this->mailer,
|
||||
$this->audit,
|
||||
$this->intake,
|
||||
);
|
||||
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
@@ -411,7 +419,11 @@ class GroupClassControllerTest extends TestCase
|
||||
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
|
||||
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
|
||||
$this->enrollments->shouldReceive('insert')->once()->andReturn(44);
|
||||
// Stamped with the staff member who added them (user 3), which is what
|
||||
// later lets the studio record the intake it never had a chance to ask for.
|
||||
$this->enrollments->shouldReceive('insert')->once()->with(Mockery::on(
|
||||
static fn (Enrollment $e): bool => 3 === $e->enrolledBy && $e->isStaffRegistered()
|
||||
))->andReturn(44);
|
||||
|
||||
$payment = new Payment(
|
||||
studentId: 5,
|
||||
@@ -435,6 +447,103 @@ class GroupClassControllerTest extends TestCase
|
||||
self::assertStringContainsString('1 student(s) added to the class.', $html);
|
||||
}
|
||||
|
||||
public function testEnrollmentIdOpensTheIntakeDetailView(): void
|
||||
{
|
||||
$_GET = ['enrollment_id' => '44'];
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||
|
||||
// enrolled_by 3: the studio added this student, so intake can be recorded.
|
||||
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 3, id: 44);
|
||||
$this->expectEnrollmentDetail($enrollment);
|
||||
|
||||
$this->intake->shouldReceive('pending')->once()->with($enrollment)->andReturn([
|
||||
'questions' => [['id' => 9, 'label' => 'Anything we should know?', 'required' => false]],
|
||||
'policies' => [['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']],
|
||||
]);
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('Enrolment details', $html);
|
||||
self::assertStringContainsString('Ada Lovelace', $html);
|
||||
self::assertStringContainsString('Record intake collected elsewhere', $html);
|
||||
self::assertStringContainsString('Anything we should know?', $html);
|
||||
self::assertStringContainsString('How were these collected?', $html);
|
||||
}
|
||||
|
||||
public function testAnEnrolmentTheStudentMadeOffersNoRecordingForm(): void
|
||||
{
|
||||
$_GET = ['enrollment_id' => '44'];
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||
|
||||
// enrolled_by 0: the student enrolled themselves and gave their own answers.
|
||||
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 44);
|
||||
$this->expectEnrollmentDetail($enrollment);
|
||||
|
||||
$this->intake->shouldNotReceive('pending');
|
||||
|
||||
self::assertStringNotContainsString('Record intake collected elsewhere', $this->renderInstructor());
|
||||
}
|
||||
|
||||
public function testAnInstructorCannotOpenAnotherInstructorsEnrolment(): void
|
||||
{
|
||||
$_GET = ['enrollment_id' => '44'];
|
||||
|
||||
// Enrolment belongs to instructor 9; the current user is 3.
|
||||
$this->enrollments->shouldReceive('findById')->once()->with(44)
|
||||
->andReturn(new Enrollment(offeringId: 8, studentId: 5, instructorId: 9, enrolledBy: 9, id: 44));
|
||||
$this->audit->shouldNotReceive('answers');
|
||||
$this->intake->shouldNotReceive('pending');
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('This enrolment could not be found.', $html);
|
||||
self::assertStringNotContainsString('Record intake collected elsewhere', $html);
|
||||
}
|
||||
|
||||
public function testSubmittedEnrolmentIntakeIsRecordedAndReported(): void
|
||||
{
|
||||
$_GET = ['enrollment_id' => '44'];
|
||||
$_POST = [
|
||||
'usc_action' => 'record_intake',
|
||||
'answers' => ['9' => 'Nut allergy'],
|
||||
'accepted_policy_version_ids' => ['6'],
|
||||
'collected_via' => 'phone',
|
||||
'collected_note' => 'Called the parent',
|
||||
];
|
||||
|
||||
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('sanitize_textarea_field')->returnArg();
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
|
||||
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 3, id: 44);
|
||||
$this->expectEnrollmentDetail($enrollment);
|
||||
$this->intake->shouldReceive('pending')->andReturn(['questions' => [], 'policies' => []]);
|
||||
|
||||
$this->intake->shouldReceive('record')
|
||||
->once()
|
||||
->with($enrollment, [9 => 'Nut allergy'], [6], 'phone', 'Called the parent', 3)
|
||||
->andReturn('Recorded 1 answer and 1 policy acceptance, collected: Over the phone');
|
||||
|
||||
$html = $this->renderInstructor();
|
||||
|
||||
self::assertStringContainsString('Recorded 1 answer and 1 policy acceptance', $html);
|
||||
self::assertStringContainsString('notice-success', $html);
|
||||
// The generic class-form handler must not also run and report a missing class.
|
||||
self::assertStringNotContainsString('That group class was not found.', $html);
|
||||
}
|
||||
|
||||
/** The lookups the enrolment detail view makes, with an empty audit trail. */
|
||||
private function expectEnrollmentDetail(Enrollment $enrollment): void
|
||||
{
|
||||
$this->enrollments->shouldReceive('findById')->once()->with(44)->andReturn($enrollment);
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(8, 'Choir', 10));
|
||||
$this->audit->shouldReceive('answers')->with($enrollment)->andReturn([]);
|
||||
$this->audit->shouldReceive('acceptances')->with($enrollment)->andReturn([]);
|
||||
}
|
||||
|
||||
public function testGrantAccessCreatesGrantAndEmailsStudent(): void
|
||||
{
|
||||
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
|
||||
|
||||
@@ -38,9 +38,13 @@ class AcceptanceRepositoryTest extends TestCase
|
||||
&& $d['registration_id'] === 12
|
||||
// No explicit acceptor: the student agreed for themselves.
|
||||
&& $d['accepted_by'] === 5
|
||||
&& $d['ip_address'] === '203.0.113.7';
|
||||
&& $d['ip_address'] === '203.0.113.7'
|
||||
// Ticked online: no collection provenance to record.
|
||||
&& null === $d['collected_via']
|
||||
&& null === $d['collected_note']
|
||||
&& 0 === $d['recorded_by'];
|
||||
}),
|
||||
['%d', '%d', '%d', '%s', '%d', '%s', '%s']
|
||||
['%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%d', '%s']
|
||||
);
|
||||
$this->db->insert_id = 1;
|
||||
|
||||
|
||||
@@ -36,9 +36,13 @@ class AnswerRepositoryTest extends TestCase
|
||||
&& $data['registration_type'] === Answer::REG_LESSON
|
||||
&& $data['registration_id'] === 12
|
||||
&& $data['student_id'] === 5
|
||||
&& $data['answer_value'] === 'Beginner';
|
||||
&& $data['answer_value'] === 'Beginner'
|
||||
// Given online: no collection provenance to record.
|
||||
&& null === $data['collected_via']
|
||||
&& null === $data['collected_note']
|
||||
&& 0 === $data['recorded_by'];
|
||||
}),
|
||||
['%d', '%s', '%d', '%d', '%s', '%s']
|
||||
['%d', '%s', '%d', '%d', '%s', '%s', '%s', '%d', '%s']
|
||||
);
|
||||
|
||||
$this->db->insert_id = 77;
|
||||
|
||||
@@ -10,7 +10,7 @@ class AnswerTest extends TestCase
|
||||
{
|
||||
public function testConstructorAndProperties(): void
|
||||
{
|
||||
$answer = new Answer(3, Answer::REG_LESSON, 12, 5, 'Beginner', 99);
|
||||
$answer = new Answer(3, Answer::REG_LESSON, 12, 5, 'Beginner', id: 99);
|
||||
|
||||
self::assertSame(3, $answer->questionId);
|
||||
self::assertSame(Answer::REG_LESSON, $answer->registrationType);
|
||||
|
||||
+46
-8
@@ -1,11 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Registration;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
@@ -13,19 +13,21 @@ use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\IntakeAudit;
|
||||
use Unsupervised\Schedular\Registration\IntakeProvenance;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class LessonDetailTest extends TestCase
|
||||
class IntakeAuditTest extends TestCase
|
||||
{
|
||||
private AnswerRepository&Mockery\MockInterface $answers;
|
||||
private QuestionRepository&Mockery\MockInterface $questions;
|
||||
private AcceptanceRepository&Mockery\MockInterface $acceptances;
|
||||
private PolicyRepository&Mockery\MockInterface $policies;
|
||||
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||
private LessonDetail $detail;
|
||||
private IntakeAudit $detail;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -37,7 +39,7 @@ class LessonDetailTest extends TestCase
|
||||
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||
|
||||
$this->detail = new LessonDetail(
|
||||
$this->detail = new IntakeAudit(
|
||||
$this->answers,
|
||||
$this->questions,
|
||||
$this->acceptances,
|
||||
@@ -58,8 +60,8 @@ class LessonDetailTest extends TestCase
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||
['question' => '#9', 'answer' => '—'],
|
||||
['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking'],
|
||||
['question' => '#9', 'answer' => '—', 'source' => 'Given online when booking'],
|
||||
],
|
||||
$this->detail->answers($this->lesson(7))
|
||||
);
|
||||
@@ -88,12 +90,47 @@ class LessonDetailTest extends TestCase
|
||||
'version' => 'v2',
|
||||
'accepted_at' => '2026-07-01 10:00:00',
|
||||
'ip' => '1.2.3.4',
|
||||
'source' => 'Given online when booking',
|
||||
],
|
||||
],
|
||||
$this->detail->acceptances($this->lesson(7))
|
||||
);
|
||||
}
|
||||
|
||||
public function testACollectedElsewhereAnswerNamesItsSourceAndWhoRecordedIt(): void
|
||||
{
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user->ID = 7;
|
||||
$user->first_name = 'Jane';
|
||||
$user->last_name = 'Doe';
|
||||
$user->nickname = 'jane';
|
||||
$user->display_name = 'jane';
|
||||
Functions\when('get_userdata')->justReturn($user);
|
||||
|
||||
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_LESSON, 7)->andReturn([
|
||||
new Answer(
|
||||
questionId: 2,
|
||||
registrationType: Answer::REG_LESSON,
|
||||
registrationId: 7,
|
||||
studentId: 5,
|
||||
answerValue: 'Nut allergy',
|
||||
collectedVia: IntakeProvenance::VIA_PAPER,
|
||||
collectedNote: 'Filed in the studio binder',
|
||||
recordedBy: 7
|
||||
),
|
||||
]);
|
||||
$this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Allergies', id: 2));
|
||||
|
||||
self::assertSame(
|
||||
[[
|
||||
'question' => 'Allergies',
|
||||
'answer' => 'Nut allergy',
|
||||
'source' => 'On a signed paper form — Filed in the studio binder — recorded by Jane Doe',
|
||||
]],
|
||||
$this->detail->answers($this->lesson(7))
|
||||
);
|
||||
}
|
||||
|
||||
public function testSeriesOccurrenceReadsTheAnchorsAnswersAndAcceptances(): void
|
||||
{
|
||||
// Occurrence #12 of a weekly reservation anchored on lesson 7: the intake
|
||||
@@ -119,7 +156,7 @@ class LessonDetailTest extends TestCase
|
||||
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
|
||||
|
||||
self::assertSame(
|
||||
[['question' => 'Skill level', 'answer' => 'Beginner']],
|
||||
[['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking']],
|
||||
$this->detail->answers($occurrence)
|
||||
);
|
||||
self::assertSame(
|
||||
@@ -128,6 +165,7 @@ class LessonDetailTest extends TestCase
|
||||
'version' => 'v2',
|
||||
'accepted_at' => '2026-07-01 10:00:00',
|
||||
'ip' => '1.2.3.4',
|
||||
'source' => 'Given online when booking',
|
||||
]],
|
||||
$this->detail->acceptances($occurrence)
|
||||
);
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Registration;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\IntakeProvenance;
|
||||
use Unsupervised\Schedular\Registration\IntakeRecording;
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class IntakeRecordingTest extends TestCase
|
||||
{
|
||||
private QuestionRepository&Mockery\MockInterface $questions;
|
||||
private AnswerRepository&Mockery\MockInterface $answers;
|
||||
private PolicyRepository&Mockery\MockInterface $policies;
|
||||
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||
private AcceptanceRepository&Mockery\MockInterface $acceptances;
|
||||
private RegistrationGate&Mockery\MockInterface $gate;
|
||||
private IntakeRecording $intake;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||
|
||||
$this->questions = Mockery::mock(QuestionRepository::class);
|
||||
$this->answers = Mockery::mock(AnswerRepository::class);
|
||||
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||
$this->acceptances = Mockery::mock(AcceptanceRepository::class);
|
||||
$this->gate = Mockery::mock(RegistrationGate::class);
|
||||
|
||||
$this->intake = new IntakeRecording(
|
||||
$this->questions,
|
||||
$this->answers,
|
||||
$this->policies,
|
||||
$this->versions,
|
||||
$this->acceptances,
|
||||
$this->gate
|
||||
);
|
||||
}
|
||||
|
||||
public function testPendingListsOnlyWhatIsNotYetRecorded(): void
|
||||
{
|
||||
$this->questions->shouldReceive('findByOffering')->with(8, true)->andReturn([
|
||||
new Question(offeringId: 8, label: 'Skill level', isRequired: true, id: 2),
|
||||
new Question(offeringId: 8, label: 'Anything we should know?', id: 9),
|
||||
]);
|
||||
// Question 2 was already answered; only question 9 is still outstanding.
|
||||
$this->answers->shouldReceive('findByRegistration')->with(Answer::REG_LESSON, 1)->andReturn([
|
||||
new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 1, studentId: 5, answerValue: 'Beginner'),
|
||||
]);
|
||||
|
||||
$this->gate->shouldReceive('requiredPolicyVersionIds')->andReturn([4, 6]);
|
||||
$this->acceptances->shouldReceive('findByRegistration')->with(PolicyAcceptance::REG_LESSON, 1)->andReturn([
|
||||
new PolicyAcceptance(policyVersionId: 4, studentId: 5, registrationType: PolicyAcceptance::REG_LESSON, registrationId: 1),
|
||||
]);
|
||||
$this->versions->shouldReceive('findById')->with(6)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 6));
|
||||
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
|
||||
|
||||
$pending = $this->intake->pending($this->lesson());
|
||||
|
||||
self::assertSame([['id' => 9, 'label' => 'Anything we should know?', 'required' => false]], $pending['questions']);
|
||||
self::assertSame([['version_id' => 6, 'policy' => 'Cancellation', 'version' => 'v2']], $pending['policies']);
|
||||
}
|
||||
|
||||
public function testRecordsTheAnswersAndAcceptancesWithHowTheyWereCollected(): void
|
||||
{
|
||||
$this->expectPending();
|
||||
|
||||
$this->gate->shouldReceive('record')
|
||||
->once()
|
||||
->with(
|
||||
PolicyAcceptance::REG_LESSON,
|
||||
1,
|
||||
5,
|
||||
8,
|
||||
[9 => 'Nut allergy'],
|
||||
[6],
|
||||
// No IP: the student was never at a browser, and the staff member's
|
||||
// would be a false location in the audit trail.
|
||||
null,
|
||||
// No acceptor override either: the student agreed, on paper.
|
||||
0,
|
||||
Mockery::on(static fn (IntakeProvenance $p): bool => IntakeProvenance::VIA_PAPER === $p->collectedVia
|
||||
&& 'Filed in the studio binder' === $p->collectedNote
|
||||
&& 3 === $p->recordedBy)
|
||||
);
|
||||
|
||||
$notice = $this->intake->record(
|
||||
$this->lesson(),
|
||||
[9 => 'Nut allergy'],
|
||||
[6],
|
||||
IntakeProvenance::VIA_PAPER,
|
||||
'Filed in the studio binder',
|
||||
3
|
||||
);
|
||||
|
||||
self::assertIsString($notice);
|
||||
self::assertStringContainsString('1 answer', $notice);
|
||||
self::assertStringContainsString('1 policy acceptance', $notice);
|
||||
self::assertStringContainsString('On a signed paper form', $notice);
|
||||
self::assertStringContainsString('Filed in the studio binder', $notice);
|
||||
}
|
||||
|
||||
public function testALessonTheStudentBookedThemselvesCannotBeRecordedAgainst(): void
|
||||
{
|
||||
// No repository is even consulted: the guard comes first, so a student's
|
||||
// own answers can never be added to after the fact.
|
||||
$this->gate->shouldReceive('record')->never();
|
||||
|
||||
$result = $this->intake->record(
|
||||
$this->lesson(bookedBy: 0),
|
||||
[9 => 'Nut allergy'],
|
||||
[],
|
||||
IntakeProvenance::VIA_PAPER,
|
||||
'',
|
||||
3
|
||||
);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('not_recordable', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testAnAlreadyRecordedAnswerOrAcceptanceIsIgnored(): void
|
||||
{
|
||||
$this->expectPending();
|
||||
$this->gate->shouldReceive('record')->never();
|
||||
|
||||
// Question 2 and version 4 are already on file — a stale form reposting
|
||||
// them must not duplicate or overwrite what is there.
|
||||
$result = $this->intake->record(
|
||||
$this->lesson(),
|
||||
[2 => 'Advanced'],
|
||||
[4],
|
||||
IntakeProvenance::VIA_PAPER,
|
||||
'',
|
||||
3
|
||||
);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('nothing_to_record', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testTheCollectionMethodIsRequiredAndMustBeOneOfTheKnownOnes(): void
|
||||
{
|
||||
$this->gate->shouldReceive('record')->never();
|
||||
|
||||
$result = $this->intake->record($this->lesson(), [9 => 'Nut allergy'], [], 'telepathy', '', 3);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_collection_method', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testOtherMustBeExplained(): void
|
||||
{
|
||||
$this->gate->shouldReceive('record')->never();
|
||||
|
||||
$result = $this->intake->record($this->lesson(), [9 => 'Nut allergy'], [], IntakeProvenance::VIA_OTHER, ' ', 3);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('collection_note_required', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testAWeeklySeriesRecordsAgainstItsAnchor(): void
|
||||
{
|
||||
// Occurrence #12 of a series anchored on lesson 1: answered for once.
|
||||
$occurrence = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, seriesId: 1, bookedBy: 3, id: 12);
|
||||
|
||||
$this->expectPending();
|
||||
|
||||
// Registration id 1, not 12: the whole series shares one intake record, so
|
||||
// opening any occurrence shows and adds to the same answers.
|
||||
$this->gate->shouldReceive('record')
|
||||
->once()
|
||||
->with(PolicyAcceptance::REG_LESSON, 1, 5, 8, [9 => 'Nut allergy'], [], null, 0, Mockery::any());
|
||||
|
||||
self::assertIsString($this->intake->record($occurrence, [9 => 'Nut allergy'], [], IntakeProvenance::VIA_PHONE, '', 3));
|
||||
}
|
||||
|
||||
public function testAGroupClassEnrolmentRecordsAgainstTheEnrolmentTable(): void
|
||||
{
|
||||
// The same recorder, a different registration type: an enrolment is its own
|
||||
// registration, so nothing follows a series anchor here.
|
||||
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, enrolledBy: 7, id: 44);
|
||||
|
||||
$this->questions->shouldReceive('findByOffering')->with(8, true)->andReturn([
|
||||
new Question(offeringId: 8, label: 'Anything we should know?', id: 9),
|
||||
]);
|
||||
$this->answers->shouldReceive('findByRegistration')->with(Answer::REG_ENROLLMENT, 44)->andReturn([]);
|
||||
$this->gate->shouldReceive('requiredPolicyVersionIds')->andReturn([]);
|
||||
$this->acceptances->shouldReceive('findByRegistration')->with(PolicyAcceptance::REG_ENROLLMENT, 44)->andReturn([]);
|
||||
|
||||
$this->gate->shouldReceive('record')
|
||||
->once()
|
||||
->with(Answer::REG_ENROLLMENT, 44, 5, 8, [9 => 'Nut allergy'], [], null, 0, Mockery::any());
|
||||
|
||||
self::assertIsString($this->intake->record($enrollment, [9 => 'Nut allergy'], [], IntakeProvenance::VIA_EMAIL, '', 7));
|
||||
}
|
||||
|
||||
public function testAnEnrolmentTheStudentMadeCannotBeRecordedAgainst(): void
|
||||
{
|
||||
$this->gate->shouldReceive('record')->never();
|
||||
|
||||
$result = $this->intake->record(
|
||||
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 44),
|
||||
[9 => 'Nut allergy'],
|
||||
[],
|
||||
IntakeProvenance::VIA_EMAIL,
|
||||
'',
|
||||
7
|
||||
);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('not_recordable', $result->get_error_code());
|
||||
}
|
||||
|
||||
/** The repository responses behind a lesson with question 9 and version 6 outstanding. */
|
||||
private function expectPending(): void
|
||||
{
|
||||
$this->questions->shouldReceive('findByOffering')->with(8, true)->andReturn([
|
||||
new Question(offeringId: 8, label: 'Skill level', id: 2),
|
||||
new Question(offeringId: 8, label: 'Anything we should know?', id: 9),
|
||||
]);
|
||||
$this->answers->shouldReceive('findByRegistration')->with(Answer::REG_LESSON, 1)->andReturn([
|
||||
new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 1, studentId: 5, answerValue: 'Beginner'),
|
||||
]);
|
||||
$this->gate->shouldReceive('requiredPolicyVersionIds')->andReturn([4, 6]);
|
||||
$this->acceptances->shouldReceive('findByRegistration')->with(PolicyAcceptance::REG_LESSON, 1)->andReturn([
|
||||
new PolicyAcceptance(policyVersionId: 4, studentId: 5, registrationType: PolicyAcceptance::REG_LESSON, registrationId: 1),
|
||||
]);
|
||||
$this->versions->shouldReceive('findById')->with(6)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 6));
|
||||
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
|
||||
}
|
||||
|
||||
private function lesson(int $bookedBy = 3): Lesson
|
||||
{
|
||||
return new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, bookedBy: $bookedBy, id: 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user