Compare commits

...
43 Commits
Author SHA1 Message Date
thatguygriffandClaude Opus 5 572aaf5b49 Publish prebuilt CI images to the Gitea container registry
CI Images / Build CI image (PHP 8.2) (pull_request) Successful in 1m14s
CI Images / Build CI image (PHP 8.1) (pull_request) Successful in 1m26s
CI Images / Build CI image (PHP 8.3) (pull_request) Successful in 2m3s
CI Images / Build CI image (PHP 8.5) (pull_request) Successful in 2m9s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m7s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 3m8s
CI / Tests (PHP 8.1) (pull_request) Successful in 5m55s
CI / Tests (PHP 8.5) (pull_request) Successful in 6m21s
CI / Coding Standards & Static Analysis (pull_request) Successful in 18m26s
CI / Build Plugin Zip (pull_request) Skipped
setup-php installs PHP 8.3+ from apt on these arm64 runners: a ~145s floor
against ~35s for 8.1/8.2, with a tail that twice ran past the step timeout
and failed the run (#178). Caching the .debs softened it without removing
the apt step, and 8.5 has the same problem.

Add a per-version CI image built on php:<version>-cli-alpine and a workflow
that publishes it to git.unsupervised.ca/unsupervised/ci-php:<version>. The
org is public, so the packages pull anonymously.

The image carries bash and nodejs because act_runner runs JavaScript actions
inside the job container, GNU coreutils/grep/sed because the workflow scripts
use `tac` and `grep --include`, and curl/jq/git/zip for release.yml and
bin/build-zip.sh. Composer 2 and the intl and zip extensions round it out.

Nothing consumes the images yet — ci.yml and release.yml switch over in a
follow-up, because a job cannot run in an image that has not been published.

Part of #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 21:55:28 -03:00
thatguygriff 116394f2ff Merge pull request 'Bump version to 1.5.5' (#189) from release/bump-1.5.5 into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 53s
CI / Tests (PHP 8.5) (push) Successful in 3m28s
CI / Tests (PHP 8.3) (push) Successful in 5m46s
CI / Tests (PHP 8.2) (push) Successful in 6m14s
CI / Coding Standards & Static Analysis (push) Failing after 12m16s
CI / Build Plugin Zip (push) Skipped
Reviewed-on: #189
2026-08-25 00:50:11 +00:00
Release Bot dda8386c1f Bump version to 1.5.5 and open changelog section 2026-08-24 23:50:02 +00:00
thatguygriff 7278309bf5 Merge pull request 'Let the studio register the students who cannot register themselves' (#186) from fix/185-book-for-child-and-vet-group-enrolment into main
CI / Coding Standards & Static Analysis (push) Successful in 13m23s
CI / Tests (PHP 8.1) (push) Successful in 55s
CI / Tests (PHP 8.2) (push) Successful in 1m7s
CI / Tests (PHP 8.3) (push) Successful in 6m59s
CI / Tests (PHP 8.5) (push) Successful in 12m55s
CI / No Debug Code (push) Successful in 3s
CI / Build Plugin Zip (push) Successful in 5m53s
Release / Build and Publish Release (push) Successful in 8m5s
Release / Open next-version bump PR (push) Successful in 5s
Reviewed-on: #186
2026-08-24 22:03:11 +00:00
thatguygriffandClaude Opus 5 5ce42f0003 Let the studio register the students who cannot register themselves
CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m3s
CI / Tests (PHP 8.5) (pull_request) Successful in 2m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 3m24s
CI / Coding Standards & Static Analysis (pull_request) Successful in 8m21s
CI / Build Plugin Zip (pull_request) Skipped
The Book a lesson for a student panel built its picker from the us_student
role but vetted the submission with the book_lesson capability. ChildLoginGate
and RegistrationLoginGate withhold that capability from accounts that keep the
role, so the panel offered every guardian-managed child and every unapproved
signup and then refused them — with a message claiming no student had been
chosen, and a form cleared of all five fields.

Withholding book_lesson stops those accounts registering in their own name. It
was never meant to stop the studio acting for them, which is what the panel is
for, and for a child is the only route to a lesson besides their guardian.

Guard the student role instead, via a new RoleManager::isStudent() shared with
every picker and guard on the staff side so the two cannot drift apart again.
Group enrolment gets the same predicate: addDirect() and grantAccess() vetted
their posted ids not at all, and would enrol an instructor, an administrator,
or an account deleted since the page was drawn — raising a real payment against
them for a priced class.

Keep a refused booking's fields as submitted, reading the form through one
LessonController::submittedBooking() so what gets booked and what is shown
again cannot disagree about a field name. A booking that succeeds still leaves
an empty form, so the next one does not inherit it.

Closes #185

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XunBYk2sFEc1oL14sUiuBU
2026-08-24 18:42:59 -03:00
thatguygriff 76530878b5 Merge pull request 'Bump version to 1.5.4' (#184) from release/bump-1.5.4 into main
CI / Tests (PHP 8.1) (push) Successful in 58s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.5) (push) Successful in 4m20s
CI / Coding Standards & Static Analysis (push) Successful in 5m21s
CI / Tests (PHP 8.2) (push) Successful in 6m4s
CI / Tests (PHP 8.3) (push) Successful in 12m56s
CI / Build Plugin Zip (push) Successful in 2m39s
Reviewed-on: #184
2026-08-24 17:59:40 +00:00
Release Bot 6031a75012 Bump version to 1.5.4 and open changelog section 2026-08-24 17:56:38 +00:00
thatguygriff 41843e5253 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
Reviewed-on: #183
2026-08-24 17:50:46 +00:00
thatguygriffandClaude Opus 5 8c21a3fa9d Let the studio book lessons and record intake collected elsewhere
CI / Tests (PHP 8.1) (pull_request) Successful in 6m39s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m59s
CI / Tests (PHP 8.5) (pull_request) Successful in 3m31s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m28s
CI / Build Plugin Zip (pull_request) Skipped
Two related gaps, closed together because the second is created by the first.

A private lesson could only be booked by the student or their guardian, so a
booking taken over the phone had no way in — where group classes have had "Add
students directly" all along. "Book a lesson for a student" is now a panel on
Scheduler and My Lessons: student, open time, lesson type, with weekly term
reservations and a no-charge option for make-up lessons. The booking core is
extracted to Booking\LessonBooker and shared with POST /bookings, so the two
paths cannot drift on offering rules, slot claiming, or billing.

That leaves a registration with no intake answers and no policy acceptances,
because nobody was at a keyboard to give them — already true of every directly
added group-class student. Ticking the boxes on a student's behalf would be an
audit trail that says something untrue, so instead the answers are collected
another way and recorded afterwards, from a lesson's or an enrolment's detail
page. Every recording must say how it was collected, which is stamped on each
row along with who typed it and shown in a new "How it was given" column: a
policy ticked online and one transcribed from paper must never look alike.

Only staff-made registrations qualify (us_lessons.booked_by,
us_group_enrollments.enrolled_by) — one the student made already holds their
own answers. Only what is still missing can be recorded, re-checked at write
time, so a stale or double-posted form cannot duplicate or overwrite. No IP is
stored for a transcription, and accepted_by stays the student while recorded_by
names the staff member.

Intake is now generic over Registration\IntakeSubject, which Lesson and
Enrollment both implement; LessonDetail became Registration\IntakeAudit and is
shared by both detail views rather than duplicated.

Closes #182

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QfHt6CyJHz6KkA4RuaS7WK
2026-08-24 14:06:16 -03:00
thatguygriff 8a34ec41e9 Merge pull request 'Cache the packages php-builder installs, and restore PHP 8.5' (#181) from ci/cache-php-builder-deps into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.2) (push) Successful in 2m27s
CI / Tests (PHP 8.1) (push) Successful in 2m33s
CI / Tests (PHP 8.3) (push) Successful in 2m55s
CI / Coding Standards & Static Analysis (push) Successful in 3m26s
CI / Tests (PHP 8.5) (push) Successful in 7m59s
CI / Build Plugin Zip (push) Successful in 6m59s
Reviewed-on: #181
2026-08-20 19:18:46 +00:00
thatguygriffandClaude Opus 5 37c8d2b39e Put PHP 8.5 back in the matrix
CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 55s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m15s
CI / Tests (PHP 8.5) (pull_request) Successful in 6m41s
CI / Tests (PHP 8.3) (pull_request) Failing after 12m21s
CI / Coding Standards & Static Analysis (pull_request) Successful in 13m48s
CI / Build Plugin Zip (pull_request) Skipped
I misread the instruction to hold 8.5 as "take it out until the cache
lands" and removed it in f6481d4, so #179 merged without it. It was meant
to stay where it was. Restoring it.

8.5 takes the same php-builder path as 8.3, so the apt cache in this
branch is exactly what it needs, and the per-version key means it caches
its own ~70 -dev packages rather than sharing with the ondrej-path jobs.

composer test passes on 8.5.9 locally: 915 tests, 2592 assertions.

Refs #178.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
2026-08-20 16:04:52 -03:00
thatguygriffandClaude Opus 5 a90e06ae70 Key the apt cache per PHP version and drop the instrumentation
CI / No Debug Code (pull_request) Successful in 26s
CI / Tests (PHP 8.1) (pull_request) Successful in 2m35s
CI / Tests (PHP 8.2) (pull_request) Successful in 2m35s
CI / Coding Standards & Static Analysis (pull_request) Successful in 4m1s
CI / Tests (PHP 8.3) (pull_request) Failing after 7m17s
CI / Build Plugin Zip (pull_request) Skipped
Run 536 proved the cache works -- ACTIONS_CACHE_URL is now populated, no
GHES warning, "Cache saved with key: apt-php-builder-deps-ARM64-v1" -- and
in doing so showed the key was wrong. The jobs that saved it were 8.1 and
8.2.

They take the ondrej PPA path, a handful of runtime packages, while 8.3
takes php-builder and its ~70 -dev packages. One shared key therefore lets
whichever job finishes first decide what every other job restores, and 8.1
is always first, at ~30s against 8.3's several minutes. 8.3 would have
restored a few runtime .debs it has no use for and then downloaded all 70
anyway. My "the dep list is the same for every PHP version" comment was
true only among the builder versions.

Keyed per version now, so each path caches what it actually installs. That
also picks up a small win on 8.1 and 8.2 rather than only avoiding harm.

Both temporary steps are gone. Report restored .debs was there to show the
cache URL arriving and the restore landing; both are established, so it
goes. Keep downloaded .debs stays -- it is not instrumentation, it disables
Ubuntu's docker-clean, without which there are no .debs left to cache.

Refs #178.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
2026-08-20 15:57:27 -03:00
thatguygriffandClaude Opus 5 d5f6ebf0b5 Move actions/cache to v4 and probe the cache URL
thatguygriff/infra#1 turned the runner cache server on, so actions/cache
has an endpoint for the first time. v4 rather than v3 because v4.2+ can
speak the cache service v2 API, which is what the runner serves.

The instrumentation step now also prints ACTIONS_CACHE_URL, so a single
run shows the endpoint arriving and the cache being read in one log
rather than needing a separate probe.

Refs #178.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
2026-08-20 15:56:37 -03:00
thatguygriffandClaude Opus 5 4b4b2453ae Cache the .debs php-builder installs, to measure whether it helps
Experiment for #178. On self-hosted runners setup-php installs PHP 8.3+
through php-builder, whose install.sh apt-installs ~70 -dev packages
before unpacking the build. The build tarball itself is only 19MB, so
that apt work is the whole cost, not the download.

Ubuntu's image drops the .debs after install, so every job fetches them
from the archive again. This keeps them and restores them through the
cache server, which lives in the cluster, so a WAN download becomes a
local one. The dependency list does not vary by PHP version, so a single
key serves 8.3 and 8.5 and the quality and build jobs alike.

Only the .debs are cached. /var/lib/apt/lists is deliberately left alone,
since a stale index is how apt starts 404ing mid-install, and reducing
flakiness is the entire point.

The Report restored .debs step is temporary instrumentation to show
whether the cache is actually being read. Baseline to beat, from run 526:
8.1 30s, 8.2 53s, 8.3 454s, quality 151s, 8.5 733s and a hard failure.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
2026-08-20 15:56:37 -03:00
thatguygriff 7f10769330 Merge pull request 'Fold PHPCS and PHPStan into one job' (#179) from ci/consolidate-quality-job-and-php-85 into main
CI / Tests (PHP 8.2) (push) Canceled after 1s
CI / Tests (PHP 8.1) (push) Successful in 34s
CI / No Debug Code (push) Successful in 27s
CI / Build Plugin Zip (push) Skipped
CI / Coding Standards & Static Analysis (push) Successful in 10s
CI / Tests (PHP 8.3) (push) Canceled after 15s
Reviewed-on: #179
2026-08-20 18:55:16 +00:00
thatguygriffandClaude Opus 5 f6481d4a3f Hold PHP 8.5 until the runner serves a cache
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m37s
CI / Tests (PHP 8.3) (pull_request) Successful in 13m2s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 1m21s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m22s
8.5 lands on setup-php's php-builder path, same as 8.3, so it pays for
~70 apt -dev packages on every run. It timed out in run 526 and passed in
527 on identical code, which is a coin toss, and merging it would mean
intermittent red for a version nothing ships on yet.

The code is fine on 8.5 — verified locally on 8.5.9: 915 tests, PHPStan
and PHPCS clean, check-platform-reqs satisfied. This is purely about the
runners, so 8.5 comes back once #178 is fixed and the matrix is cheap
again. This PR is now just the PHPCS/PHPStan consolidation.

Refs #178.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
2026-08-20 15:33:03 -03:00
thatguygriffandClaude Opus 5 43b903c1c8 Revert the v4 cache retry: the gate stays shut, so it is the runner
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m3s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m27s
CI / Tests (PHP 8.3) (pull_request) Successful in 6m47s
CI / Tests (PHP 8.5) (pull_request) Failing after 12m17s
CI / Build Plugin Zip (pull_request) Skipped
Tested and the hypothesis is dead. actions/cache@v4 resolved properly
(SHA 0057852) and printed the same GHES warning as v3, so the action
version was not what was closing the gate.

That points at the runner rather than the action. Gitea's docs say the
runner patches the GHES check out of the action's bundle only when it
recognises it, and that a bundle it does not recognise "is left alone".
No patching for either v3 or v4, and no ACTIONS_CACHE_URL to patch it
towards, is what you get when cache is simply off in the runner config.

So the fix is cache.enabled in act_runner's config.yaml, with host set to
an address job containers can reach. Nothing in this repository unblocks
it, and inert steps are what let the Composer cache rot unnoticed, so the
experiment comes out again. Both halves — the apt cache and the move to
v4 — should land together once the runner serves a cache.

Refs #178.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
2026-08-20 15:07:18 -03:00
thatguygriffandClaude Opus 5 d5eb2764a3 Retry the apt cache on actions/cache@v4
CI / Tests (PHP 8.1) (pull_request) Successful in 55s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m15s
CI / Tests (PHP 8.3) (pull_request) Successful in 4m4s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m5s
CI / Tests (PHP 8.5) (pull_request) Failing after 12m14s
CI / Build Plugin Zip (pull_request) Skipped
My last conclusion was wrong. The GHES warning is not evidence that the
runner has no cache server; it is actions/cache v3 disabling itself.
v3's isGhes() reads GITHUB_SERVER_URL, and anything that is not
github.com reads as GitHub Enterprise, so on Gitea it always trips and
the action returns before touching the cache. That explains the 0.2s
save perfectly well without any runner setting being off.

Gitea's runner 3.0.0 release notes say every runner starts its own cache
server and that the runner "patches action bundles at load time to open
the GHES gate and read the cache endpoint from ACTIONS_CACHE_URL", with
actions/cache supported unforked. Our runners report v3.0.0, so the
server should be there and the gate should be open — but the patching
evidently does not reach a v3 bundle.

So this reapplies the apt cache and moves every actions/cache to v4. If
the hypothesis holds the GHES warning disappears, the save step actually
takes time, and a second run restores the .debs. The Composer cache gets
the bump too, since it has been silently doing nothing for the same
reason.

Refs #178.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
2026-08-20 15:04:33 -03:00
thatguygriffandClaude Opus 5 dd31afcd06 Revert the apt cache experiment: the cache service is switched off
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m2s
CI / Tests (PHP 8.5) (pull_request) Successful in 3m55s
CI / Tests (PHP 8.2) (pull_request) Successful in 6m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 4m59s
CI / Coding Standards & Static Analysis (pull_request) Failing after 12m16s
CI / Build Plugin Zip (pull_request) Skipped
Measured on run 527 and it cannot work. Every actions/cache step on these
runners prints

  Cache action is only supported on GHES version >= 3.5 ... check with
  GHES admin if Actions cache service is enabled or not

and then no-ops. The save step finishes in 0.19-0.31s, which is not a
few hundred megabytes of .debs going anywhere. setup-php timings were
unchanged against the run 526 baseline, within the usual variance:
8.3 454s then 148s, 8.5 733s then 446s, both noise rather than signal.

The same warning appears on the Composer cache this workflow has carried
all along, including run 523 and earlier, so that step has never cached
anything either. Worth fixing, but in the runner config rather than here.

Leaving dead steps in the workflow is how the Composer cache went years
without anyone noticing it did nothing, so the experiment comes out until
act_runner has its cache service enabled. It is in the history when that
happens. Detail in #178.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
2026-08-20 14:59:39 -03:00
thatguygriffandClaude Opus 5 85c7a01939 Cache the .debs php-builder installs, to measure whether it helps
CI / Tests (PHP 8.2) (pull_request) Successful in 54s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m7s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m56s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m35s
CI / Tests (PHP 8.5) (pull_request) Successful in 12m53s
CI / Build Plugin Zip (pull_request) Skipped
Experiment for #178. On self-hosted runners setup-php installs PHP 8.3+
through php-builder, whose install.sh apt-installs ~70 -dev packages
before unpacking the build. The build tarball itself is only 19MB, so
that apt work is the whole cost, not the download.

Ubuntu's image drops the .debs after install, so every job fetches them
from the archive again. This keeps them and restores them through the
cache server, which lives in the cluster, so a WAN download becomes a
local one. The dependency list does not vary by PHP version, so a single
key serves 8.3 and 8.5 and the quality and build jobs alike.

Only the .debs are cached. /var/lib/apt/lists is deliberately left alone,
since a stale index is how apt starts 404ing mid-install, and reducing
flakiness is the entire point.

The Report restored .debs step is temporary instrumentation to show
whether the cache is actually being read. Baseline to beat, from run 526:
8.1 30s, 8.2 53s, 8.3 454s, quality 151s, 8.5 733s and a hard failure.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
2026-08-20 14:44:54 -03:00
thatguygriffandClaude Opus 5 bc046ec2a1 Fold PHPCS and PHPStan into one job and test PHP 8.5
CI / Tests (PHP 8.1) (pull_request) Successful in 57s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m17s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m26s
CI / Tests (PHP 8.3) (pull_request) Successful in 13m1s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.5) (pull_request) Failing after 12m16s
The two jobs were identical up to their final step, each paying for its own
Setup PHP. That step is the flaky one (#178), so running it twice to reach
two short commands was two chances for a run to fall over instead of one.
They are now steps in a single Coding Standards & Static Analysis job.

The one thing given up is that PHPCS failing now stops the job before
PHPStan reports, where before the two ran in parallel and both spoke. That
seemed a fair trade for halving the exposure, and the fix for a PHPCS
failure rarely depends on knowing PHPStan's verdict at the same time.

PHP 8.5 joins the test matrix. composer.json already allows it at >=8.1
and the suite passes on 8.5.9 locally: 915 tests, PHPStan and PHPCS clean,
check-platform-reqs satisfied. 8.4 is deliberately not added, only 8.5.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
2026-08-20 14:29:30 -03:00
thatguygriff b814ae34b4 Merge pull request 'Stop authenticating setup-php against the GitHub API' (#177) from ci/drop-github-token-from-setup-php into main
CI / Coding Standards (push) Successful in 3m7s
CI / Tests (PHP 8.1) (push) Successful in 2m11s
CI / Tests (PHP 8.2) (push) Successful in 1m6s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 6m42s
CI / Tests (PHP 8.3) (push) Successful in 12m55s
CI / Build Plugin Zip (push) Successful in 2m45s
Reviewed-on: #177
2026-08-20 17:25:22 +00:00
thatguygriffandClaude Opus 5 9071a3f70f Stop authenticating setup-php against the GitHub API
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m56s
CI / Coding Standards (pull_request) Successful in 7m5s
CI / Build Plugin Zip (pull_request) Skipped
CI / PHPStan (pull_request) Successful in 8m5s
The GitHub API rate limit was the wrong diagnosis, so this reverts the
1Password-backed token added in #175 along with the mirrored composite
action, leaving the workflows as they were.

Timing every Setup PHP step across runs 454-523 rules the rate limit out.
PHP 8.1 and 8.2 install in 26-41 seconds, 12 for 12, never once failing.
PHP 8.3 has never finished in under 143 seconds and ranges up to 1273,
with two outright failures. Those jobs share a fan-out, and so an egress
address and a rate limit bucket, with the 8.1 and 8.2 jobs that are never
touched. A throttle could not sort itself by PHP version that way.

Run 523, the first to carry the token, is the direct refutation: the
token resolved and verified, and Setup PHP still took 749 seconds on
kallone and 408 on eris. The 8.3 penalty also predates the whole story,
sitting at ~145 seconds back on 30 July.

What is left is a slow path specific to 8.3 on these arm64 runners, whose
long tail sometimes crosses the step timeout and reports the unhelpful
"Could not setup PHP 8.3". That is worth fixing on its own terms rather
than behind a token that was never in the path.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uw545F1vveNJKjzLxdi2ks
2026-08-20 14:22:52 -03:00
thatguygriff 170d7e6c21 Merge pull request 'Authenticate setup-php against the GitHub API' (#175) from ci/github-token-for-setup-php into main
CI / Tests (PHP 8.2) (push) Successful in 57s
CI / Tests (PHP 8.1) (push) Successful in 1m0s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 3m10s
CI / PHPStan (push) Successful in 7m19s
CI / Tests (PHP 8.3) (push) Successful in 12m53s
CI / Build Plugin Zip (push) Successful in 2m45s
Reviewed-on: #175
2026-08-20 16:56:38 +00:00
thatguygriff aa3dd13775 Merge pull request 'Bump version to 1.5.3' (#176) from release/bump-1.5.3 into main
CI / PHPStan (push) Successful in 3m6s
CI / Tests (PHP 8.1) (push) Successful in 1m9s
CI / Tests (PHP 8.2) (push) Successful in 1m10s
CI / Tests (PHP 8.3) (push) Successful in 7m52s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Failing after 12m16s
CI / Build Plugin Zip (push) Skipped
Reviewed-on: #176
2026-08-20 16:37:58 +00:00
Release Bot 164c8ebf97 Bump version to 1.5.3 and open changelog section 2026-08-20 16:28:20 +00:00
thatguygriffandClaude Opus 5 1291af0b72 Authenticate setup-php against the GitHub API
CI / PHPStan (pull_request) Successful in 6m52s
CI / Tests (PHP 8.1) (pull_request) Successful in 6m0s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m11s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m55s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 7m6s
CI / Build Plugin Zip (pull_request) Skipped
setup-php resolves its tools through the GitHub API, unauthenticated at 60
requests an hour per source address. A CI fan-out across the fleet exhausts
that bucket, and the step then retries for several minutes before reporting
only "Could not setup PHP 8.3". It reads as a hang rather than a throttle,
and it took out both a main CI run and a release build.

Each cluster has its own egress address and so its own bucket, which is why
the same job passed on one runner and failed on another in the same minute.

The token comes from 1Password through the Connect instance in whichever
cluster picked up the job, matching the pattern in thatguygriff/infra. That
repository's composite action is not reachable from here, so it is mirrored
locally. It stays a step output rather than being exported to the job
environment, to keep it away from the package scripts composer install runs.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0133tYSQoZhoKebKZV8o2GPs
2026-08-20 13:27:22 -03:00
thatguygriff 78083fc96c Merge pull request 'Let a studio choose its default payment method, and disconnect Stripe again' (#174) from feature/173-default-payment-method into main
CI / PHPStan (push) Successful in 8m6s
CI / Tests (PHP 8.1) (push) Successful in 1m6s
CI / Tests (PHP 8.2) (push) Successful in 54s
CI / Tests (PHP 8.3) (push) Successful in 6m48s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 5m55s
CI / Build Plugin Zip (push) Successful in 2m51s
Release / Build and Publish Release (push) Successful in 16m35s
Release / Open next-version bump PR (push) Successful in 4s
Reviewed-on: #174
2026-08-20 15:53:02 +00:00
thatguygriffandClaude Opus 5 c077a653fb Let a studio pick its default payment method and disconnect Stripe
CI / No Debug Code (pull_request) Successful in 5s
CI / Tests (PHP 8.2) (pull_request) Successful in 54s
CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / PHPStan (pull_request) Successful in 3m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 5m31s
CI / Build Plugin Zip (pull_request) Skipped
Stripe configuration was a one-way door. Keys could be entered but never
removed, and entering them moved every student onto card billing at once,
so there was no way to have Stripe live and satisfy yourself that card
payments worked before committing the studio to them.

Two settings-page changes open both directions:

Default payment method (`us_default_payment_method`) is now an explicit
choice between card and e-transfer for students with no per-student
override, rather than something inferred from whether keys exist. Card
remains the default, so a site that adds keys and changes nothing else
behaves as before. BillingMethodResolver still degrades a card default to
e-transfer while Stripe is unconfigured — there is nothing to charge a
card with — and `comp` is deliberately not offerable studio-wide, since
it would silently stop billing everybody; an unrecognised stored value
reads back as card. Holding the default on e-transfer with Stripe live is
the staged-rollout path: move individual students to card on their detail
page, watch real charges land, then flip the studio over.

Clear Stripe configuration deletes the publishable key, secret key and
webhook signing secret and returns the mode to test, so a re-configuration
later cannot inherit live. Currency, HST, e-transfer and registration
settings are untouched, as are recorded payments. The button only appears
when some Stripe value is stored, and reuses the page's existing nonce and
`manage_billing` check.

`composer test` (915), `composer lint` and `composer cs` all pass. Options
only — no schema change, so no version bump.

Closes #173

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WyktWmwNRgMYuwe5eBuPZm
2026-08-20 12:43:29 -03:00
thatguygriff f9e222be29 Merge pull request 'Bump version to 1.5.2' (#172) from release/bump-1.5.2 into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 46s
CI / Tests (PHP 8.2) (push) Successful in 1m4s
CI / Tests (PHP 8.3) (push) Successful in 2m43s
CI / Coding Standards (push) Successful in 3m40s
CI / PHPStan (push) Successful in 3m43s
CI / Build Plugin Zip (push) Successful in 2m51s
Reviewed-on: #172
2026-08-11 22:06:41 +00:00
Release Bot b950e35e5a Bump version to 1.5.2 and open changelog section 2026-08-11 22:06:18 +00:00
thatguygriff b220de48c5 Merge pull request 'Let a guardian enrol every child in the same group class' (#171) from fix/group-multi-student-enrolment into main
CI / No Debug Code (push) Successful in 4s
CI / Tests (PHP 8.1) (push) Successful in 1m4s
CI / Tests (PHP 8.2) (push) Successful in 1m10s
CI / Coding Standards (push) Successful in 3m35s
CI / PHPStan (push) Successful in 3m52s
CI / Tests (PHP 8.3) (push) Successful in 21m42s
CI / Build Plugin Zip (push) Successful in 14m57s
Release / Build and Publish Release (push) Successful in 3m11s
Release / Open next-version bump PR (push) Successful in 5s
Reviewed-on: #171
2026-08-11 20:44:29 +00:00
thatguygriffandClaude Opus 5 8017dbb9ff Let a guardian enrol every child in the same group class
CI / PHPStan (pull_request) Successful in 2m55s
CI / No Debug Code (pull_request) Successful in 26s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m5s
CI / Coding Standards (pull_request) Successful in 3m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 6m9s
CI / Build Plugin Zip (pull_request) Skipped
The group-class page matched enrolments to the account instead of to the
student: the lookup it built from GET /enrollments was keyed by offering id
alone, so the first household enrolment marked the class as "yours" and took
the Enrol button away from everyone else on the account. A parent could enrol
one child and was then offered nothing but Withdraw.

The server was never the constraint — hasActiveEnrollment() checks the
(offering, student) pair and GET /enrollments deliberately returns the whole
household — so the fix is to stop discarding student_id on the way in. Active
enrolments are now grouped per class as a list, each student gets their own
"… is enrolled in this class." line and their own named Withdraw button, and
the Enrol button stays (as "Enrol another student") while anyone the account
may enrol is still out.

The enrolment form offers only the students not yet enrolled. When exactly one
of them is left the picker collapses, and that case needed care: an omitted
student_id reads as "enrol the account holder" server-side, so a hidden field
carries the id rather than posting nothing and signing up the parent instead of
the last child.

Closes #170

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jy9UPhmpLUAfimsyecHN2Z
2026-08-11 17:36:01 -03:00
thatguygriff c73b10d779 Merge pull request 'Bump version to 1.5.1' (#169) from release/bump-1.5.1 into main
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.2) (push) Successful in 55s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m52s
CI / Coding Standards (push) Successful in 2m57s
CI / Tests (PHP 8.3) (push) Successful in 2m47s
CI / Build Plugin Zip (push) Successful in 2m51s
Reviewed-on: #169
2026-07-30 19:48:12 +00:00
Release Bot 7ea8d653ee Bump version to 1.5.1 and open changelog section 2026-07-30 19:46:05 +00:00
thatguygriff 1e4e21e8d3 Merge pull request 'Show a recurring lesson's policy acceptances and intake answers on every week of it' (#168) from fix/167-series-policy-acceptances into main
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / Tests (PHP 8.1) (push) Successful in 54s
CI / No Debug Code (push) Successful in 1s
CI / PHPStan (push) Successful in 2m54s
CI / Coding Standards (push) Successful in 2m58s
CI / Tests (PHP 8.3) (push) Successful in 2m43s
Release / Build and Publish Release (push) Successful in 2m58s
Release / Open next-version bump PR (push) Successful in 4s
CI / Build Plugin Zip (push) Successful in 2m46s
Reviewed-on: #168
2026-07-30 19:41:03 +00:00
thatguygriffandClaude Opus 5 df3462a8b3 Show a series' policy acceptances on every occurrence
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m54s
CI / Coding Standards (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
A weekly booking reserves a series of lessons, but the student answers
the intake and ticks the policy boxes once — so BookingEndpoint records
both against the anchor lesson alone. The admin detail view looked them
up by whichever lesson id was being viewed, so every occurrence after
the first showed no answers and no acceptances at all.

LessonDetail now takes the Lesson rather than a bare id and resolves the
registration to `series_id ?? id`, so each occurrence reads the anchor's
records. This is the same seam PaymentService already uses to find a
series lesson's payment on the anchor.

Nothing was ever missing from the database, so existing bookings read
correctly with no migration and no schema change.

Closes #167

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 16:35:26 -03:00
thatguygriff 748478f2f1 Merge pull request 'Let the account holder edit their own details on the profile page' (#166) from feature/165-editable-own-profile into main
CI / Tests (PHP 8.1) (push) Successful in 46s
CI / Tests (PHP 8.2) (push) Successful in 55s
CI / No Debug Code (push) Successful in 3s
CI / PHPStan (push) Successful in 2m57s
CI / Coding Standards (push) Successful in 3m3s
CI / Tests (PHP 8.3) (push) Successful in 2m48s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #166
2026-07-30 19:29:21 +00:00
thatguygriffandClaude Opus 5 f97b8a4576 Let the account holder edit their own profile details
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 55s
CI / PHPStan (pull_request) Successful in 2m57s
CI / Coding Standards (pull_request) Successful in 3m3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
The Profile block is headed "Your profile", but the one person on it you
could not change was yourself: your name, your birth year, and whether you
take lessons yourself were fixed at whatever signup recorded, and correcting
any of them meant asking a studio admin.

A "Your details" section now opens the page, saved through the same
nonce-checked template_redirect post/redirect/get path the child rows use:

- Your name, written to display_name and nickname together, for the reason
  updateChild() does — UserName reads the nickname first, and leaving it
  behind would put the account's email address back on every screen that
  names a person.
- "I take lessons myself", the positive of us_guardian_only. This makes good
  on the claim already in bookableStudents() and the feature doc that a
  guardian-only account can put itself right from the profile page.
- Your birth year, held to the same normaliseBirthYear() rule as every other
  student.

The email is shown but not editable: it is the account's user_login as well
as its address, so changing it stays a studio-side job.

The birth-year field deliberately carries no `required` attribute. It is
asked of a student only, and this page loads no JavaScript, so a
browser-enforced `required` would leave a guardian who books solely for
other people unable to submit the form at all; handleSelf() enforces it
against the checkbox instead. Unticking the box does not clear a stored
birth year — it says who books, not "forget what is on file".

Closes #165

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 15:08:05 -03:00
thatguygriff 325a86f247 Merge pull request 'Ask some registration questions of students only, and require them of students without requiring them of the account holder' (#164) from feature/student-scoped-registration-questions into main
CI / Tests (PHP 8.2) (push) Successful in 57s
CI / Build Plugin Zip (push) Successful in 2m49s
CI / Tests (PHP 8.1) (push) Successful in 58s
CI / No Debug Code (push) Successful in 3s
CI / Coding Standards (push) Successful in 2m50s
CI / PHPStan (push) Successful in 2m59s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
Reviewed-on: #164
2026-07-30 16:56:33 +00:00
thatguygriffandClaude Opus 5 434fe801ba Ask some registration questions of students only
CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / Tests (PHP 8.1) (pull_request) Successful in 58s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / PHPStan (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
Every account-signup question was asked of everybody who registered, on the
same terms: "school and grade" had to be put to an adult signing themselves
up, and a question a studio needed answered for each student could only be
made required by demanding it of everyone.

A question now carries an audience — everyone, or only the students someone
registers on behalf of — and its own required flag for each side, so optional
for you and required for every student you enrol is expressible. Both settings
are account-scope only: an offering asks its questions once, about the student
being booked, so there is no second audience to differ from, and an offering
question mirrors its single "required" into both columns.

Every caller reads askedOfSelf()/isRequiredForSelf()/isRequiredForChild()
rather than the raw flags, so a students-only question can neither block the
account holder nor have an answer filed against them by a crafted post. The
family screen, which only ever adds a student, is held to the students' rule.

is_required_child arrives from dbDelta defaulting to 0, which would quietly
stop every existing required question being required of the students a
guardian registers — the case it most likely existed for. A one-time backfill
copies is_required across, guarded by its own option so a question later made
optional for students stays that way.

Closes #163

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 13:51:52 -03:00
thatguygriff 84378e856b Merge pull request 'Bump version to 1.4.2' (#162) from release/bump-1.4.2 into main
CI / Tests (PHP 8.2) (push) Successful in 51s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m53s
CI / Coding Standards (push) Successful in 2m58s
CI / Build Plugin Zip (push) Successful in 2m45s
CI / Tests (PHP 8.1) (push) Successful in 45s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
Reviewed-on: #162
2026-07-30 15:55:06 +00:00
Release Bot a2cece750b Bump version to 1.4.2 and open changelog section 2026-07-30 15:34:07 +00:00
82 changed files with 5468 additions and 572 deletions
+60
View File
@@ -0,0 +1,60 @@
# CI image for unsupervised-scheduler, one tag per PHP version.
#
# Built and published by .gitea/workflows/ci-images.yml to
# git.unsupervised.ca/unsupervised/ci-php:<php-version>. CI and release jobs
# run inside it via `container:`, so nothing installs PHP at job time.
#
# Why: setup-php installs 8.3+ through apt/the ondrej PPA on these arm64
# runners — a ~145s floor against ~35s for 8.1/8.2, with a tail that has
# twice crossed into hard failure (#178). Pulling a ~120MB image from the
# registry in our own cluster replaces that entirely (#187).
ARG PHP_VERSION=8.3
FROM php:${PHP_VERSION}-cli-alpine
# bash and nodejs are not optional: act_runner executes JavaScript actions
# (actions/checkout, actions/cache, actions/upload-artifact) *inside* the job
# container, and shells `run:` steps through bash.
#
# coreutils, gawk, grep and sed replace the busybox applets with the GNU ones
# the workflow scripts are written against (`tac`, `grep --include`).
#
# jq, curl, git and zip/unzip are used by release.yml and bin/build-zip.sh.
RUN apk add --no-cache \
bash \
coreutils \
curl \
gawk \
git \
grep \
jq \
nodejs \
sed \
unzip \
zip \
icu-libs \
libzip \
&& apk add --no-cache --virtual .build-deps \
$PHPIZE_DEPS \
icu-dev \
libzip-dev \
&& docker-php-ext-install -j"$(nproc)" intl zip \
&& apk del --no-network .build-deps
# mbstring is compiled into the official php images; intl and zip are added
# above. That covers what phpunit, phpstan, phpcs and Composer need.
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Jobs run as root inside the container, and never answer prompts.
ENV COMPOSER_ALLOW_SUPERUSER=1 \
COMPOSER_NO_INTERACTION=1 \
COMPOSER_HOME=/composer
RUN mkdir -p "$COMPOSER_HOME" \
&& php -v \
&& php -m | grep -qx intl \
&& php -m | grep -qx mbstring \
&& composer --version
CMD ["/bin/bash"]
+94
View File
@@ -0,0 +1,94 @@
name: CI Images
# Publishes the per-PHP-version images that ci.yml and release.yml run inside
# (#187). Nothing else consumes them, so this workflow is the only place the
# registry path is written down.
#
# Triggers:
# - the Dockerfile or this workflow changing on main, so an edit ships;
# - the same paths on a pull request, which builds but does not push, so a
# broken Dockerfile is caught before it reaches main;
# - workflow_dispatch, to rebuild on demand;
# - weekly, so PHP patch releases and Alpine security updates land without
# anyone remembering to ask.
on:
push:
branches:
- main
paths:
- '.gitea/ci/Dockerfile'
- '.gitea/workflows/ci-images.yml'
pull_request:
paths:
- '.gitea/ci/Dockerfile'
- '.gitea/workflows/ci-images.yml'
schedule:
- cron: '17 4 * * 1'
workflow_dispatch:
env:
# The instance ROOT_URL host — the container registry lives on the same host.
REGISTRY: git.unsupervised.ca
IMAGE: unsupervised/ci-php
jobs:
build:
name: Build CI image (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
# One version failing should not hide whether the others built.
fail-fast: false
matrix:
# Keep in step with the test matrix in ci.yml.
php:
- '8.1'
- '8.2'
- '8.3'
- '8.5'
steps:
- uses: actions/checkout@v4
# The images are built natively, so they carry the runner's
# architecture only. Fine while every runner is arm64; if a runner of a
# different architecture ever joins the pool it will overwrite these
# tags with its own arch and the others will fail to pull.
- name: Check Docker is available
run: |
if ! docker info >/dev/null 2>&1; then
echo "No usable Docker daemon in the job container." >&2
echo "act_runner needs container.docker_host set (or left empty to autodetect)." >&2
exit 1
fi
docker version --format 'client {{.Client.Version}} / server {{.Server.Version}} / arch {{.Server.Arch}}'
# secrets.GITHUB_TOKEN is the Actions task token and can write packages
# for the repository owner. REGISTRY_TOKEN is an escape hatch: set it to
# a PAT with package:write if the task token is ever refused.
- name: Log in to the container registry
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}" \
| docker login "${REGISTRY}" -u "${{ vars.REGISTRY_USER || github.actor }}" --password-stdin
- name: Build
run: |
docker build \
--pull \
--build-arg "PHP_VERSION=${{ matrix.php }}" \
--tag "${REGISTRY}/${IMAGE}:${{ matrix.php }}" \
--file .gitea/ci/Dockerfile \
.gitea/ci
# Pull requests build only — the tags on the registry are what the other
# workflows run inside, so only main and a manual dispatch move them.
- name: Push
if: github.event_name != 'pull_request'
run: |
image="${REGISTRY}/${IMAGE}:${{ matrix.php }}"
docker push "${image}"
echo "Published ${image}"
- name: Log out
if: always() && github.event_name != 'pull_request'
run: docker logout "${REGISTRY}" || true
+92 -27
View File
@@ -8,12 +8,44 @@ on:
pull_request:
jobs:
lint:
name: Coding Standards
# PHPCS and PHPStan share a job so the two of them draw once on Setup PHP
# rather than twice. That step is slow and intermittently fails on 8.3
# (see #178), so every job that can be folded into another is one less
# chance for a run to fall over. They run as separate steps, and PHPCS
# failing stops the job before PHPStan reports.
quality:
name: Coding Standards & Static Analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# setup-php installs PHP 8.3+ through php-builder, which apt-installs ~70
# -dev packages before unpacking the build (#178). Ubuntu's image deletes
# the .debs after install, so every job re-downloads them. Keeping them
# and restoring them from the cache server, which lives in the cluster,
# turns a WAN download into a local one.
#
# Keyed per PHP version because the install path differs by version and
# the package sets are not interchangeable: 8.3+ pulls the ~70 -dev
# packages through php-builder, while 8.1 and 8.2 come from the ondrej
# PPA as a handful of runtime packages. Sharing one key across both lets
# whichever job finishes first decide what the others restore, and 8.1 is
# always first.
#
# Only the .debs are cached, never /var/lib/apt/lists — a stale index is
# how you get 404s mid-install.
- name: Keep downloaded .debs
run: |
sudo rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
| sudo tee /etc/apt/apt.conf.d/99keep-downloaded-packages >/dev/null
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives/*.deb
key: apt-php8.3-${{ runner.arch }}-v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
@@ -21,7 +53,7 @@ jobs:
tools: composer:v2
- name: Cache Composer packages
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.json') }}
@@ -32,28 +64,6 @@ jobs:
- name: Run PHPCS
run: composer cs
static-analysis:
name: PHPStan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
- name: Cache Composer packages
uses: actions/cache@v3
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.json') }}
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Run PHPStan
run: composer lint
@@ -67,9 +77,37 @@ jobs:
- '8.1'
- '8.2'
- '8.3'
- '8.5'
steps:
- uses: actions/checkout@v4
# setup-php installs PHP 8.3+ through php-builder, which apt-installs ~70
# -dev packages before unpacking the build (#178). Ubuntu's image deletes
# the .debs after install, so every job re-downloads them. Keeping them
# and restoring them from the cache server, which lives in the cluster,
# turns a WAN download into a local one.
#
# Keyed per PHP version because the install path differs by version and
# the package sets are not interchangeable: 8.3+ pulls the ~70 -dev
# packages through php-builder, while 8.1 and 8.2 come from the ondrej
# PPA as a handful of runtime packages. Sharing one key across both lets
# whichever job finishes first decide what the others restore, and 8.1 is
# always first.
#
# Only the .debs are cached, never /var/lib/apt/lists — a stale index is
# how you get 404s mid-install.
- name: Keep downloaded .debs
run: |
sudo rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
| sudo tee /etc/apt/apt.conf.d/99keep-downloaded-packages >/dev/null
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives/*.deb
key: apt-php${{ matrix.php }}-${{ runner.arch }}-v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
@@ -79,7 +117,7 @@ jobs:
tools: composer:v2
- name: Cache Composer packages
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: ${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
@@ -108,11 +146,38 @@ jobs:
runs-on: ubuntu-latest
# Only build a shippable artifact once changes land on main, and only
# after the quality gates pass.
needs: [lint, static-analysis, test, no-debug]
needs: [quality, test, no-debug]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
# setup-php installs PHP 8.3+ through php-builder, which apt-installs ~70
# -dev packages before unpacking the build (#178). Ubuntu's image deletes
# the .debs after install, so every job re-downloads them. Keeping them
# and restoring them from the cache server, which lives in the cluster,
# turns a WAN download into a local one.
#
# Keyed per PHP version because the install path differs by version and
# the package sets are not interchangeable: 8.3+ pulls the ~70 -dev
# packages through php-builder, while 8.1 and 8.2 come from the ondrej
# PPA as a handful of runtime packages. Sharing one key across both lets
# whichever job finishes first decide what the others restore, and 8.1 is
# always first.
#
# Only the .debs are cached, never /var/lib/apt/lists — a stale index is
# how you get 404s mid-install.
- name: Keep downloaded .debs
run: |
sudo rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
| sudo tee /etc/apt/apt.conf.d/99keep-downloaded-packages >/dev/null
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives/*.deb
key: apt-php8.3-${{ runner.arch }}-v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
+36
View File
@@ -11,6 +11,42 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work.
## [1.5.5]
## [1.5.4]
### Fixed
- **Adding students to a group class now checks that each one is actually a student.** **Add students directly** and **Make available** acted on whatever ids the form posted without confirming they named students at all, so a stale page — or a tampered submission — could put an instructor, an administrator, or an account that had since been deleted onto a class roster, raising a real payment against them. Both controls now skip anything that is not a student, and the "%d student(s) added" count tells you how many actually went through. Children and students awaiting approval are unaffected: they are students, and adding them is what these controls are for.
- **A refused booking no longer empties the Book a lesson for a student form.** Whatever the reason it came back — the time taken while you were typing, a weekly reservation asked for on a time that does not repeat — the panel reopens with the student, time, lesson type, both ticks and your note exactly as you left them, so a correction is one field, not five. A booking that goes through still leaves an empty form behind for the next one.
- **Booking a lesson for a child, or for a student you have not approved yet, no longer fails with "Choose a student to book for."** The **Book a lesson for a student** panel offered every student it could see, but then refused a good half of them: a parent's child and a self-signup still awaiting approval both appeared in the list, and both were rejected on submit — with an error that read as though no student had been chosen, and which cleared the form. Neither of those accounts is allowed to book *in their own name* (a child's is never signed in to at all, and an unapproved signup waits for you), and the panel was mistakenly applying that same restriction to the studio booking on their behalf, which is precisely the case it was built for. Anyone the panel offers can now be booked for.
## [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
- **You can now choose which payment method the studio bills by, instead of it following your Stripe keys.** Saving Stripe keys used to move every student onto credit-card billing the moment they were entered — there was no way to have Stripe live and still bill by e-transfer while you satisfied yourself that card payments worked. **Studio Settings → Billing → Default payment method** now makes that an explicit choice between **Credit card** and **E-transfer**. Leaving it on E-transfer with Stripe configured lets you switch one student at a time to Credit card on their student detail page and watch their bookings charge for real; when you are satisfied, changing this one setting moves everyone over. Credit card remains the default, so a studio that adds keys and changes nothing else behaves exactly as before, and it still falls back to e-transfer until keys are saved — a card cannot be charged without them.
- **Stripe can now be disconnected from Studio Settings.** Keys could be replaced but never removed, so a studio that set Stripe up to try it had no way back to e-transfer short of editing the database. **Clear Stripe configuration**, at the foot of the settings page whenever any Stripe value is stored, forgets the publishable key, the secret key and the webhook signing secret, and returns the mode to Test — billing falls back to e-transfer until keys are entered again. Payments already recorded are untouched, as are your currency, HST, e-transfer and registration settings. If you are disconnecting for good, delete the webhook endpoint in the Stripe Dashboard too, or it will keep sending events this site can no longer verify.
## [1.5.1]
### Fixed
- **A parent can now enrol more than one child in the same group class.** Enrolling the first student worked, and then the class card switched to "You are enrolled in this class." with a **Withdraw** button — for the whole account. There was no way to sign up a second child short of withdrawing the first, even though nothing was ever actually full or forbidden: the class page was matching enrolments to the account rather than to the student, so one child's seat spoke for everybody. Each enrolled student now gets their own line on the card, named — "Ada is enrolled in this class." — with their own Withdraw button, and the Enrol button stays put, reading **Enrol another student**, until everyone on the account is in. The form's "Who is this for?" list offers only the students not yet enrolled, so the class cannot be double-booked for the same child by accident. Enrolments already recorded are unaffected; the seats were always separate on the studio's side, and this is the page catching up with that.
## [1.5.0]
### Added
- **A registration question can now be asked of students only, and can be required of a student without being required of the account holder.** Every account-signup question was asked of everybody who registered, on the same terms — so "School and grade" had to be put to the adult signing themselves up, and a question a studio needed answered for a child could only be made required by demanding it of everyone. Each question now says who it is asked of — everyone, or only the students you register on behalf of — and carries its own **Required** setting for each: optional for you, required for every student you enrol, is now a thing a studio can ask for. Existing questions are untouched: they stay asked of everyone, and one that was required stays required of everyone.
- **You can now edit your own details on the profile page**, not just your students'. The page is called **Your profile**, and until now the one person on it you could not change was yourself: a mistyped name at signup, or a name that had since changed, meant asking the studio to fix it. **Your details** now sits at the top of the page with your name, your birth year, and whether you take lessons yourself. Your email address is shown but not editable — it is also how you sign in, so changing it stays a studio-side job.
- **"I take lessons myself" can be corrected after signup.** Signup asks whether you are registering just yourself, only on behalf of students, or both, and the answer decides whether you are offered as a student when booking. Choosing wrongly — or taking up lessons later alongside the children you book for — used to leave you asking the studio to change it. Ticking the box makes you bookable again and asks for your birth year like any other student; unticking it takes you back off the list without discarding the birth year you already gave, so ticking it back on costs you nothing.
### Fixed
- **A recurring lesson now shows the policies the student accepted on every week of it, not just the first.** Booking a weekly lesson reserves a series of them, and the student answers the intake questions and agrees to the studio's policies once, for the whole reservation. Opening any week after the first showed no answers and no policies accepted — as though nothing had been agreed to. Nothing was ever missing: the agreement was recorded against the first lesson of the series and every other week was looking for one of its own. Each week of a series now shows the intake answers and the full acceptance record — policy, version, when it was accepted, and from where — captured when the reservation was booked. Existing bookings read correctly straight away; there is nothing to re-collect from anyone.
## [1.4.1]
### Added
+26
View File
@@ -521,6 +521,32 @@
}
/* The guardian's manage-children screen ([us_family]). */
/*
* The account holder's own details, set off from the students below so the two
* halves of the page do not read as one long form.
*/
.us-family-self {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #eee;
}
.us-family-self-email span {
font-weight: 600;
}
/*
* Guidance under a control, not a label: it explains when a field matters
* rather than naming it, so it is sized down and reads after the input.
*/
.us-field-hint {
display: block;
margin-top: 4px;
font-size: 0.9em;
opacity: 0.75;
}
.us-family-list {
margin: 0 0 20px;
padding: 0;
+115 -39
View File
@@ -137,7 +137,78 @@
return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline;
}
function renderClasses(offerings, enrolledMap) {
// Active enrolments grouped by class. A household can hold several in the
// same class — one per student — so the value is a list, never a single id.
function activeByOffering(enrollments) {
const map = new Map();
enrollments
.filter((e) => e.status === 'active')
.forEach((e) => {
const key = Number(e.offering_id);
const held = map.get(key) || [];
held.push({ id: e.id, studentId: Number(e.student_id) });
map.set(key, held);
});
return map;
}
// Who on this account could still be enrolled in a class: everyone the
// account may enrol, minus those already holding an active enrolment in it.
// The per-student check is the point — the account used to be treated as a
// single enrollee, so enrolling one child hid the Enrol button from the rest
// of the household even though the server would have taken them happily.
function availableStudents(offeringId, enrolled) {
const held = enrolled.get(Number(offeringId)) || [];
// Degraded case: an unparseable student list leaves no id to compare
// against, so any existing enrolment is read as covering the account.
if (!students.length) return held.length ? [] : [{ id: 0, name: '', is_self: true }];
const taken = new Set(held.map((e) => e.studentId));
return students.filter((s) => !taken.has(Number(s.id)));
}
// The enrolled student's name, or '' when there is nobody to tell them apart
// from: an account with a single student reads better in the second person.
function studentName(studentId) {
if (students.length < 2) return '';
const s = students.find((st) => Number(st.id) === Number(studentId));
return s && !s.is_self ? s.name : '';
}
function enrolledRow(o, e) {
const name = studentName(e.studentId);
return `
<p class="us-enrolled"><strong>${name ? `${escHtml(name)} is` : 'You are'} enrolled in this class.</strong></p>
${isWithdrawalOpen(o)
? `<button data-enrollment-id="${e.id}" data-student="${escHtml(name)}" class="us-withdraw-btn">Withdraw${name ? ` ${escHtml(name)}` : ''}</button>`
: `<p class="us-withdraw-closed">Withdrawal${name ? ` for ${escHtml(name)}` : ''} has closed — contact the studio to withdraw.</p>`}`;
}
function classCard(o, enrolled) {
const held = enrolled.get(Number(o.id)) || [];
const available = availableStudents(o.id, enrolled);
const canEnrol = available.length > 0 && isEnrollmentOpen(o);
return `
<div class="us-class">
<h3>${escHtml(o.title)}</h3>
${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''}
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p class="us-class-price">${escHtml(window.usPricing.priceLabel(o))}</p>
${canEnrol && enrolmentDeadline(o)
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
: ''}
${held.map((e) => enrolledRow(o, e)).join('')}
${canEnrol
? `<button data-offering-id="${o.id}" class="us-enrol-btn">${held.length ? 'Enrol another student' : 'Enrol'}</button>`
: (available.length ? '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>' : '')}
</div>`;
}
function renderClasses(offerings, enrolled) {
let groups = offerings.filter((o) => o.kind === 'group_class');
if (singleOfferingId) {
groups = groups.filter((o) => Number(o.id) === singleOfferingId);
@@ -149,44 +220,29 @@
return;
}
list.innerHTML = groups.map((o) => `
<div class="us-class">
<h3>${escHtml(o.title)}</h3>
${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''}
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p class="us-class-price">${escHtml(window.usPricing.priceLabel(o))}</p>
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
: ''}
${enrolledMap.has(Number(o.id))
? `<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>
${isWithdrawalOpen(o)
? `<button data-enrollment-id="${enrolledMap.get(Number(o.id))}" class="us-withdraw-btn">Withdraw</button>`
: '<p class="us-withdraw-closed">Withdrawal has closed — contact the studio to withdraw.</p>'}`
: (isEnrollmentOpen(o)
? `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`
: '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>')}
</div>
`).join('');
list.innerHTML = groups.map((o) => classCard(o, enrolled)).join('');
list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
btn.addEventListener('click', () => {
hideConfirmation();
openEnrolment(offering);
openEnrolment(offering, availableStudents(offering.id, enrolled));
});
});
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId));
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId, btn.dataset.student || ''));
});
}
function withdraw(enrollmentId) {
function withdraw(enrollmentId, studentName) {
clearError();
if (!window.confirm('Withdraw from this class? Your seat is released and any pending payment is cancelled.')) {
// Named, because a household can hold more than one enrolment in the
// same class and "this class" alone would not say whose seat is going.
const prompt = studentName
? `Withdraw ${studentName} from this class? Their seat is released and any pending payment is cancelled.`
: 'Withdraw from this class? Your seat is released and any pending payment is cancelled.';
if (!window.confirm(prompt)) {
return;
}
apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' })
@@ -194,22 +250,45 @@
.catch((err) => showError(err.message));
}
function openEnrolment(offering) {
function openEnrolment(offering, available) {
clearError();
Promise.all([
apiFetch(`offerings/${offering.id}/questions`),
apiFetch('policies?scope=booking'),
])
.then(([questions, policies]) => renderEnrolment(offering, questions, policies))
.then(([questions, policies]) => renderEnrolment(offering, questions, policies, available))
.catch((err) => showError(err.message));
}
function renderEnrolment(offering, questions, policies) {
/**
* The "who is this for?" control for one class, offering only the students
* who are not already enrolled in it.
*
* When exactly one is left there is nothing to choose, but the id still has
* to reach the server: an omitted picker posts no student_id, which the
* server reads as "enrol the account holder" — and would enrol the parent
* instead of the one child still to be signed up.
*/
function studentFieldHtml(available) {
if (available.length > 1) {
return window.usGuardian.selectorHtml(available, 'us-enrol-student');
}
const only = available[0];
if (!only) return '';
return `<input type="hidden" id="us-enrol-student" value="${Number(only.id)}">
${students.length > 1
? `<p class="us-student-picker">For ${only.is_self ? 'yourself' : escHtml(only.name)}.</p>`
: ''}`;
}
function renderEnrolment(offering, questions, policies, available) {
list.innerHTML = `
<div class="us-register">
<h3>${escHtml(offering.title)}</h3>
<form id="us-enrol-form">
${window.usGuardian.selectorHtml(students, 'us-enrol-student')}
${studentFieldHtml(available)}
${questions.map(questionField).join('')}
${policies.map(policyField).join('')}
${window.usPricing.summaryHtml(offering)}
@@ -306,20 +385,17 @@
function loadClasses() {
clearError();
hideConfirmation();
// The student's own enrolments are fetched alongside the catalog so a
// class they already have an active enrolment in shows its status
// The household's enrolments are fetched alongside the catalog so a
// class a student already has an active enrolment in shows their status
// instead of offering to enrol them again (the API would reject the
// duplicate anyway). A cancelled enrolment does not block re-enrolling.
// duplicate anyway). Each student is tracked separately: one child being
// enrolled says nothing about their siblings, who can still be signed up
// for the same class. A cancelled enrolment does not block re-enrolling.
return Promise.all([
apiFetch('offerings?kind=group_class'),
apiFetch('enrollments'),
])
.then(([offerings, enrollments]) => renderClasses(
offerings,
new Map(enrollments
.filter((e) => e.status === 'active')
.map((e) => [Number(e.offering_id), e.id]))
))
.then(([offerings, enrollments]) => renderClasses(offerings, activeByOffering(enrollments)))
.catch((err) => showError(err.message));
}
+70
View File
@@ -0,0 +1,70 @@
# CI images
CI and release jobs do not install PHP. They run inside prebuilt images
published to the Gitea container registry:
```
git.unsupervised.ca/unsupervised/ci-php:8.1
git.unsupervised.ca/unsupervised/ci-php:8.2
git.unsupervised.ca/unsupervised/ci-php:8.3
git.unsupervised.ca/unsupervised/ci-php:8.5
```
The `Unsupervised` org is public, so the packages pull anonymously — jobs need
no registry credentials to use them.
## Why
`shivammathur/setup-php` installs PHP 8.3+ from apt/the ondrej PPA on these
arm64 runners. That was a ~145s floor against ~35s for 8.1 and 8.2, with a
tail that twice ran past the step timeout and failed the run outright
(#178). Caching the `.deb`s helped, but the apt step itself remained, and PHP
8.5 has the same shape of problem. Pulling a ~120MB image from a registry
inside the cluster replaces the whole thing (#187).
## What is in the image
`.gitea/ci/Dockerfile` builds on `php:<version>-cli-alpine` and adds:
- **`bash` and `nodejs`** — act_runner runs JavaScript actions
(`actions/checkout`, `actions/cache`, `actions/upload-artifact`) *inside*
the job container and shells `run:` steps through bash. Without these, the
first step of every job fails.
- **`coreutils`, `gawk`, `grep`, `sed`** — GNU versions, because the workflow
scripts use `tac` and `grep --include`, which busybox does not provide.
- **`curl`, `jq`, `git`, `zip`, `unzip`** — used by `release.yml` and
`bin/build-zip.sh`.
- **`intl` and `zip` PHP extensions**, plus Composer 2. `mbstring` is already
compiled into the official images.
## Publishing
`.gitea/workflows/ci-images.yml` builds and pushes them. It runs when the
Dockerfile changes on `main`, weekly (so PHP patch releases and Alpine
security updates land on their own), and on `workflow_dispatch`. On a pull
request it builds without pushing, so a broken Dockerfile is caught before it
reaches `main`.
## Adding or dropping a PHP version
1. Add the version to the `php` matrix in `.gitea/workflows/ci-images.yml`.
2. Merge to `main`, or dispatch the workflow, and wait for the tag to appear.
3. Add the version to the `test` matrix in `.gitea/workflows/ci.yml`.
Steps 2 and 3 cannot be one commit: a job cannot run in an image that has not
been published yet.
## Architecture
The images are built natively on whichever runner picks the job, so they carry
that runner's architecture only. Every runner in the pool is arm64 today. If
one of a different architecture ever joins, it will overwrite these tags with
its own arch and the rest will fail to pull — at which point the build needs
`docker buildx` and a multi-arch manifest.
## If a push is refused
The build authenticates with `secrets.GITHUB_TOKEN`, the Actions task token.
If the registry ever refuses it, create a personal access token with
`package:write`, store it as the `REGISTRY_TOKEN` secret, and optionally set
the `REGISTRY_USER` variable — the workflow prefers both when present.
+65 -7
View File
@@ -15,6 +15,7 @@ A group class can be marked **invite-only** (`us_offerings.access_mode = invite_
| `instructor_id`| BIGINT UNSIGNED | WordPress user ID (denormalised from the offering) |
| `status` | VARCHAR(20) | `active` / `cancelled` / `completed` |
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
| `enrolled_by` | BIGINT UNSIGNED | Staff member who added the student from wp-admin; 0 when the student (or their guardian) enrolled themselves |
| `enrolled_at` | DATETIME | Insertion time |
## Class Dates, Time, and Instructor
@@ -81,11 +82,20 @@ student detail page. Only *upcoming* sessions are added there — the
term's worth of past dates would bury the lessons under "Past lessons".
## Enrolment Flow
The class list is loaded together with the student's own enrolments
(`GET /enrollments`); a class the student already has an `active` enrolment in
shows "You are enrolled in this class." instead of the Enrol button (the
server would reject the duplicate with `409 already_enrolled` regardless — a
cancelled enrolment does not block re-enrolling).
The class list is loaded together with the household's enrolments
(`GET /enrollments`), and the two are matched up **per student**, not per account.
Each active enrolment in a class adds its own line to the card — "Ada is enrolled
in this class." — with its own **Withdraw** button, and the Enrol button stays
(reading "Enrol another student") for as long as anyone the account may enrol is
still out of the class. The enrolment form then offers only those students; when
exactly one is left the picker collapses to a hidden field carrying that student's
id, because an omitted `student_id` reads as "enrol the account holder" and would
sign up the parent instead of the last child. Only when the whole household is
enrolled does the Enrol button disappear.
The per-student matching mirrors the server, which rejects a duplicate with
`409 already_enrolled` for that `(offering, student)` pair alone — a sibling is
never a duplicate, and a cancelled enrolment does not block re-enrolling.
1. Student opens a group class from the offering catalog. Each class card shows its price with the **cadence** it is billed on — `120.00 CAD up front`, `40.00 CAD monthly`, and so on.
2. Student answers the offering's questions (`GET /offerings/{id}/questions`).
@@ -114,7 +124,8 @@ closed. Past the deadline the details page labels these as late enrolments. See
## Withdrawal Flow
A student may withdraw themselves from a class they are enrolled in through the same
group-class page: an active enrolment shows a **Withdraw** button.
group-class page: an active enrolment shows a **Withdraw** button. A guardian sees one
per enrolled child, labelled with the child's name, so the right seat is the one released.
`POST /enrollments/{id}/withdraw` marks the enrolment `cancelled` (freeing its
capacity seat) and voids any still-pending payment. It **never issues an account
credit** — a timely withdrawal is a clean exit, not a refund (credits are reserved
@@ -170,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.
@@ -179,6 +192,17 @@ controls beneath it:
a **pending** invite, the grant is attached to that invite and **no second link is
sent**. An address that already has an account is treated as **Make available** instead.
Both student-picking controls vet every posted id with `Auth\RoleManager::isStudent()`
before acting on it — the same predicate the picker is built from, and the same one
`Booking\AdminBooking` guards a staff booking with. A posted id naming an instructor,
an administrator, or an account deleted since the page was drawn is skipped rather
than enrolled, so nothing can put a non-student on a roster or raise a payment
against one. Being a student is a matter of the **role**, not the `book_lesson`
capability, so a guardian's child and a signup still awaiting approval are both
fully enrollable — neither may enrol *themselves*, which is exactly what the studio
adding them is for. The reported count is what was actually added, so a skipped id
shows up as a smaller number.
When an email-invited person completes registration, `RegistrationPage` links their new
account to the grant (`GroupAccessRepository::linkStudentByEmail`), so the invite-only
class becomes enrollable for them — they choose whether to enrol.
@@ -220,12 +244,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`)
+107 -4
View File
@@ -17,6 +17,7 @@ Students register for a private lesson by choosing an offering, picking a time (
| `status` | VARCHAR(20) | `pending` / `confirmed` / `cancelled` |
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
| `notes` | TEXT | Optional student notes |
| `booked_by` | BIGINT UNSIGNED | Staff member who booked it for the student; 0 when the student (or their guardian) booked it themselves |
| `created_at` | DATETIME | Insertion time |
## Registration Flow
@@ -82,6 +83,103 @@ 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
and every field as it was submitted, so only the mistake needs correcting. A
booking that succeeds clears the form, so the next one does not inherit it.
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 four 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.
4. **It can book for a student who cannot book at all.** The guard is
`Auth\RoleManager::isStudent()` — the student *role*, not the `book_lesson`
capability — so it covers a guardian's child and a self-signup still awaiting
approval alike, and is shared with the group-class **Add students directly**
and **Make available** controls so the two paths cannot drift. Both hold the role;
both have `book_lesson` withheld (`Guardian\ChildLoginGate`,
`Auth\RegistrationLoginGate`) so that neither can book in their own name.
That restriction is on them, not on the studio acting for them — and for a
child, whose account is never signed in to, it is the only route to a lesson
besides their guardian's. The picker and the guard therefore accept exactly
the same set, so nothing offered in the panel can be refused as ineligible.
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 +236,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 +255,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`
- 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 +283,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
+48 -4
View File
@@ -187,7 +187,8 @@ than writing `0`, so "not set" stays the one spelling of "yes, a student".
One guard: a guardian-only account with **nobody linked to it** is still offered
itself, because an empty picker is no way to book at all. They can put the
account right from the profile page.
account right from the profile page — see **Your details** below, where the flag
is editable as "I take lessons myself".
Per child the form collects:
- **Name** (required)
@@ -267,9 +268,51 @@ Booking-scope policies are accepted at booking time by whoever is signed in;
`BookingEndpoint` passes the same `accepted_by` when a guardian books for a
child.
## Managing children
## Managing the account
`[us_family]` (block: **Profile**) renders the guardian's manage-children screen:
### Your details
The profile screen opens with the account holder's own record, because the
alternative was a page called **Your profile** on which the one person who could
not be edited was you. The form saves through
`GuardianService::updateSelf()` and holds:
- **Your name**`display_name` and `nickname`, written together for the reason
`updateChild()` does: `UserName` reads the nickname first, and leaving it
behind would put the account's email address back on every screen that names a
person.
- **I take lessons myself** — the positive of `us_guardian_only`, so the form
asks the question the way a person answers it and `updateSelf()` is the one
place the sense is flipped. This is what makes good on "they can put the
account right from the profile page": an account that registered as a pure
guardian and later took up lessons — or ticked the wrong radio at signup — can
now correct itself instead of asking the studio to.
- **Your birth year**`us_birth_year`, the same meta and the same
`normaliseBirthYear()` rule every student is held to.
Two decisions worth keeping:
- The birth-year field carries **no `required` attribute**. It is asked of a
student only, and the family screen loads no JavaScript, so a browser-enforced
`required` would leave a guardian who books solely for other people unable to
submit the form at all. `FamilyPage::handleSelf()` enforces it against the
checkbox instead, which is where the condition actually lives.
- Unticking **I take lessons myself** does **not** clear a stored birth year.
The box says who books, not "forget what you know about me", and someone who
ticks it back on the next visit should find their details as they left them.
The **email** is shown but not editable: it is the account's `user_login` as
well as its address, so changing it is a studio-side job rather than a
profile-screen one.
The account-scope questions are not re-asked here, in either direction — the
child rows do not offer them on edit either, and a studio that needs a newly
self-declared student's answers asks for them the same way it would for any
other change of circumstance.
### Managing children
The rest of `[us_family]` (block: **Profile**) is the manage-children screen:
list the children, add one, edit a name/birth year, remove one.
- **Add** creates another accountless child user and links it. Account-scope
@@ -366,7 +409,8 @@ need — via `GuardianService::contactFor()`.
- Models: `Unsupervised\Schedular\Guardian\GuardianLink`
- Repository: `Unsupervised\Schedular\Guardian\GuardianRepository`
- Service: `Unsupervised\Schedular\Guardian\GuardianService` (child creation,
`canActFor()`, `payerFor()`, `contactFor()`, removal rules)
`canActFor()`, `payerFor()`, `contactFor()`, removal rules, and the account
holder's own record via `accountHolder()`/`updateSelf()`)
- Login block: `Unsupervised\Schedular\Guardian\ChildLoginGate`
- Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`)
- Shared question field: `Unsupervised\Schedular\Registration\QuestionField`
+28 -2
View File
@@ -34,6 +34,15 @@ page (`manage_billing`, studio admin only):
| `us_currency` | Default ISO 4217 currency, e.g. `CAD` |
| `us_etransfer_email` | Studio-default e-transfer destination |
| `us_hst_rate` | Default HST/tax percentage, e.g. `13` |
| `us_default_payment_method` | Studio-default billing method (`card` \| `etransfer`) |
Secrets are write-only in the form: a stored secret is never echoed back, and a
blank field keeps it. To disconnect Stripe entirely, **Clear Stripe
configuration** (shown once any Stripe value is stored) deletes the publishable
key, secret key and webhook secret and drops the mode back to `test`
(`StudioSettings::clearStripeConfig()`). Currency, HST, e-transfer and
registration settings are untouched, as are payments already recorded; billing
falls back to e-transfer until keys are entered again.
## HST / Tax
@@ -52,8 +61,9 @@ total when tax applies.
## Per-Student Billing Method
Each student's billing method is stored in user meta `us_payment_method`, set by the
studio admin (`Students → student detail → Billing method`). When unset, the studio
default applies `card` if Stripe is configured, otherwise `etransfer`
(`BillingMethodResolver`):
default applies (`BillingMethodResolver::defaultMethod()`): the
`us_default_payment_method` option, degraded to `etransfer` whenever Stripe is not
configured, since a card cannot be charged without keys.
| Method | Behaviour |
|------------|-----------------------------------------------------------------------|
@@ -61,6 +71,22 @@ default applies — `card` if Stripe is configured, otherwise `etransfer`
| `etransfer`| Payment row created `pending`; admin marks it `paid` when funds arrive |
| `comp` | No charge; registration is confirmed immediately, no payment row required |
## Studio Default Billing Method
**Studio Settings → Billing → Default payment method** (`manage_billing`) chooses
between `card` and `etransfer` for every student without an override. Card is the
default, so a studio that adds Stripe keys and changes nothing else behaves as it
always has.
Setting it to `etransfer` is the **staged rollout** path: Stripe stays live, but
the studio keeps billing by e-transfer while individual students are switched to
`card` on their student detail page. Their bookings exercise real Stripe charges
end to end; once that is proven, flipping the studio default to `card` moves
everyone at once and the per-student overrides can be cleared.
`comp` is deliberately not offered as a studio default — it is a per-student
decision, and a studio-wide `comp` would silently stop billing everybody. A stored
value that is neither `card` nor `etransfer` reads back as `card`.
## E-transfer Destination Email
Where students send e-transfers is resolved and **frozen onto the payment** at
booking time (`us_payments.etransfer_email`), so each record keeps the destination
+11 -1
View File
@@ -36,7 +36,10 @@ The studio admin drafts, versions, and publishes policies (e.g. cancellation, pa
| `registration_type` | VARCHAR(20) | `lesson` or `enrollment` |
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.id` |
| `accepted_at` | DATETIME | Timestamp of acceptance |
| `ip_address` | VARCHAR(45) | IP captured at acceptance (audit trail) |
| `ip_address` | VARCHAR(45) | IP captured at acceptance (audit trail); NULL when not given online |
| `collected_via` | VARCHAR(20) | How the acceptance reached the studio when it was not given online (`paper` / `in_person` / `phone` / `email` / `other`); NULL means online |
| `collected_note` | VARCHAR(191) | Free-text detail for the above; required for `other` |
| `recorded_by` | BIGINT UNSIGNED | Staff member who typed a collected-elsewhere acceptance in; 0 otherwise |
## Versioning & Acceptance Rules
- Editing a published policy creates a new `draft` version; the old version stays `published` until the draft is published.
@@ -91,3 +94,10 @@ cover every policy's current version or the registration is rejected.
box, where that differs from `student_id` — a guardian agreeing on a child's
behalf. It defaults to 0, read back as "the student agreed for themselves"
(`PolicyAcceptance::acceptorOrStudent()`). See `parent-guardian-accounts.md`.
`recorded_by` answers a different question: who *entered* the acceptance, for one
collected on paper or over the phone and typed in afterwards. The student still
agreed, so `accepted_by` stays theirs; `collected_via` says how, and no IP is
stored because they were never at a browser. Only lessons the studio booked can
be recorded against — see **Recording Intake Collected Elsewhere** in
`lesson-booking.md`.
+40 -8
View File
@@ -23,11 +23,32 @@ and the same authoring page (**Offerings → Questions**).
| `label` | VARCHAR(255) | The question text shown to the registrant |
| `field_type` | VARCHAR(20) | `text` / `textarea` / `select` / `checkbox` |
| `options` | TEXT | JSON array of choices (for `select`); NULL otherwise |
| `is_required` | TINYINT(1) | 1 = registrant must answer to continue |
| `audience` | VARCHAR(20) | `all` (default) or `child` — who the question is asked of (account scope) |
| `is_required` | TINYINT(1) | 1 = the **account holder** must answer to continue |
| `is_required_child` | TINYINT(1) | 1 = each **student being registered** must answer to continue |
| `sort_order` | INT | Display order within the scope |
| `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form |
| `created_at` | DATETIME | Insertion time |
## Audience and Required-ness (account scope)
An account-scope question is asked in two places, and the two are configured separately:
- **The account holder's own "About you" panel** — shown when they are registering
themselves (`self` or `both`). Governed by `audience` (a `child` question is not asked
here at all) and by `is_required`.
- **Each student block** — one per person they are registering on behalf of, on the signup
form and on the guardian's family screen. Every question is asked here regardless of
`audience`; `is_required_child` decides whether it blocks submission.
That split is what lets a studio ask "School and grade" of children only, or make
"Previous experience" optional for an adult signing themselves up but required for every
child they enrol. `audience = 'child'` leaves `is_required` moot — the question never
reaches the account holder's panel.
`audience` and `is_required_child` are ignored for offering-scope questions: booking and
enrolment ask their intake questions once, about the student being booked, with no separate
account-holder form to differ from.
## Data Model — `{prefix}us_question_answers`
| Column | Type | Notes |
@@ -36,6 +57,9 @@ and the same authoring page (**Offerings → Questions**).
| `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 |
@@ -51,19 +75,24 @@ lesson, a group enrolment, or an account signup (`account` + the user ID).
## Account-scope Flow (signup)
1. The `[us_student_register]` page (`Auth\RegistrationPage`) loads active account-scope questions via `QuestionRepository::findByScope('account')`.
2. The form is a single page. The questions sit in an **About you** panel, alongside the account holder's birth year, between the "Who are you registering?" choice and the students being added. `assets/js/register.js` disables and hides that whole panel when the choice is "on behalf of students" — the questions describe a student and a pure guardian is not one — and puts the same questions in every child block instead. Progressive enhancement: without JS every panel shows and the single submit still works. This applies to **every** signup path (invite, group link, self-approval).
3. On submit, required answers are validated **before** the user is created (a missing answer returns an error and creates no account); after creation each answered question is written to `us_question_answers` with `registration_type = 'account'`, `registration_id = student_id = <new user ID>`.
2. The form is a single page. The questions sit in an **About you** panel, alongside the account holder's birth year, between the "Who are you registering?" choice and the students being added — minus any `audience = 'child'` question, which is never asked of the account holder. `assets/js/register.js` disables and hides that whole panel when the choice is "on behalf of students" — the questions describe a student and a pure guardian is not one — and puts the full question set in every child block instead. Progressive enhancement: without JS every panel shows and the single submit still works. This applies to **every** signup path (invite, group link, self-approval).
3. On submit, required answers are validated **before** the user is created (a missing answer returns an error and creates no account)`is_required` against the account holder's panel, `is_required_child` against each student block; after creation each answered question is written to `us_question_answers` with `registration_type = 'account'`, `registration_id = student_id = <new user ID>`. An answer posted for a `child`-audience question against the account holder is discarded, not stored.
4. A studio admin reviews the answers on the student's admin screen under **Registration Information** (`Auth\StudentHistory::registrationInfo()` lists every account question paired with the student's answer, "—" when unanswered). These rows are excluded from the offering-scope "Intake answers" table.
## Admin Interface
Both scopes are edited from **Offerings → Questions** (`Registration\QuestionController`):
- Pick an offering to edit its questions, or **"Account signup (all registrations)"** for the account-scope questions.
- The account-scope form adds **Asked of** (everyone / students only) and a second **Required** checkbox for students; both are hidden for offering scope, where they have no meaning.
- Studio admin (`manage_questions` + `manage_instructors`) edits any offering's questions and the account-scope questions.
- Instructor (`manage_questions`) edits questions only on their own offerings; the account-scope option is hidden.
## REST API
Only offering-scope questions are exposed over REST. Account-scope questions are managed
through the server-rendered admin page and read directly by `RegistrationPage`.
through the server-rendered admin page and read directly by `RegistrationPage` — a request
naming one is turned away as not found, since the owner check has no offering to check
against, so REST can neither read nor overwrite an `audience`. An offering question written
over REST mirrors its single `is_required` into `is_required_child`, as the admin form and
the upgrade backfill both do.
| Method | Endpoint | Permission |
|----------|---------------------------------------------------|----------------------|
@@ -74,12 +103,13 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
## Implementation
- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository` (`findByOffering`, `findByScope`), `Unsupervised\Schedular\Registration\AnswerRepository`
- Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`)
- Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`, `audience`, `isRequiredChild`, and the `askedOfSelf()` / `isRequiredForSelf()` / `isRequiredForChild()` readers every caller uses instead of touching `isRequired` directly), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`)
- Admin controller: `Unsupervised\Schedular\Registration\QuestionController`
- REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint` (offering scope only)
- Signup form: `Unsupervised\Schedular\Auth\RegistrationPage`, `templates/frontend/register-page.php`, `assets/js/register.js`
- Admin review: `Unsupervised\Schedular\Auth\StudentHistory::registrationInfo()`, `templates/admin/student-detail.php`
- Schema: `us_questions.scope` + nullable `us_questions.offering_id` (requires a plugin version bump so `dbDelta` runs)
- Schema: `us_questions.scope` + nullable `us_questions.offering_id`, `us_questions.audience`, `us_questions.is_required_child` (each requires a plugin version bump so `dbDelta` runs)
- Required-for-students backfill: `is_required_child` arrives with `DEFAULT 0`, which would quietly make every existing required question optional for students. `QuestionRepository::backfillChildRequired()` copies `is_required` into it once; `Plugin::boot()` runs it guarded by the `us_questions_child_required_backfilled` option, after the version gate has let `dbDelta` add the column
- Nullability repair: `dbDelta` does **not** reliably relax a column from `NOT NULL` to `NULL`, so sites created before account-scope questions kept `offering_id NOT NULL` and rejected account inserts. `QuestionRepository::ensureOfferingNullable()` re-applies the nullable definition (idempotent `ALTER … MODIFY`); `Plugin::boot()` runs it once, guarded by the `us_questions_offering_nullable` option rather than the version gate (affected sites may already be on the current version)
## Tests
@@ -87,8 +117,10 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
- `tests/Unit/Registration/AnswerRepositoryTest.php`
- `tests/Unit/Registration/QuestionTest.php`
- `tests/Unit/Registration/AnswerTest.php`
- `tests/Unit/Registration/QuestionFieldTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php`
- `tests/Unit/Auth/StudentHistoryTest.php`
- `tests/Unit/Guardian/FamilyPageTest.php`
## Per-Child Answers
For a parent/guardian signup, **account-scope** questions are asked **once per
@@ -96,5 +128,5 @@ child** rather than once per guardian — in practice they describe the student
(instrument, level, school), not the account holder. Each answer's `student_id`
and `registration_id` are the child's user ID, so a studio admin reading a
child's screen sees the information that describes them. The guardian's family
screen asks the same questions when a child is added later. See
`parent-guardian-accounts.md`.
screen asks the same questions when a child is added later, under the same
`is_required_child` rule as the signup form. See `parent-guardian-accounts.md`.
+13 -4
View File
@@ -17,9 +17,10 @@ use Unsupervised\Schedular\Auth\StudentActions;
use Unsupervised\Schedular\Auth\StudentController;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Booking\AdminBooking;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonBooker;
use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\Booking\LessonDetail;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController;
@@ -42,6 +43,9 @@ use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\QuestionController;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Registration\IntakeAudit;
use Unsupervised\Schedular\Registration\IntakeRecording;
use Unsupervised\Schedular\Registration\RegistrationGate;
class AdminMenu {
@@ -66,15 +70,20 @@ class AdminMenu {
private PaymentController $paymentController;
private PaymentReportController $paymentReportController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits, GuardianService $guardians ) {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits, GuardianService $guardians, LessonBooker $booker, RegistrationGate $gate ) {
// One audit presenter and one recorder, shared by the lesson and enrolment
// detail views: intake is the same thing whichever registration it hangs off.
$intakeAudit = new IntakeAudit( $answers, $questions, $acceptances, $policies, $policyVersions );
$intakeRecording = new IntakeRecording( $questions, $answers, $policies, $policyVersions, $acceptances, $gate );
$this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) );
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, $intakeAudit, new AdminBooking( $availability, $offerings, $booker ), $intakeRecording );
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
$this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites );
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer, $intakeAudit, $intakeRecording );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
$this->instructorController = new InstructorController();
$this->settings = $settings;
+18 -5
View File
@@ -319,6 +319,13 @@ class RegistrationPage {
// student themselves. "Both" is both.
$registeringFor = $this->submittedRegisteringFor();
// A "students only" question is never put to the account holder, so it is
// dropped before their answers are validated or stored — a crafted post
// cannot file one against them.
$selfQuestions = array_values(
array_filter( $accountQuestions, static fn( Question $question ): bool => $question->askedOfSelf() )
);
// "Students" and "both" collect student blocks; only "self" does not.
$isGuardian = self::FOR_SELF !== $registeringFor;
@@ -355,7 +362,7 @@ class RegistrationPage {
// Checked as two passes rather than one so the message can say *whose*
// answers are missing — under "both" a single message could not.
foreach ( array_column( $children, 'answers' ) as $set ) {
if ( $this->hasUnansweredRequired( $accountQuestions, $set ) ) {
if ( $this->hasUnansweredRequired( $accountQuestions, $set, forChild: true ) ) {
return esc_html__( 'Please answer all required registration questions for each student.', 'unsupervised-schedular' );
}
}
@@ -369,7 +376,7 @@ class RegistrationPage {
return esc_html( GuardianService::ownBirthYearError() );
}
if ( $asksSelf && $this->hasUnansweredRequired( $accountQuestions, $answers ) ) {
if ( $asksSelf && $this->hasUnansweredRequired( $selfQuestions, $answers ) ) {
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
}
@@ -420,7 +427,7 @@ class RegistrationPage {
// After the children, so a rollback that deletes this account cannot
// leave its answers behind pointing at a user that no longer exists.
if ( $asksSelf ) {
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
$this->recordAnswers( $selfQuestions, $answers, (int) $userId );
}
if ( $inviteValid && ! $invite->isGroup() ) {
@@ -564,12 +571,18 @@ class RegistrationPage {
/**
* Whether any required question in `$questions` is left blank in `$answers`.
*
* `$forChild` picks which required-ness applies: a question can be optional
* for the account holder answering about themselves and still required of
* every student they register.
*
* @param list<Question> $questions
* @param array<int, string> $answers
*/
private function hasUnansweredRequired( array $questions, array $answers ): bool {
private function hasUnansweredRequired( array $questions, array $answers, bool $forChild = false ): bool {
foreach ( $questions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
$required = $forChild ? $question->isRequiredForChild() : $question->isRequiredForSelf();
if ( $required && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
return true;
}
}
+21
View File
@@ -60,6 +60,27 @@ class RoleManager {
self::CAP_EXPORT_PAYMENTS,
];
/**
* Whether a user account is a student the studio may act for.
*
* Deliberately the role and not the `book_lesson` capability: that capability
* is withheld from a guardian's child ({@see \Unsupervised\Schedular\Guardian\ChildLoginGate})
* and from a self-signup still awaiting approval
* ({@see \Unsupervised\Schedular\Auth\RegistrationLoginGate}), so that neither
* can book or enrol *in their own name*. Staff booking or enrolling on their
* behalf is the case those restrictions exist to leave open and for a child,
* whose account is never signed in to, it is the only route there is.
*
* Use this for every "may the studio register this person?" check, so the
* pickers staff choose from and the guards that vet their choice cannot drift
* into offering someone who is then refused.
*/
public static function isStudent( int $userId ): bool {
$user = $userId > 0 ? get_userdata( $userId ) : false;
return $user instanceof \WP_User && in_array( self::STUDENT, (array) $user->roles, true );
}
public function __construct( private AccessSettings $access = new AccessSettings() ) {}
public function register(): void {
+23 -4
View File
@@ -176,10 +176,26 @@ class BlockPreview {
}
/**
* Sample family (manage-children) page: two representative children and the
* add form, with the controls inert so the editor preview cannot post.
* Sample family page: the account holder's own details, two representative
* children and the add form, with the controls inert so the editor preview
* cannot post.
*/
public static function family(): string {
$self = sprintf(
'<h4>%s</h4><p class="us-family-self-email">%s <span>[email protected]</span></p>'
. '<p><label for="us-own-name">%s' . self::REQUIRED_MARK . '</label><input type="text" id="us-own-name" value="%s"></p>'
. '<p><label><input type="checkbox" checked disabled> %s</label></p>'
. '<p><label for="us-own-birth-year">%s</label><input type="number" id="us-own-birth-year" placeholder="YYYY"></p>'
. '<p><button type="button" disabled>%s</button></p>',
esc_html__( 'Your details', 'unsupervised-schedular' ),
esc_html__( 'Email', 'unsupervised-schedular' ),
esc_html__( 'Your name', 'unsupervised-schedular' ),
esc_attr__( 'Grace Hopper', 'unsupervised-schedular' ),
esc_html__( 'I take lessons myself', 'unsupervised-schedular' ),
esc_html__( 'Your birth year', 'unsupervised-schedular' ),
esc_html__( 'Save my details', 'unsupervised-schedular' )
);
$children = '';
foreach ( [ 'Ada Lovelace', 'Alan Turing' ] as $name ) {
$children .= sprintf(
@@ -202,9 +218,12 @@ class BlockPreview {
);
return sprintf(
'<div class="us-family">%s<h3>%s</h3><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
self::note( __( 'Editor preview — signed-in guardians see and manage their own students here.', 'unsupervised-schedular' ) ),
'<div class="us-family">%s<h3>%s</h3><form class="us-family-self">%s</form>'
. '<h4>%s</h4><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
self::note( __( 'Editor preview — signed-in visitors see and manage their own details and students here.', 'unsupervised-schedular' ) ),
esc_html__( 'Your profile', 'unsupervised-schedular' ),
$self,
esc_html__( 'Your students', 'unsupervised-schedular' ),
$children,
$add
);
+289
View File
@@ -0,0 +1,289 @@
<?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 four
* 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.
* 4. **It can book for a student who cannot book at all** a guardian's child,
* or someone still awaiting approval. Both hold the student role but have
* `book_lesson` withheld so that neither can book in their own name; that is
* a limit on them, never on the studio acting for them.
*/
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 {
// The student role, not the `book_lesson` capability — see
// {@see RoleManager::isStudent()} for why a child and an unapproved signup
// must both be bookable for.
if ( ! RoleManager::isStudent( $studentId ) ) {
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 every holder of the student role,
* which is exactly the set {@see book()} accepts. That deliberately includes
* the children a guardian books for and students still awaiting approval:
* neither may book in their own name, both may be booked for.
*
* @return list<array{id: int, name: string}>
*/
private function studentOptions(): array {
$users = array_filter(
get_users(
[
'role' => RoleManager::STUDENT,
'orderby' => 'display_name',
'order' => 'ASC',
]
),
static fn( mixed $u ): bool => $u instanceof \WP_User
);
return array_values(
array_map(
static fn( \WP_User $u ): array => [
'id' => (int) $u->ID,
'name' => UserName::format( $u, (int) $u->ID ),
],
$users
)
);
}
}
+18 -147
View File
@@ -7,9 +7,7 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
@@ -17,18 +15,13 @@ use Unsupervised\Schedular\Val;
class BookingEndpoint {
/**
* The most occurrences a single weekly booking may reserve at once, so one
* student cannot lock up an instructor's entire recurring schedule.
*/
private const MAX_WEEKLY_OCCURRENCES = 12;
public function __construct(
private AvailabilityRepository $availability,
private BookingRepository $bookings,
private OfferingRepository $offerings,
private RegistrationGate $gate,
private PaymentService $payments,
private LessonBooker $booker,
private CancellationPolicy $cancellationPolicy,
private GuardianService $guardians,
private SessionSchedule $sessions,
@@ -227,52 +220,12 @@ class BookingEndpoint {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
// Resolve the offering for this booking. A client-supplied offering must
// never override the slot's price or payment routing: when the slot is tied
// to a specific offering that offering is authoritative, and any offering
// used must belong to the slot's instructor. This prevents substituting a
// cheaper/free offering to dodge payment, or another instructor's offering
// to misroute it.
$requestedOfferingId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$slotOfferingId = (int) ( $slot->offeringId ?? 0 );
if ( $slotOfferingId > 0 ) {
if ( $requestedOfferingId > 0 && $requestedOfferingId !== $slotOfferingId ) {
return new \WP_Error( 'offering_mismatch', __( 'This slot is tied to a different offering.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offeringId = $slotOfferingId;
} else {
$offeringId = $requestedOfferingId;
$offering = $this->booker->resolveOffering( $slot, absint( Val::int( $request->get_param( 'offering_id' ) ) ) );
if ( $offering instanceof \WP_Error ) {
return $offering;
}
// Every lesson books against an offering: it carries the price, intake
// questions, and payment routing. Without one the booking would silently
// be free and unquestioned, so generic slots require the student's choice.
if ( $offeringId <= 0 ) {
return new \WP_Error( 'offering_required', __( 'Choose a lesson type to book this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering ) {
return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( $offering->instructorId !== $slot->instructorId ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
// A slot-tied offering was the instructor's explicit choice and is honoured
// as-is; a student-chosen one must be something the catalog actually offers
// for this slot: an active private-lesson type whose length fits the slot.
if ( 0 === $slotOfferingId ) {
if ( ! $offering->isActive || Offering::KIND_PRIVATE_LESSON !== $offering->kind ) {
return new \WP_Error( 'invalid_offering', __( 'That offering cannot be booked as a private lesson.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( null !== $offering->durationMinutes && $offering->durationMinutes !== $slot->durationMinutes ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering does not match this slot\'s lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
}
$offeringId = (int) $offering->id;
$answers = $this->answers( $request );
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) );
@@ -282,93 +235,28 @@ class BookingEndpoint {
return $gateError;
}
$notes = Val::string( $request->get_param( 'notes' ) );
$recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' )
? Lesson::RECURRENCE_WEEKLY
: Lesson::RECURRENCE_SINGLE;
$notes = Val::string( $request->get_param( 'notes' ) );
$template = new Lesson(
slotId: $slotId,
studentId: $studentId,
instructorId: $slot->instructorId,
offeringId: $offeringId,
recurrence: $recurrence,
notes: '' !== $notes ? $notes : null,
$reservation = $this->booker->reserve(
$slot,
$offering,
$studentId,
Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
$notes
);
// Weekly reservation across the slot's recurring group; otherwise a single lesson.
if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) {
// Claim each occurrence atomically (capped so one booking cannot lock an
// instructor's entire schedule), then create a lesson only for the slots
// this request actually won — never for one already taken by someone else.
$candidates = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) );
$candidates = array_slice( $candidates, 0, self::MAX_WEEKLY_OCCURRENCES );
$claimed = array_values( array_filter( $candidates, fn( int $candidateId ): bool => $this->availability->claim( $candidateId ) ) );
if ( [] === $claimed ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$ids = $this->bookings->insertSeries( $template, $claimed );
$anchorId = $ids[0] ?? 0;
} else {
// Claim before inserting: if another request already took the slot, the
// guarded update reports no rows and we reject rather than double-book.
if ( ! $this->availability->claim( $slotId ) ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$anchorId = $this->bookings->insert( $template );
$ids = [ $anchorId ];
if ( $reservation instanceof \WP_Error ) {
return $reservation;
}
$ids = $reservation['ids'];
$anchorId = $reservation['anchor_id'];
// The acceptance binds the student but is attributed to whoever actually
// ticked the boxes — the guardian, when they booked for a child.
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() );
$payment = null;
$status = Lesson::STATUS_PENDING;
// Scheduled billing (weekly / monthly) normally defers payment to the daily
// scan, but a single lesson booked once its scheduled due date has already
// passed — e.g. an extra lesson added to a month that was already billed — is
// charged at booking instead, so it is never missed or billed late.
$chargeAtBooking = $offering->price > 0.0 && (
! $offering->isScheduledBilling()
|| ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) )
);
if ( $chargeAtBooking ) {
// A full-term price already covers the whole reservation; a per-lesson
// (one_time) price is owed once per occurrence actually claimed, so a
// weekly reservation cannot hold a term while paying for one week.
$amount = Offering::BILLING_FULL_TERM === $offering->billingMode
? $offering->price
: $offering->price * count( $ids );
$payment = $this->payments->createForRegistration(
Payment::REG_LESSON,
$anchorId,
$studentId,
$slot->instructorId,
$amount,
$offering->currency,
$offering->etransferEmail,
payerId: $this->guardians->payerFor( $studentId )
);
if ( null !== $payment && $payment->isPaid() ) {
$status = Lesson::STATUS_CONFIRMED;
}
} else {
// Either a free offering, or scheduled billing (weekly / monthly) whose
// payment is deferred to the daily billing scan. Either way there is no
// payment step now to confirm the lessons, so the reserved slots are
// confirmed at booking time; the billing scan bills them when they come due.
foreach ( $ids as $lessonId ) {
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
}
$status = Lesson::STATUS_CONFIRMED;
}
[ 'status' => $status, 'payment' => $payment ] = $this->booker->settle( $ids, $anchorId, $slot, $offering, $studentId );
// `payment: null` tells the front end to skip the payment step entirely.
return new \WP_REST_Response(
@@ -424,23 +312,6 @@ class BookingEndpoint {
return $out;
}
/**
* Whether a scheduled-billing offering's due date for a given session has
* already passed at booking time. Weekly bills 24 hours before the lesson;
* monthly bills on the 1st, so its due moment has passed once "now" is in the
* lesson's month or later. Only meaningful for weekly / monthly offerings.
*/
private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool {
$now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) );
$start = new \DateTimeImmutable( $slotStart );
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' );
}
return $now >= $start->modify( '-1 day' );
}
private function clientIp(): ?string {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
+3 -1
View File
@@ -24,9 +24,10 @@ class BookingRepository {
'status' => $lesson->status,
'payment_id' => $lesson->paymentId,
'notes' => $lesson->notes,
'booked_by' => $lesson->bookedBy,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s' ]
[ '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
@@ -54,6 +55,7 @@ class BookingRepository {
seriesId: $seriesId > 0 ? $seriesId : null,
status: $template->status,
notes: $template->notes,
bookedBy: $template->bookedBy,
)
);
+43 -1
View File
@@ -3,9 +3,11 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\IntakeSubject;
use Unsupervised\Schedular\Val;
class Lesson {
class Lesson implements IntakeSubject {
public const STATUS_PENDING = 'pending';
public const STATUS_CONFIRMED = 'confirmed';
@@ -38,9 +40,47 @@ class Lesson {
public readonly string $status = self::STATUS_PENDING,
public readonly ?int $paymentId = null,
public readonly ?string $notes = null,
/**
* The staff member who booked this lesson on the student's behalf, from
* wp-admin; 0 when it was booked through the student-facing flow, by the
* student or their guardian. It is what marks a lesson whose intake answers
* and policy acceptances may be recorded after the fact nobody was at a
* keyboard to give them at booking time.
*/
public readonly int $bookedBy = 0,
public readonly ?int $id = null,
) {}
public function intakeRegistrationType(): string {
return Answer::REG_LESSON;
}
/**
* The lesson id this booking's intake answers and policy acceptances hang
* off: the series anchor for a weekly reservation, the lesson itself
* otherwise. A series is answered for and agreed to once, so every occurrence
* reads and writes the same registration.
*/
public function intakeRegistrationId(): int {
return $this->seriesId ?? (int) $this->id;
}
public function intakeOfferingId(): int {
return (int) $this->offeringId;
}
public function intakeStudentId(): int {
return $this->studentId;
}
/**
* Whether the studio booked this lesson on the student's behalf, rather than
* the student (or their guardian) booking it themselves.
*/
public function isStaffRegistered(): bool {
return $this->bookedBy > 0;
}
public static function fromRow( \stdClass $row ): self {
return new self(
slotId: Val::int( $row->slot_id ),
@@ -52,6 +92,7 @@ class Lesson {
status: Val::string( $row->status ),
paymentId: Val::intOrNull( $row->payment_id ),
notes: Val::stringOrNull( $row->notes ),
bookedBy: Val::int( $row->booked_by ?? 0 ),
id: Val::int( $row->id ),
);
}
@@ -73,6 +114,7 @@ class Lesson {
'status' => $this->status,
'payment_id' => $this->paymentId,
'notes' => $this->notes,
'booked_by' => $this->bookedBy,
];
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Val;
/**
* The booking core shared by the student-facing REST endpoint and the admin
* "book a lesson for a student" form: which offering a slot may be booked as,
* claiming the slot(s) and writing the lesson row(s), and raising the payment
* that confirms them.
*
* It deliberately knows nothing about who is asking. Authorisation a guardian
* booking for their own child, a studio admin booking for anyone is settled by
* the caller before anything here is touched, and so are the intake answers and
* policy acceptances that gate a student's own booking (an admin booking on
* someone's behalf has none to collect). What must not diverge between the two
* paths is everything below: the offering rules that decide a slot's price and
* payment routing, the atomic claim that stops a double-booking, and the billing
* that follows.
*/
class LessonBooker {
/**
* The most occurrences a single weekly booking may reserve at once, so one
* student cannot lock up an instructor's entire recurring schedule.
*/
public const MAX_WEEKLY_OCCURRENCES = 12;
public function __construct(
private AvailabilityRepository $availability,
private BookingRepository $bookings,
private OfferingRepository $offerings,
private PaymentService $payments,
private GuardianService $guardians,
) {}
/**
* Resolve the offering a slot is to be booked as. A caller-supplied offering
* must never override the slot's price or payment routing: when the slot is
* tied to a specific offering that offering is authoritative, and any offering
* used must belong to the slot's instructor. This prevents substituting a
* cheaper/free offering to dodge payment, or another instructor's offering to
* misroute it.
*/
public function resolveOffering( AvailabilitySlot $slot, int $requestedOfferingId ): Offering|\WP_Error {
$requestedOfferingId = absint( $requestedOfferingId );
$slotOfferingId = (int) ( $slot->offeringId ?? 0 );
if ( $slotOfferingId > 0 ) {
if ( $requestedOfferingId > 0 && $requestedOfferingId !== $slotOfferingId ) {
return new \WP_Error( 'offering_mismatch', __( 'This slot is tied to a different offering.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offeringId = $slotOfferingId;
} else {
$offeringId = $requestedOfferingId;
}
// Every lesson books against an offering: it carries the price, intake
// questions, and payment routing. Without one the booking would silently
// be free and unquestioned, so generic slots require an explicit choice.
if ( $offeringId <= 0 ) {
return new \WP_Error( 'offering_required', __( 'Choose a lesson type to book this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering ) {
return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( $offering->instructorId !== $slot->instructorId ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
// A slot-tied offering was the instructor's explicit choice and is honoured
// as-is; a chosen one must be something the catalog actually offers for this
// slot: an active private-lesson type whose length fits the slot.
if ( 0 === $slotOfferingId ) {
if ( ! $offering->isActive || Offering::KIND_PRIVATE_LESSON !== $offering->kind ) {
return new \WP_Error( 'invalid_offering', __( 'That offering cannot be booked as a private lesson.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( null !== $offering->durationMinutes && $offering->durationMinutes !== $slot->durationMinutes ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering does not match this slot\'s lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
}
return $offering;
}
/**
* Claim the slot(s) and write the lesson row(s) a single lesson, or one per
* remaining occurrence of the slot's weekly group. The rows are created
* `pending`; `settle()` decides what confirms them.
*
* `$bookedBy` is the staff member booking on the student's behalf, and 0 for a
* booking made through the student-facing flow. It is recorded on every lesson
* of a series, since a series is booked once.
*
* @return array{ids: list<int>, anchor_id: int}|\WP_Error
*/
public function reserve( AvailabilitySlot $slot, Offering $offering, int $studentId, string $recurrence, ?string $notes = null, int $bookedBy = 0 ): array|\WP_Error {
$slotId = (int) $slot->id;
$recurrence = Lesson::RECURRENCE_WEEKLY === $recurrence ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE;
$template = new Lesson(
slotId: $slotId,
studentId: $studentId,
instructorId: $slot->instructorId,
offeringId: (int) $offering->id,
recurrence: $recurrence,
notes: null !== $notes && '' !== $notes ? $notes : null,
bookedBy: $bookedBy,
);
// Weekly reservation across the slot's recurring group; otherwise a single lesson.
if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) {
// Claim each occurrence atomically (capped so one booking cannot lock an
// instructor's entire schedule), then create a lesson only for the slots
// this request actually won — never for one already taken by someone else.
$candidates = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) );
$candidates = array_slice( $candidates, 0, self::MAX_WEEKLY_OCCURRENCES );
$claimed = array_values( array_filter( $candidates, fn( int $candidateId ): bool => $this->availability->claim( $candidateId ) ) );
if ( [] === $claimed ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$ids = $this->bookings->insertSeries( $template, $claimed );
return [
'ids' => $ids,
'anchor_id' => $ids[0] ?? 0,
];
}
// Claim before inserting: if another request already took the slot, the
// guarded update reports no rows and we reject rather than double-book.
if ( ! $this->availability->claim( $slotId ) ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$anchorId = $this->bookings->insert( $template );
return [
'ids' => [ $anchorId ],
'anchor_id' => $anchorId,
];
}
/**
* Raise the payment for a reservation and report the status its lessons end up
* in. A priced booking stays `pending` until its payment settles; anything with
* nothing to charge now a free offering, scheduled billing, or a booking the
* caller marked `$noCharge` is confirmed here and then.
*
* @param list<int> $ids
*
* @return array{status: string, payment: ?Payment}
*/
public function settle( array $ids, int $anchorId, AvailabilitySlot $slot, Offering $offering, int $studentId, bool $noCharge = false ): array {
// Scheduled billing (weekly / monthly) normally defers payment to the daily
// scan, but a single lesson booked once its scheduled due date has already
// passed — e.g. an extra lesson added to a month that was already billed — is
// charged at booking instead, so it is never missed or billed late.
$chargeAtBooking = ! $noCharge && $offering->price > 0.0 && (
! $offering->isScheduledBilling()
|| ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) )
);
if ( $chargeAtBooking ) {
// A full-term price already covers the whole reservation; a per-lesson
// (one_time) price is owed once per occurrence actually claimed, so a
// weekly reservation cannot hold a term while paying for one week.
$amount = Offering::BILLING_FULL_TERM === $offering->billingMode
? $offering->price
: $offering->price * count( $ids );
$payment = $this->payments->createForRegistration(
Payment::REG_LESSON,
$anchorId,
$studentId,
$slot->instructorId,
$amount,
$offering->currency,
$offering->etransferEmail,
payerId: $this->guardians->payerFor( $studentId )
);
return [
'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING,
'payment' => $payment,
];
}
// Either nothing is owed — a free offering, or a booking the studio comped —
// or scheduled billing (weekly / monthly) whose payment is deferred to the
// daily billing scan. Either way there is no payment step now to confirm the
// lessons, so the reserved slots are confirmed at booking time; the billing
// scan bills the scheduled ones when they come due.
foreach ( $ids as $lessonId ) {
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
}
return [
'status' => Lesson::STATUS_CONFIRMED,
'payment' => null,
];
}
/**
* Whether a scheduled-billing offering's due date for a lesson has already
* gone by monthly bills on the first of the lesson's month, weekly the day
* before the lesson.
*/
private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool {
$now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) );
$start = new \DateTimeImmutable( $slotStart );
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' );
}
return $now >= $start->modify( '-1 day' );
}
}
+191 -16
View File
@@ -10,6 +10,9 @@ use Unsupervised\Schedular\Availability\WeekCalendar;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Registration\IntakeAudit;
use Unsupervised\Schedular\Registration\IntakeProvenance;
use Unsupervised\Schedular\Registration\IntakeRecording;
use Unsupervised\Schedular\Val;
class LessonController {
@@ -19,7 +22,9 @@ class LessonController {
private PaymentRepository $payments,
private AvailabilityRepository $availability,
private OfferingRepository $offerings,
private LessonDetail $detail,
private IntakeAudit $detail,
private AdminBooking $adminBooking,
private IntakeRecording $intake,
) {}
public function renderAdminDashboard(): void {
@@ -31,11 +36,11 @@ class LessonController {
return;
}
$this->handleEtransferUpdate( false );
[ $notice, $error ] = $this->handleFormAction( false, 0 );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() );
$this->renderLessonsPage( $rows, 'us-scheduler' );
$this->renderLessonsPage( $rows, 'us-scheduler', 0, $notice, $error );
}
public function renderInstructorLessons(): void {
@@ -47,11 +52,13 @@ class LessonController {
return;
}
$this->handleEtransferUpdate( true );
$instructorId = get_current_user_id();
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
[ $notice, $error ] = $this->handleFormAction( true, $instructorId );
$this->renderLessonsPage( $rows, 'us-my-lessons' );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( $instructorId ) );
$this->renderLessonsPage( $rows, 'us-my-lessons', $instructorId, $notice, $error );
}
/**
@@ -68,15 +75,23 @@ class LessonController {
$lesson = $this->repository->findById( $lessonId );
$backUrl = admin_url( 'admin.php?page=' . $pageSlug );
$notice = '';
$error = '';
if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) {
$row = null;
$answers = [];
$accepts = [];
$intake = $this->emptyIntake();
} else {
// Recorded before the tables are read, so what was just entered appears
// on the page that reports it.
[ $notice, $error ] = $this->recordIntake( $lesson );
$row = $this->row( $lesson );
$answers = $this->detail->answers( $lessonId );
$accepts = $this->detail->acceptances( $lessonId );
$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 is 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,108 @@ 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 );
// A refused booking is shown again as it was typed — losing five fields to a
// single mistake is what made the panel infuriating to correct. A successful
// one starts empty, so the next booking does not inherit the last one's.
$bookValues = '' !== $error ? $this->submittedBooking() : $this->emptyBooking();
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
}
/**
* Handle a per-lesson payment override (e-transfer email or HST rate). When
* $onlyOwn, the payment must belong to the current instructor.
* Run the submitted action and report what happened: a per-lesson payment
* override (e-transfer email or HST rate), or a lesson booked for a student.
* When $onlyOwn, the payment or slot must belong to the current instructor.
*
* @return array{string, string} Success notice and error message; each is
* empty when it does not apply.
*/
private function handleEtransferUpdate( bool $onlyOwn ): void {
private function handleFormAction( bool $onlyOwn, int $instructorId ): array {
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) {
return;
return [ '', '' ];
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
if ( 'book_for_student' === $action ) {
return $this->bookForStudent( $onlyOwn ? $instructorId : 0 );
}
$this->updatePayment( $action, $onlyOwn );
return [ '', '' ];
}
/**
* Book a lesson on a student's behalf from the submitted form. The slot is
* scoped to the instructor's own schedule on **My Lessons** ($onlyInstructorId
* non-zero) and studio-wide on the **Scheduler**.
*
* @return array{string, string}
*/
private function bookForStudent( int $onlyInstructorId ): array {
$submitted = $this->submittedBooking();
$result = $this->adminBooking->book(
$submitted['student_id'],
$submitted['slot_id'],
$submitted['offering_id'],
$submitted['weekly'] ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
$submitted['no_charge'],
$submitted['notes'],
$onlyInstructorId
);
return $result instanceof \WP_Error
? [ '', $result->get_error_message() ]
: [ $result, '' ];
}
/**
* The book-for-a-student form exactly as submitted. Read in one place so what
* gets booked and what the form shows again after a refusal cannot drift apart
* on a field name.
*
* @return array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string}
*/
private function submittedBooking(): array {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- read only after handleFormAction() has verified the nonce: to book, or to re-render (escaped) a form it refused.
return [
'student_id' => absint( Val::int( $_POST['student_id'] ?? 0 ) ),
'slot_id' => absint( Val::int( $_POST['slot_id'] ?? 0 ) ),
'offering_id' => absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
'weekly' => isset( $_POST['recurrence_weekly'] ),
'no_charge' => isset( $_POST['no_charge'] ),
'notes' => sanitize_text_field( Val::string( wp_unslash( $_POST['notes'] ?? '' ) ) ),
];
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* An untouched book-for-a-student form.
*
* @return array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string}
*/
private function emptyBooking(): array {
return [
'student_id' => 0,
'slot_id' => 0,
'offering_id' => 0,
'weekly' => false,
'no_charge' => false,
'notes' => '',
];
}
/**
* 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.
-73
View File
@@ -1,73 +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}.
*/
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( int $lessonId ): 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, $lessonId )
);
}
/**
* 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( int $lessonId ): 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, $lessonId )
);
}
}
+41 -1
View File
@@ -3,9 +3,11 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\IntakeSubject;
use Unsupervised\Schedular\Val;
class Enrollment {
class Enrollment implements IntakeSubject {
public const STATUS_ACTIVE = 'active';
public const STATUS_CANCELLED = 'cancelled';
@@ -24,9 +26,45 @@ class Enrollment {
public readonly int $instructorId,
public readonly string $status = self::STATUS_ACTIVE,
public readonly ?int $paymentId = null,
/**
* The staff member who enrolled this student from wp-admin the class
* detail page's **Add students directly**; 0 when the student or their
* guardian enrolled themselves. It is what marks an enrolment whose intake
* answers and policy acceptances may be recorded after the fact, nobody
* having been at a keyboard to give them at the time.
*/
public readonly int $enrolledBy = 0,
public readonly ?int $id = null,
) {}
public function intakeRegistrationType(): string {
return Answer::REG_ENROLLMENT;
}
/**
* An enrolment is registered once and is its own registration there is no
* series anchor to follow, as a term of classes is one enrolment.
*/
public function intakeRegistrationId(): int {
return (int) $this->id;
}
public function intakeOfferingId(): int {
return $this->offeringId;
}
public function intakeStudentId(): int {
return $this->studentId;
}
/**
* Whether the studio enrolled this student, rather than the student (or their
* guardian) enrolling themselves.
*/
public function isStaffRegistered(): bool {
return $this->enrolledBy > 0;
}
public static function fromRow( \stdClass $row ): self {
return new self(
offeringId: Val::int( $row->offering_id ),
@@ -34,6 +72,7 @@ class Enrollment {
instructorId: Val::int( $row->instructor_id ),
status: Val::string( $row->status ),
paymentId: Val::intOrNull( $row->payment_id ),
enrolledBy: Val::int( $row->enrolled_by ?? 0 ),
id: Val::int( $row->id ),
);
}
@@ -51,6 +90,7 @@ class Enrollment {
'instructor_id' => $this->instructorId,
'status' => $this->status,
'payment_id' => $this->paymentId,
'enrolled_by' => $this->enrolledBy,
];
}
}
+2 -1
View File
@@ -20,9 +20,10 @@ class EnrollmentRepository {
'instructor_id' => $enrollment->instructorId,
'status' => $enrollment->status,
'payment_id' => $enrollment->paymentId,
'enrolled_by' => $enrollment->enrolledBy,
'enrolled_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%d', '%s', '%d', '%s' ]
[ '%d', '%d', '%d', '%s', '%d', '%d', '%s' ]
);
return $this->db->insert_id;
+163 -4
View File
@@ -14,6 +14,9 @@ use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Registration\IntakeAudit;
use Unsupervised\Schedular\Registration\IntakeProvenance;
use Unsupervised\Schedular\Registration\IntakeRecording;
use Unsupervised\Schedular\Val;
class GroupClassController {
@@ -26,6 +29,8 @@ class GroupClassController {
private PaymentService $paymentService,
private InviteRepository $invites,
private RegistrationMailer $mailer,
private IntakeAudit $audit,
private IntakeRecording $intake,
) {}
/**
@@ -41,13 +46,18 @@ class GroupClassController {
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
}
$baseUrl = admin_url( 'admin.php?page=us-group-classes' );
if ( $this->maybeRenderEnrollmentDetail( $baseUrl, 0 ) ) {
return;
}
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
$notice = $this->handleFormAction( get_current_user_id() );
}
$offerings = $this->offerings->findAll( 0, Offering::KIND_GROUP_CLASS );
$baseUrl = admin_url( 'admin.php?page=us-group-classes' );
// View-state query param only (which class to drill into) — nothing is
// mutated from it, so no nonce applies.
@@ -104,6 +114,10 @@ class GroupClassController {
$instructorId = get_current_user_id();
if ( $this->maybeRenderEnrollmentDetail( admin_url( 'admin.php?page=us-my-group-classes' ), $instructorId ) ) {
return;
}
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
$notice = $this->handleFormAction( $instructorId );
@@ -144,6 +158,138 @@ class GroupClassController {
include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php';
}
/**
* When the request targets a single enrolment (`?enrollment_id=`), render its
* detail view the audit trail of what the student answered and agreed to,
* and, for an enrolment the studio made, the form to record intake collected
* elsewhere. Reports whether the page has been handled.
*
* `$onlyInstructorId` scopes it the way the pages themselves are scoped: an
* instructor may only open enrolments in their own classes, while the studio
* **Group Classes** page passes 0 and may open any.
*/
private function maybeRenderEnrollmentDetail( string $baseUrl, int $onlyInstructorId ): bool {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only enrolment selector.
$enrollmentId = absint( Val::int( $_GET['enrollment_id'] ?? 0 ) );
if ( $enrollmentId <= 0 ) {
return false;
}
$enrollment = $this->enrollments->findById( $enrollmentId );
$notice = '';
$error = '';
if ( null === $enrollment || ( $onlyInstructorId > 0 && $enrollment->instructorId !== $onlyInstructorId ) ) {
$row = null;
$answers = [];
$accepts = [];
$intake = $this->emptyIntake();
} else {
// Recorded before the tables are read, so what was just entered appears
// on the page that reports it.
[ $notice, $error ] = $this->recordIntake( $enrollment );
$row = $this->enrollmentRow( $enrollment );
$answers = $this->audit->answers( $enrollment );
$accepts = $this->audit->acceptances( $enrollment );
$intake = $this->intakeForm( $enrollment );
}
include USC_PLUGIN_DIR . 'templates/admin/enrollment-detail.php';
return true;
}
/**
* Who and what one enrolment is, for the head of its detail view.
*
* @return array{enrollment_id: int, student: string, class: string, instructor: string, status: string, payment: string}
*/
private function enrollmentRow( Enrollment $enrollment ): array {
$student = get_userdata( $enrollment->studentId );
$offering = $this->offerings->findById( $enrollment->offeringId );
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
return [
'enrollment_id' => (int) $enrollment->id,
'student' => UserName::format( $student instanceof \WP_User ? $student : null, $enrollment->studentId ),
'class' => null !== $offering ? $offering->title : '—',
'instructor' => null !== $offering ? $this->instructorName( $offering ) : '—',
'status' => $enrollment->status,
'payment' => null !== $payment ? $payment->status : '—',
];
}
/**
* Handle a submitted "record intake collected elsewhere" form.
*
* @return array{string, string} Success notice and error message.
*/
private function recordIntake( Enrollment $enrollment ): array {
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_group_action' ) ) {
return [ '', '' ];
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
if ( 'record_intake' !== sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
return [ '', '' ];
}
$answers = [];
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized below.
foreach ( (array) ( $_POST['answers'] ?? [] ) as $questionId => $value ) {
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each element is coerced to a positive int; slashes cannot survive integer coercion.
$rawVersionIds = (array) ( $_POST['accepted_policy_version_ids'] ?? [] );
$versionIds = array_values( array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $rawVersionIds ) ) );
$result = $this->intake->record(
$enrollment,
$answers,
$versionIds,
sanitize_key( Val::string( wp_unslash( $_POST['collected_via'] ?? '' ) ) ),
sanitize_text_field( Val::string( wp_unslash( $_POST['collected_note'] ?? '' ) ) ),
get_current_user_id()
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
return $result instanceof \WP_Error
? [ '', $result->get_error_message() ]
: [ $result, '' ];
}
/**
* What the detail template needs to offer the recording form: whether this
* enrolment qualifies at all, what is still missing, and the collection
* methods to choose between.
*
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
*/
private function intakeForm( Enrollment $enrollment ): array {
if ( ! $enrollment->isStaffRegistered() ) {
return $this->emptyIntake();
}
return [ 'recordable' => true ] + $this->intake->pending( $enrollment ) + [ 'methods' => IntakeProvenance::choices() ];
}
/**
* The form data for an enrolment that cannot be recorded against one the
* student made, or one that could not be opened at all.
*
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
*/
private function emptyIntake(): array {
return [
'recordable' => false,
'questions' => [],
'policies' => [],
'methods' => [],
];
}
/**
* Summary row for one class in the instructor overview: its identity, when it
* meets, and how many active enrolments it holds against capacity.
@@ -176,7 +322,7 @@ class GroupClassController {
* invite-only classes the list of people invited but not yet enrolled.
*
* @param list<Enrollment> $enrollments
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{id: int, student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
*/
private function classDetail( Offering $offering, array $enrollments ): array {
$roster = [];
@@ -189,6 +335,7 @@ class GroupClassController {
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
$roster[] = [
'id' => (int) $enrollment->id,
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
'status' => $enrollment->status,
'payment' => $payment?->status,
@@ -334,6 +481,10 @@ class GroupClassController {
offeringId: (int) $offering->id,
studentId: $studentId,
instructorId: $offering->instructorId,
// Stamped so this enrolment can be told apart later: only one the
// studio made may have its intake recorded after the fact, the
// student never having been asked the questions.
enrolledBy: get_current_user_id(),
)
);
@@ -489,7 +640,15 @@ class GroupClassController {
}
/**
* The de-duplicated positive student ids posted from a multi-select.
* The de-duplicated student ids posted from a multi-select, keeping only ids
* that are actually students.
*
* The select is built from {@see studentOptions()}, but nothing stops a posted
* id naming an instructor, an administrator, or an account deleted since the
* page was drawn and enrolling one would write a roster row, and bill it,
* against someone who is not in the class. Vetting here covers both actions at
* once, and against the same {@see RoleManager::isStudent()} the picker uses,
* so a child or an unapproved signup is still perfectly enrollable.
*
* @return list<int>
*/
@@ -499,7 +658,7 @@ class GroupClassController {
$raw = (array) ( $_POST['student_ids'] ?? [] );
$ids = array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $raw ) );
return array_values( array_unique( $ids ) );
return array_values( array_filter( array_unique( $ids ), RoleManager::isStudent( ... ) ) );
}
/**
+26 -3
View File
@@ -10,8 +10,8 @@ use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Val;
/**
* The guardian's "my family" screen (`[us_family]`): list, add, edit and remove
* the children they book for.
* The guardian's "my family" screen (`[us_family]`): their own details, plus
* list, add, edit and remove the children they book for.
*
* Submissions are processed on `template_redirect` before any output and
* post/redirect/get back to the page, so a refresh cannot resubmit and add the
@@ -23,6 +23,7 @@ class FamilyPage {
private const RESULT_ADDED = 'added';
private const RESULT_UPDATED = 'updated';
private const RESULT_REMOVED = 'removed';
private const RESULT_SELF = 'self';
/**
* Error from the most recent submission processed on `template_redirect`,
@@ -58,6 +59,7 @@ class FamilyPage {
$userId = get_current_user_id();
$self = $this->guardians->accountHolder( $userId );
$children = $this->guardians->children( $userId );
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$error = $this->submitError;
@@ -99,6 +101,7 @@ class FamilyPage {
'add' => $this->handleAdd( $userId ),
'edit' => $this->handleEdit( $userId ),
'remove' => $this->handleRemove( $userId ),
'self' => $this->handleSelf( $userId ),
default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ),
};
@@ -149,6 +152,21 @@ class FamilyPage {
return $error ?? self::RESULT_UPDATED;
}
/**
* Save the account holder's own details. The birth year is only asked of a
* student, so it is the checkbox not the browser that decides whether one
* is required; the field carries no `required` attribute, or a guardian who
* books only for other people could never submit the form at all.
*/
private function handleSelf( int $userId ): string|\WP_Error {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$isStudent = isset( $_POST['is_student'] );
$error = $this->guardians->updateSelf( $userId, $this->postString( 'own_name' ), $this->postString( 'own_birth_year' ), $isStudent );
return $error ?? self::RESULT_SELF;
}
private function handleRemove( int $guardianId ): string|\WP_Error {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
@@ -162,12 +180,16 @@ class FamilyPage {
* The first required question left unanswered, as the error to show or null
* when every required question has a value.
*
* This screen only ever adds a student the guardian registers, so the
* students' required-ness is the one that applies the same rule the child
* blocks on the signup form are held to.
*
* @param list<Question> $questions
* @param array<int, string> $answers question_id => submitted value
*/
private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error {
foreach ( $questions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
if ( $question->isRequiredForChild() && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this student.', 'unsupervised-schedular' ) );
}
}
@@ -240,6 +262,7 @@ class FamilyPage {
self::RESULT_ADDED => __( 'Student added.', 'unsupervised-schedular' ),
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
self::RESULT_REMOVED => __( 'Student removed.', 'unsupervised-schedular' ),
self::RESULT_SELF => __( 'Your details have been updated.', 'unsupervised-schedular' ),
default => '',
};
}
+66
View File
@@ -163,6 +163,52 @@ class GuardianService {
return null;
}
/**
* Update the account holder's own details from the profile screen: their
* name, whether they are a student in their own right, and when they are
* their birth year.
*
* `$isStudent` is the positive of what {@see META_GUARDIAN_ONLY} stores, so
* the form can ask the question the way a person would answer it and this is
* the single place the sense is flipped.
*
* Returns null on success, mirroring {@see updateChild()}.
*/
public function updateSelf( int $userId, string $name, string $birthYear, bool $isStudent ): ?\WP_Error {
$name = trim( $name );
if ( '' === $name ) {
return new \WP_Error( 'missing_name', __( 'Please give your name.', 'unsupervised-schedular' ) );
}
if ( $isStudent && 0 === self::normaliseBirthYear( $birthYear ) ) {
return new \WP_Error( 'missing_birth_year', self::ownBirthYearError() );
}
$result = wp_update_user(
[
'ID' => $userId,
'display_name' => $name,
'nickname' => $name,
]
);
if ( is_wp_error( $result ) ) {
return $result;
}
$this->setGuardianOnly( $userId, ! $isStudent );
// Only written when they are a student. Saying "I only book for other
// people" is a statement about who books, not an instruction to forget a
// year already on file — and someone who ticks the box back on the next
// visit should find their own details as they left them.
if ( $isStudent ) {
$this->setBirthYear( $userId, $birthYear );
}
return null;
}
/**
* Unlink a child and delete their account. Refused once the child has any
* lesson or enrolment history: their id is referenced by lessons, payments and
@@ -319,6 +365,26 @@ class GuardianService {
return $out;
}
/**
* The account holder's own details, as the profile screen's form needs them.
* The counterpart to {@see children()} for the person reading the page.
*
* `is_student` is the positive of {@see META_GUARDIAN_ONLY} see
* {@see updateSelf()}, which reads it back the same way round.
*
* @return array{name: string, email: string, birth_year: string, is_student: bool}
*/
public function accountHolder( int $userId ): array {
$user = get_userdata( $userId );
return [
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $userId ),
'email' => $user instanceof \WP_User ? $user->user_email : '',
'birth_year' => $this->birthYear( $userId ),
'is_student' => ! self::isGuardianOnly( $userId ),
];
}
/**
* The guardian behind a child, or null when the student books for themselves.
*
+12 -5
View File
@@ -7,8 +7,8 @@ use Unsupervised\Schedular\Val;
/**
* Resolves the billing method for a student: a per-student override if set,
* otherwise the studio default card when Stripe is configured, e-transfer when
* it is not.
* otherwise the studio default chosen on Studio Settings which itself falls
* back to e-transfer whenever Stripe is not configured.
*/
class BillingMethodResolver {
@@ -27,10 +27,17 @@ class BillingMethodResolver {
/**
* The studio default when a student has no explicit override.
*
* Card is only ever the default when the studio asked for it *and* Stripe is
* configured; without keys there is nothing to charge a card with. A studio
* that sets the default to e-transfer keeps every student on e-transfer even
* with Stripe live, so card billing can be proven on a few students each
* given a per-student override before the whole studio moves over.
*/
public function defaultMethod(): string {
return $this->settings->isStripeConfigured()
? Payment::METHOD_CARD
: Payment::METHOD_ETRANSFER;
return Payment::METHOD_CARD === $this->settings->defaultPaymentMethod()
&& $this->settings->isStripeConfigured()
? Payment::METHOD_CARD
: Payment::METHOD_ETRANSFER;
}
}
+50 -1
View File
@@ -16,6 +16,14 @@ class StudioSettings {
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
public const OPT_HST_RATE = 'us_hst_rate';
/**
* The studio-wide default billing method for students with no per-student
* override. Card is the default; setting it to e-transfer holds every student
* on e-transfer even once Stripe is live, so card billing can be trialled on a
* few students before the whole studio moves over.
*/
public const OPT_DEFAULT_PAYMENT_METHOD = 'us_default_payment_method';
/**
* Studio-default cancellation cutoff, stored in hours. A student may not
* cancel a lesson once it starts within this many hours. Displayed to the
@@ -56,6 +64,17 @@ class StudioSettings {
return 'live' === get_option( self::OPT_MODE, 'test' ) ? 'live' : 'test';
}
/**
* The studio-default billing method: `card` or `etransfer`. A card default
* still degrades to e-transfer while Stripe is unconfigured see
* BillingMethodResolver, which owns that fallback.
*/
public function defaultPaymentMethod(): string {
return Payment::METHOD_ETRANSFER === get_option( self::OPT_DEFAULT_PAYMENT_METHOD, Payment::METHOD_CARD )
? Payment::METHOD_ETRANSFER
: Payment::METHOD_CARD;
}
public function currency(): string {
$currency = Val::string( get_option( self::OPT_CURRENCY, 'CAD' ) );
@@ -112,13 +131,32 @@ class StudioSettings {
return self::MODE_SELF_APPROVAL === $this->registrationMode();
}
/**
* Forget every Stripe credential, returning the studio to e-transfer billing.
* The mode drops back to `test` so a later re-configuration cannot go live by
* inheriting the old setting.
*/
public function clearStripeConfig(): void {
delete_option( self::OPT_PUBLISHABLE );
delete_option( self::OPT_SECRET );
delete_option( self::OPT_WEBHOOK_SECRET );
delete_option( self::OPT_MODE );
}
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) {
wp_die( esc_html__( 'You do not have permission to manage billing settings.', 'unsupervised-schedular' ) );
}
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_settings_action' ) ) {
$this->save();
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified immediately above.
if ( 'clear_stripe' === sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
$this->clearStripeConfig();
$notice = __( 'Stripe configuration cleared. New registrations default to e-transfer until Stripe is set up again.', 'unsupervised-schedular' );
} else {
$this->save();
}
}
$publishableKey = $this->publishableKey();
@@ -133,6 +171,10 @@ class StudioSettings {
$etransferEmail = $this->etransferEmail();
$hstRate = $this->hstRate();
$stripeConfigured = $this->isStripeConfigured();
$defaultMethod = $this->defaultPaymentMethod();
// Offer the clear button whenever any Stripe value lingers, not only when
// the pair of keys makes Stripe fully usable.
$stripeAnySet = '' !== $publishableKey || $secretKeySet || $webhookSecretSet;
$openRegistration = $this->openRegistrationEnabled();
// Stored in hours, surfaced to the admin in whole days.
$cancellationCutoffDays = (int) round( $this->cancellationCutoffHours() / 24 );
@@ -158,6 +200,13 @@ class StudioSettings {
update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' );
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( Val::string( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) ) );
update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) );
// Anything but an explicit e-transfer choice means card, so a mangled or
// missing field can never silently disable card billing studio-wide.
$defaultMethod = sanitize_key( Val::string( wp_unslash( $_POST['default_payment_method'] ?? Payment::METHOD_CARD ) ) );
update_option(
self::OPT_DEFAULT_PAYMENT_METHOD,
Payment::METHOD_ETRANSFER === $defaultMethod ? Payment::METHOD_ETRANSFER : Payment::METHOD_CARD
);
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion.
$hstRate = isset( $_POST['hst_rate'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0;
update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) );
+16 -2
View File
@@ -16,6 +16,7 @@ use Unsupervised\Schedular\Auth\StudentAdminGuard;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonBooker;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
@@ -72,6 +73,15 @@ class Plugin {
update_option( 'us_questions_offering_nullable', '1' );
}
// One-time backfill of us_questions.is_required_child, which dbDelta adds
// defaulting to 0 — leaving every question that *was* required no longer
// required of the students a guardian registers. Runs after the version
// gate above, so the column it writes to exists. Guarded so a question
// later made optional for students stays that way.
if ( '1' !== get_option( 'us_questions_child_required_backfilled', '' ) && $questions->backfillChildRequired() ) {
update_option( 'us_questions_child_required_backfilled', '1' );
}
$answers = new AnswerRepository( $wpdb );
$policies = new PolicyRepository( $wpdb );
$policyVersions = new PolicyVersionRepository( $wpdb );
@@ -92,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();
@@ -112,8 +126,8 @@ class Plugin {
( new StudentAdminGuard() )->register();
( new DeletedUserCleanup( $bookings, $availability, $enrollments, $paymentService, $guardianRepo, $guardians ) )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians, $lessonBooker, $registrationGate ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians, $lessonBooker ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
}
+4 -1
View File
@@ -21,9 +21,12 @@ class AcceptanceRepository {
'registration_type' => $acceptance->registrationType,
'registration_id' => $acceptance->registrationId,
'ip_address' => $acceptance->ipAddress,
'collected_via' => $acceptance->collectedVia,
'collected_note' => $acceptance->collectedNote,
'recorded_by' => $acceptance->recordedBy,
'accepted_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%d', '%s', '%d', '%s', '%s' ]
[ '%d', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
+19
View File
@@ -32,6 +32,19 @@ class PolicyAcceptance {
*/
public readonly int $acceptedBy = 0,
public readonly ?string $ipAddress = null,
/**
* How this acceptance reached the studio when it was not given online see
* {@see \Unsupervised\Schedular\Registration\IntakeProvenance}. Null is the
* ordinary case: the student ticked the box themselves.
*/
public readonly ?string $collectedVia = null,
public readonly ?string $collectedNote = null,
/**
* The staff member who typed it in, when somebody did. Distinct from
* `acceptedBy`: the student still agreed, on paper or over the phone this
* is only who entered the record of it.
*/
public readonly int $recordedBy = 0,
public readonly ?string $acceptedAt = null,
public readonly ?int $id = null,
) {}
@@ -44,6 +57,9 @@ class PolicyAcceptance {
registrationId: Val::int( $row->registration_id ),
acceptedBy: Val::int( $row->accepted_by ?? 0 ),
ipAddress: Val::stringOrNull( $row->ip_address ),
collectedVia: Val::stringOrNull( $row->collected_via ?? null ),
collectedNote: Val::stringOrNull( $row->collected_note ?? null ),
recordedBy: Val::int( $row->recorded_by ?? 0 ),
acceptedAt: Val::stringOrNull( $row->accepted_at ),
id: Val::int( $row->id ),
);
@@ -81,6 +97,9 @@ class PolicyAcceptance {
'registration_type' => $this->registrationType,
'registration_id' => $this->registrationId,
'ip_address' => $this->ipAddress,
'collected_via' => $this->collectedVia,
'collected_note' => $this->collectedNote,
'recorded_by' => $this->recordedBy,
'accepted_at' => $this->acceptedAt,
];
}
+15
View File
@@ -25,6 +25,15 @@ class Answer {
public readonly int $registrationId,
public readonly int $studentId,
public readonly ?string $answerValue = null,
/**
* How this answer reached the studio when it did not come from the booking
* form see {@see IntakeProvenance}. Null is the ordinary case: the student
* typed it in themselves.
*/
public readonly ?string $collectedVia = null,
public readonly ?string $collectedNote = null,
/** The staff member who typed it in, when somebody did. */
public readonly int $recordedBy = 0,
public readonly ?int $id = null,
) {}
@@ -35,6 +44,9 @@ class Answer {
registrationId: Val::int( $row->registration_id ),
studentId: Val::int( $row->student_id ),
answerValue: Val::stringOrNull( $row->answer_value ),
collectedVia: Val::stringOrNull( $row->collected_via ?? null ),
collectedNote: Val::stringOrNull( $row->collected_note ?? null ),
recordedBy: Val::int( $row->recorded_by ?? 0 ),
id: Val::int( $row->id ),
);
}
@@ -52,6 +64,9 @@ class Answer {
'registration_id' => $this->registrationId,
'student_id' => $this->studentId,
'answer_value' => $this->answerValue,
'collected_via' => $this->collectedVia,
'collected_note' => $this->collectedNote,
'recorded_by' => $this->recordedBy,
];
}
}
+4 -1
View File
@@ -20,9 +20,12 @@ class AnswerRepository {
'registration_id' => $answer->registrationId,
'student_id' => $answer->studentId,
'answer_value' => $answer->answerValue,
'collected_via' => $answer->collectedVia,
'collected_note' => $answer->collectedNote,
'recorded_by' => $answer->recordedBy,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%s', '%d', '%d', '%s', '%s' ]
[ '%d', '%s', '%d', '%d', '%s', '%s', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
/**
* Builds the display rows for one registration's audit trail: the intake answers
* the student gave and the policy versions they accepted. Used by the admin
* lesson detail view and the group-class enrolment detail view alike, mirroring
* the per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
*
* Which rows belong to the registration is the subject's own business
* ({@see IntakeSubject::intakeRegistrationId()}) notably, a weekly lesson
* series is answered for and agreed to once, against its anchor, so every
* occurrence reads the same trail rather than only the first looking answered.
*/
class IntakeAudit {
public function __construct(
private AnswerRepository $answers,
private QuestionRepository $questions,
private AcceptanceRepository $acceptances,
private PolicyRepository $policies,
private PolicyVersionRepository $versions,
) {}
/**
* The intake-question answers recorded for this registration, in submission
* order.
*
* @return list<array{question: string, answer: string, source: string}>
*/
public function answers( IntakeSubject $subject ): array {
return array_map(
function ( Answer $answer ): array {
$question = $this->questions->findById( $answer->questionId );
$value = $answer->answerValue ?? '';
return [
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
'answer' => '' === $value ? '—' : $value,
'source' => $this->source( $answer->collectedVia, $answer->collectedNote, $answer->recordedBy ),
];
},
$this->answers->findByRegistration( $subject->intakeRegistrationType(), $subject->intakeRegistrationId() )
);
}
/**
* Where a recorded row came from: given online by the student, or collected
* some other way and typed in in which case who typed it is named, since an
* unattributed transcription is worth much less than an attributed one.
*/
private function source( ?string $collectedVia, ?string $collectedNote, int $recordedBy ): string {
$described = IntakeProvenance::describe( $collectedVia, $collectedNote );
if ( null === $collectedVia || '' === $collectedVia || $recordedBy <= 0 ) {
return $described;
}
$user = get_userdata( $recordedBy );
return sprintf(
/* translators: 1: how the answer was collected, 2: name of the staff member who recorded it. */
__( '%1$s — recorded by %2$s', 'unsupervised-schedular' ),
$described,
UserName::format( $user instanceof \WP_User ? $user : null, $recordedBy )
);
}
/**
* The policy versions the student accepted for this registration, with the
* captured acceptance time and IP for the audit trail.
*
* @return list<array{policy: string, version: string, accepted_at: string, ip: string, source: string}>
*/
public function acceptances( IntakeSubject $subject ): array {
return array_map(
function ( PolicyAcceptance $acceptance ): array {
$version = $this->versions->findById( $acceptance->policyVersionId );
$policy = $version ? $this->policies->findById( $version->policyId ) : null;
return [
'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ),
'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
'accepted_at' => $acceptance->acceptedAt ?? '',
'ip' => $acceptance->ipAddress ?? '',
'source' => $this->source( $acceptance->collectedVia, $acceptance->collectedNote, $acceptance->recordedBy ),
];
},
$this->acceptances->findByRegistration( $subject->intakeRegistrationType(), $subject->intakeRegistrationId() )
);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
/**
* Where an intake answer or policy acceptance came from, when it did not come
* from the student filling in the booking form.
*
* A lesson the studio booked on someone's behalf has no answers and no
* acceptances nobody was at a keyboard to give them so they are collected
* some other way and typed in afterwards. What makes that record worth keeping
* is knowing *how*: "accepted on 24 Aug" means one thing when a student ticked a
* box and quite another when a staff member read it off a signed form, and an
* audit trail that cannot tell them apart is worse than no audit trail, because
* it looks like one.
*
* Absent (null) provenance is therefore meaningful in its own right: it is the
* ordinary case of the student answering online.
*/
class IntakeProvenance {
public const VIA_PAPER = 'paper';
public const VIA_IN_PERSON = 'in_person';
public const VIA_PHONE = 'phone';
public const VIA_EMAIL = 'email';
public const VIA_OTHER = 'other';
/**
* How the answers can have reached the studio. `other` exists so the list
* never forces a lie, and is the one option that must be explained.
*
* @var list<string>
*/
public const VALID_METHODS = [ self::VIA_PAPER, self::VIA_IN_PERSON, self::VIA_PHONE, self::VIA_EMAIL, self::VIA_OTHER ];
/** Longest note the `collected_note` VARCHAR(191) column holds. */
public const MAX_NOTE_LENGTH = 191;
public function __construct(
public readonly string $collectedVia,
public readonly ?string $collectedNote = null,
public readonly int $recordedBy = 0,
) {}
/**
* Build from submitted values, or explain what is wrong with them. A method
* outside the vocabulary is rejected rather than stored: a column that can say
* anything says nothing. `other` requires the note, since "other" on its own
* answers the question with the question.
*/
public static function fromInput( string $collectedVia, string $collectedNote, int $recordedBy ): self|\WP_Error {
if ( ! in_array( $collectedVia, self::VALID_METHODS, true ) ) {
return new \WP_Error( 'invalid_collection_method', __( 'Choose how these were collected.', 'unsupervised-schedular' ) );
}
$note = trim( $collectedNote );
if ( self::VIA_OTHER === $collectedVia && '' === $note ) {
return new \WP_Error( 'collection_note_required', __( 'Say how these were collected.', 'unsupervised-schedular' ) );
}
return new self(
collectedVia: $collectedVia,
collectedNote: '' !== $note ? mb_substr( $note, 0, self::MAX_NOTE_LENGTH ) : null,
recordedBy: $recordedBy,
);
}
/**
* The methods as `value => label`, for the form's picker and for reading a
* stored value back on screen.
*
* @return array<string, string>
*/
public static function choices(): array {
return [
self::VIA_PAPER => __( 'On a signed paper form', 'unsupervised-schedular' ),
self::VIA_IN_PERSON => __( 'In person', 'unsupervised-schedular' ),
self::VIA_PHONE => __( 'Over the phone', 'unsupervised-schedular' ),
self::VIA_EMAIL => __( 'By email', 'unsupervised-schedular' ),
self::VIA_OTHER => __( 'Some other way', 'unsupervised-schedular' ),
];
}
/**
* How a stored row reads on screen: the method's label, plus its note. An
* empty method is the ordinary case the student answered online and says
* so rather than showing a blank cell.
*/
public static function describe( ?string $collectedVia, ?string $collectedNote = null ): string {
if ( null === $collectedVia || '' === $collectedVia ) {
return __( 'Given online when booking', 'unsupervised-schedular' );
}
$label = self::choices()[ $collectedVia ] ?? $collectedVia;
$note = null !== $collectedNote ? trim( $collectedNote ) : '';
return '' !== $note ? $label . ' — ' . $note : $label;
}
}
+194
View File
@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
/**
* Recording, after the fact, the intake answers and policy acceptances of a
* registration the studio made on a student's behalf a lesson booked from the
* Scheduler, a student added straight into a group class.
*
* Such a registration has neither: nobody was at a keyboard to answer the
* questions or tick the boxes, and staff doing it *for* the student at the time
* would be an audit trail that says something untrue. The answers are instead
* collected some other way a paper form, a phone call and typed in here, each
* row stamped with how it was obtained ({@see IntakeProvenance}), so a reader can
* always tell a student's own click from a studio's transcription.
*
* Two rules hold this honest:
*
* 1. **Only a staff-made registration qualifies**
* ({@see IntakeSubject::isStaffRegistered()}). One the student made already
* has their real answers, and letting staff add more would let the record be
* edited after the fact.
* 2. **Only what is still missing can be recorded.** Answers and acceptances are
* written once and never overwritten, so a second submission cannot quietly
* replace what a student actually said.
*/
class IntakeRecording {
public function __construct(
private QuestionRepository $questions,
private AnswerRepository $answers,
private PolicyRepository $policies,
private PolicyVersionRepository $versions,
private AcceptanceRepository $acceptances,
private RegistrationGate $gate,
) {}
/**
* What is still unrecorded for this registration: the intake questions with no
* answer, and the current policy versions with no acceptance. An empty pair
* means there is nothing left to collect and the form has nothing to show.
*
* @return array{questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>}
*/
public function pending( IntakeSubject $subject ): array {
$type = $subject->intakeRegistrationType();
$registrationId = $subject->intakeRegistrationId();
$answered = array_map(
static fn( Answer $a ): int => $a->questionId,
$this->answers->findByRegistration( $type, $registrationId )
);
$accepted = array_map(
static fn( PolicyAcceptance $a ): int => $a->policyVersionId,
$this->acceptances->findByRegistration( $type, $registrationId )
);
$questions = [];
foreach ( $this->questions->findByOffering( $subject->intakeOfferingId(), true ) as $question ) {
if ( in_array( (int) $question->id, $answered, true ) ) {
continue;
}
$questions[] = [
'id' => (int) $question->id,
'label' => $question->label,
'required' => $this->isRequiredOf( $question ),
];
}
$policies = [];
foreach ( $this->gate->requiredPolicyVersionIds() as $versionId ) {
if ( in_array( $versionId, $accepted, true ) ) {
continue;
}
$version = $this->versions->findById( $versionId );
$policy = null !== $version ? $this->policies->findById( $version->policyId ) : null;
$policies[] = [
'version_id' => $versionId,
'policy' => null !== $policy ? $policy->title : sprintf( '#%d', $versionId ),
'version' => null !== $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
];
}
return [
'questions' => $questions,
'policies' => $policies,
];
}
/**
* Record what the studio collected elsewhere, returning the notice to show.
*
* Submitted answers and acceptances are narrowed to what is actually still
* pending before anything is written, so a stale form reloaded, or posted
* twice can neither duplicate a row nor overwrite one.
*
* @param array<int, string> $answers question_id => answer value
* @param list<int> $versionIds Policy version ids being accepted
*
* @return string|\WP_Error
*/
public function record( IntakeSubject $subject, array $answers, array $versionIds, string $collectedVia, string $collectedNote, int $recordedBy ): string|\WP_Error {
if ( ! $subject->isStaffRegistered() ) {
return new \WP_Error(
'not_recordable',
__( 'Intake can only be recorded for a registration the studio made on the student\'s behalf.', 'unsupervised-schedular' )
);
}
$provenance = IntakeProvenance::fromInput( $collectedVia, $collectedNote, $recordedBy );
if ( $provenance instanceof \WP_Error ) {
return $provenance;
}
$pending = $this->pending( $subject );
$pendingQuestionIds = array_map( static fn( array $q ): int => $q['id'], $pending['questions'] );
$pendingVersionIds = array_map( static fn( array $p ): int => $p['version_id'], $pending['policies'] );
$newAnswers = [];
foreach ( $answers as $questionId => $value ) {
$value = trim( $value );
if ( '' !== $value && in_array( (int) $questionId, $pendingQuestionIds, true ) ) {
$newAnswers[ (int) $questionId ] = $value;
}
}
$newVersionIds = array_values( array_intersect( $versionIds, $pendingVersionIds ) );
if ( [] === $newAnswers && [] === $newVersionIds ) {
return new \WP_Error( 'nothing_to_record', __( 'Nothing was filled in to record.', 'unsupervised-schedular' ) );
}
// No IP address is passed: the student was not at a browser, and borrowing
// the staff member's would put a false location in the audit trail. Nor is
// an acceptor — the student agreed, on paper or over the phone; who typed it
// in is `recorded_by`, which the provenance carries.
$this->gate->record(
$subject->intakeRegistrationType(),
$subject->intakeRegistrationId(),
$subject->intakeStudentId(),
$subject->intakeOfferingId(),
$newAnswers,
$newVersionIds,
null,
0,
$provenance
);
return $this->notice( count( $newAnswers ), count( $newVersionIds ), $provenance );
}
/** What was written, and how it was said to have been collected. */
private function notice( int $answers, int $acceptances, IntakeProvenance $provenance ): string {
$parts = [];
if ( $answers > 0 ) {
/* translators: %d: number of intake answers recorded. */
$parts[] = sprintf( _n( '%d answer', '%d answers', $answers, 'unsupervised-schedular' ), $answers );
}
if ( $acceptances > 0 ) {
/* translators: %d: number of policy acceptances recorded. */
$parts[] = sprintf( _n( '%d policy acceptance', '%d policy acceptances', $acceptances, 'unsupervised-schedular' ), $acceptances );
}
return sprintf(
/* translators: 1: what was recorded, e.g. "2 answers and 1 policy acceptance", 2: how they were collected. */
__( 'Recorded %1$s, collected: %2$s', 'unsupervised-schedular' ),
implode( __( ' and ', 'unsupervised-schedular' ), $parts ),
IntakeProvenance::describe( $provenance->collectedVia, $provenance->collectedNote )
);
}
/**
* Whether the question is one the booking form would have insisted on, of
* either audience. It is shown as a hint only a studio that has half the
* answers should be able to record the half it has, rather than being made to
* invent the rest to get the form to submit.
*/
private function isRequiredOf( Question $question ): bool {
return $question->isRequired || $question->isRequiredChild;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
/**
* A registration that intake answers and policy acceptances hang off: a booked
* lesson, or a group-class enrolment.
*
* The two are different enough to keep their own tables and their own booking
* flows, but identical in this one respect somebody registered, questions were
* (or were not) answered, policies were (or were not) agreed to and the rules
* for reading and recording that are not worth writing twice. Everything about
* intake works against this interface rather than either model.
*
* The `intake` prefix is not decoration: both implementations already carry
* `$studentId` and `$offeringId` properties, and PHP 8.1 has no way to declare a
* property on an interface, so the accessors need names of their own.
*/
interface IntakeSubject {
/**
* Which polymorphic registration table this is `Answer::REG_LESSON` or
* `Answer::REG_ENROLLMENT`, matching the acceptance constants of the same
* names.
*/
public function intakeRegistrationType(): string;
/**
* The id answers and acceptances are stored against. Not always the row's own
* id: a weekly lesson series is answered for once, against its anchor, so
* every occurrence reads and writes the same registration.
*/
public function intakeRegistrationId(): int;
/** The offering whose questions apply. */
public function intakeOfferingId(): int;
/** Who the answers and acceptances belong to. */
public function intakeStudentId(): int;
/**
* Whether the studio registered this on the student's behalf, rather than the
* student (or their guardian) doing it themselves. Only these can have their
* intake recorded after the fact: one the student made already holds their own
* answers, and adding to those would make the record editable after the event.
*/
public function isStaffRegistered(): bool;
}
+86 -18
View File
@@ -21,6 +21,16 @@ class Question {
/** Question is studio-wide, asked once at account signup (no offering). */
public const SCOPE_ACCOUNT = 'account';
/** Asked of everyone: the account holder as a student, and each student they register. */
public const AUDIENCE_ALL = 'all';
/**
* Asked only of the students someone registers on behalf of never of the
* account holder's own "About you" panel. For the questions that only make
* sense about a child ("school and grade", "who may collect them").
*/
public const AUDIENCE_CHILD = 'child';
/**
* All valid field types.
*
@@ -43,9 +53,29 @@ class Question {
self::SCOPE_ACCOUNT,
];
/**
* All valid audiences.
*
* @var list<string>
*/
public const VALID_AUDIENCES = [
self::AUDIENCE_ALL,
self::AUDIENCE_CHILD,
];
/**
* Build an intake question value object.
*
* `$isRequired` and `$isRequiredChild` are deliberately separate: a studio may
* want an answer from every student it enrols without demanding the same of an
* adult signing themselves up. Read them through {@see isRequiredForSelf()} and
* {@see isRequiredForChild()} rather than directly, so the audience is applied
* with them.
*
* Both `$audience` and `$isRequiredChild` are meaningless for offering scope,
* where a booking asks its questions once about the student being booked and
* there is no separate account-holder form to differ from.
*
* @param int|null $offeringId The owning offering, or null for account-scoped questions.
* @param list<string>|null $options Choices for a `select` field.
*/
@@ -58,9 +88,36 @@ class Question {
public readonly int $sortOrder = 0,
public readonly bool $isActive = true,
public readonly string $scope = self::SCOPE_OFFERING,
public readonly string $audience = self::AUDIENCE_ALL,
public readonly bool $isRequiredChild = false,
public readonly ?int $id = null,
) {}
/**
* Whether the account holder is asked this question in their own right true
* for everything except a child-audience question.
*/
public function askedOfSelf(): bool {
return self::AUDIENCE_CHILD !== $this->audience;
}
/**
* Whether the account holder must answer before the form will submit. A
* child-audience question never reaches them, so it can never block them.
*/
public function isRequiredForSelf(): bool {
return $this->isRequired && $this->askedOfSelf();
}
/**
* Whether each student being registered must answer before the form will
* submit. Every question is asked in the student blocks whatever its audience,
* so this stands on its own.
*/
public function isRequiredForChild(): bool {
return $this->isRequiredChild;
}
public static function fromRow( \stdClass $row ): self {
$options = null;
if ( null !== $row->options && '' !== $row->options ) {
@@ -70,16 +127,25 @@ class Question {
: null;
}
// `audience` and `is_required_child` arrived after the table did, so a row
// read on a site whose dbDelta has not run yet simply lacks them: the
// pre-existing behaviour (asked of everyone, required of nobody in
// particular) is the right reading of a question authored before the
// distinction existed.
$audience = Val::string( $row->audience ?? '' );
return new self(
offeringId: Val::intOrNull( $row->offering_id ),
label: Val::string( $row->label ),
fieldType: Val::string( $row->field_type ),
options: $options,
isRequired: Val::bool( $row->is_required ),
sortOrder: Val::int( $row->sort_order ),
isActive: Val::bool( $row->is_active ),
scope: Val::string( $row->scope ),
id: Val::int( $row->id ),
offeringId: Val::intOrNull( $row->offering_id ),
label: Val::string( $row->label ),
fieldType: Val::string( $row->field_type ),
options: $options,
isRequired: Val::bool( $row->is_required ),
sortOrder: Val::int( $row->sort_order ),
isActive: Val::bool( $row->is_active ),
scope: Val::string( $row->scope ),
audience: in_array( $audience, self::VALID_AUDIENCES, true ) ? $audience : self::AUDIENCE_ALL,
isRequiredChild: Val::bool( $row->is_required_child ?? false ),
id: Val::int( $row->id ),
);
}
@@ -90,15 +156,17 @@ class Question {
*/
public function toArray(): array {
return [
'id' => $this->id,
'offering_id' => $this->offeringId,
'scope' => $this->scope,
'label' => $this->label,
'field_type' => $this->fieldType,
'options' => $this->options,
'is_required' => $this->isRequired,
'sort_order' => $this->sortOrder,
'is_active' => $this->isActive,
'id' => $this->id,
'offering_id' => $this->offeringId,
'scope' => $this->scope,
'label' => $this->label,
'field_type' => $this->fieldType,
'options' => $this->options,
'audience' => $this->audience,
'is_required' => $this->isRequired,
'is_required_child' => $this->isRequiredChild,
'sort_order' => $this->sortOrder,
'is_active' => $this->isActive,
];
}
}
+17 -7
View File
@@ -89,15 +89,25 @@ class QuestionController {
return;
}
// Audience and the students' own required-ness are asked for on the
// account-scope form only; an offering's questions are answered once about
// the student being booked, so there is no second audience to differ from.
// An offering question therefore mirrors its single "required" into both
// columns rather than storing a distinction it does not have.
$accountScope = null === $offering;
$audience = sanitize_key( Val::string( wp_unslash( $_POST['audience'] ?? '' ) ) );
$this->questions->insert(
new Question(
offeringId: null === $offering ? null : (int) $offering->id,
label: $label,
fieldType: $fieldType,
options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ),
isRequired: isset( $_POST['is_required'] ),
sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ),
scope: null === $offering ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING,
offeringId: $accountScope ? null : (int) $offering->id,
label: $label,
fieldType: $fieldType,
options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ),
isRequired: isset( $_POST['is_required'] ),
sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ),
scope: $accountScope ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING,
audience: $accountScope && in_array( $audience, Question::VALID_AUDIENCES, true ) ? $audience : Question::AUDIENCE_ALL,
isRequiredChild: $accountScope ? isset( $_POST['is_required_child'] ) : isset( $_POST['is_required'] ),
)
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
+30 -16
View File
@@ -88,14 +88,21 @@ class QuestionEndpoint {
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
}
$isRequired = (bool) $request->get_param( 'is_required' );
$question = new Question(
offeringId: $offeringId,
label: $label,
fieldType: $fieldType,
options: $this->sanitizeOptions( $request->get_param( 'options' ) ),
isRequired: (bool) $request->get_param( 'is_required' ),
sortOrder: Val::int( $request->get_param( 'sort_order' ) ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
offeringId: $offeringId,
label: $label,
fieldType: $fieldType,
options: $this->sanitizeOptions( $request->get_param( 'options' ) ),
isRequired: $isRequired,
sortOrder: Val::int( $request->get_param( 'sort_order' ) ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
// An offering asks its questions once, about the student being booked,
// so there is no second audience to differ from: the single "required"
// stands for both, the same way the upgrade backfill left every
// question authored before the two could differ.
isRequiredChild: $isRequired,
);
$id = $this->questions->insert( $question );
@@ -129,16 +136,23 @@ class QuestionEndpoint {
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
}
// Only offering-scope questions reach here — an account-scope one has no
// offering to own it and is turned away as not found above — so the same
// single "required" applies to everyone asked. See create().
$isRequired = $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired;
$question = new Question(
offeringId: $existing->offeringId,
label: $label,
fieldType: $fieldType,
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired,
sortOrder: $request->has_param( 'sort_order' ) ? Val::int( $request->get_param( 'sort_order' ) ) : $existing->sortOrder,
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
scope: $existing->scope,
id: $id,
offeringId: $existing->offeringId,
label: $label,
fieldType: $fieldType,
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
isRequired: $isRequired,
sortOrder: $request->has_param( 'sort_order' ) ? Val::int( $request->get_param( 'sort_order' ) ) : $existing->sortOrder,
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
scope: $existing->scope,
audience: $existing->audience,
isRequiredChild: $isRequired,
id: $id,
);
$this->questions->update( $id, $question );
+9 -3
View File
@@ -21,12 +21,18 @@ class QuestionField {
* the HTML attribute, for a block the browser must not block submission on
* because it may not apply at all the child blocks, which only count when
* the parent/guardian box is ticked. The server validates those either way.
*
* `$isRequired` overrides which of the question's two required flags applies
* here a question can be optional for the account holder and required for
* each student they register, and only the caller knows which block this is.
* Null falls back to the question's own {@see Question::$isRequired}.
*/
public static function render( Question $question, string $name, string $id, bool $enforceRequired = true ): string {
$required = $question->isRequired && $enforceRequired ? ' required' : '';
public static function render( Question $question, string $name, string $id, bool $enforceRequired = true, ?bool $isRequired = null ): string {
$mustAnswer = $isRequired ?? $question->isRequired;
$required = $mustAnswer && $enforceRequired ? ' required' : '';
$label = '<label for="' . esc_attr( $id ) . '">' . esc_html( $question->label )
. ( $question->isRequired ? ' <span class="us-required" aria-hidden="true">*</span>' : '' )
. ( $mustAnswer ? ' <span class="us-required" aria-hidden="true">*</span>' : '' )
. '</label>';
return '<p>' . $label . self::input( $question, $name, $id, $required ) . '</p>';
+38 -10
View File
@@ -15,7 +15,7 @@ class QuestionRepository {
$this->db->insert(
$this->table,
$this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ],
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ]
[ '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s' ]
);
return $this->db->insert_id;
@@ -26,7 +26,7 @@ class QuestionRepository {
$this->table,
$this->columns( $question ),
[ 'id' => $id ],
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d' ],
[ '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d' ],
[ '%d' ]
);
}
@@ -38,14 +38,16 @@ class QuestionRepository {
*/
private function columns( Question $question ): array {
return [
'offering_id' => $question->offeringId,
'scope' => $question->scope,
'label' => $question->label,
'field_type' => $question->fieldType,
'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ),
'is_required' => $question->isRequired ? 1 : 0,
'sort_order' => $question->sortOrder,
'is_active' => $question->isActive ? 1 : 0,
'offering_id' => $question->offeringId,
'scope' => $question->scope,
'label' => $question->label,
'field_type' => $question->fieldType,
'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ),
'audience' => $question->audience,
'is_required' => $question->isRequired ? 1 : 0,
'is_required_child' => $question->isRequiredChild ? 1 : 0,
'sort_order' => $question->sortOrder,
'is_active' => $question->isActive ? 1 : 0,
];
}
@@ -128,4 +130,30 @@ class QuestionRepository {
return null !== $sql && false !== $this->db->query( $sql );
}
/**
* Give every question authored before students had a required-ness of their
* own the one it used to have.
*
* `is_required_child` arrives with `DEFAULT 0`, so without this a question the
* studio had marked required would quietly stop being required of the students
* a guardian registers the case it most likely existed for. Copying
* `is_required` across preserves exactly the old behaviour: required of
* everyone, or of nobody.
*
* Run once, guarded by an option in {@see \Unsupervised\Schedular\Plugin::boot()},
* so a question deliberately made optional for students afterwards is not
* quietly made required again.
*
* @return bool True when the statement ran, false if it could not be prepared
* or the query failed.
*/
public function backfillChildRequired(): bool {
$sql = $this->db->prepare(
'UPDATE %i SET is_required_child = 1 WHERE is_required = 1',
$this->table
);
return null !== $sql && false !== $this->db->query( $sql );
}
}
+15 -3
View File
@@ -62,10 +62,14 @@ class RegistrationGate {
* guardian booking for a child. It defaults to 0, read back as "the student
* agreed for themselves".
*
* `$provenance` marks answers the studio collected some other way and typed in
* afterwards; null (the default) is the ordinary case of the student giving
* them online. See {@see IntakeProvenance}.
*
* @param array<int, string> $answers question_id => answer value
* @param list<int> $acceptedVersionIds Accepted policy version IDs
*/
public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null, int $acceptedBy = 0 ): void {
public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null, int $acceptedBy = 0, ?IntakeProvenance $provenance = null ): void {
foreach ( $this->questions->findByOffering( $offeringId, true ) as $question ) {
$value = (string) ( $answers[ (int) $question->id ] ?? '' );
if ( '' === $value ) {
@@ -79,6 +83,9 @@ class RegistrationGate {
registrationId: $registrationId,
studentId: $studentId,
answerValue: $value,
collectedVia: $provenance?->collectedVia,
collectedNote: $provenance?->collectedNote,
recordedBy: null !== $provenance ? $provenance->recordedBy : 0,
)
);
}
@@ -96,17 +103,22 @@ class RegistrationGate {
registrationId: $registrationId,
acceptedBy: $acceptedBy > 0 ? $acceptedBy : $studentId,
ipAddress: $ipAddress,
collectedVia: $provenance?->collectedVia,
collectedNote: $provenance?->collectedNote,
recordedBy: null !== $provenance ? $provenance->recordedBy : 0,
)
);
}
}
/**
* Current published version IDs of every booking-scoped policy.
* Current published version IDs of every booking-scoped policy what a
* booking must accept, and so also what a late, collected-elsewhere recording
* has to offer.
*
* @return list<int>
*/
private function requiredPolicyVersionIds(): array {
public function requiredPolicyVersionIds(): array {
$ids = [];
foreach ( $this->policies->findForScope( Policy::SCOPE_BOOKING ) as $policy ) {
+3 -2
View File
@@ -9,6 +9,7 @@ use Unsupervised\Schedular\Availability\WindowValidator;
use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\Booking\LessonBooker;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
@@ -39,9 +40,9 @@ class RestRegistrar {
private EnrollmentEndpoint $enrollmentEndpoint;
private PaymentEndpoint $paymentEndpoint;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians ) {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians, LessonBooker $booker ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, $booker, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
+10
View File
@@ -40,6 +40,7 @@ class Schema {
status VARCHAR(20) NOT NULL DEFAULT 'pending',
payment_id BIGINT UNSIGNED DEFAULT NULL,
notes TEXT,
booked_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY slot_id (slot_id),
@@ -85,7 +86,9 @@ class Schema {
label VARCHAR(255) NOT NULL,
field_type VARCHAR(20) NOT NULL DEFAULT 'text',
options TEXT,
audience VARCHAR(20) NOT NULL DEFAULT 'all',
is_required TINYINT(1) NOT NULL DEFAULT 0,
is_required_child TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
@@ -102,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),
@@ -143,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),
@@ -208,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),
+190
View File
@@ -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); ?>">&laquo; <?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>
+96 -2
View File
@@ -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); ?>">&laquo; <?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>
+92
View File
@@ -13,11 +13,103 @@ 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
* @var array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string} $bookValues
*/
?>
<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 selected($bookValues['student_id'], $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 selected($bookValues['slot_id'], $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 selected($bookValues['offering_id'], $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 checked($bookValues['weekly']); ?>>
<?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 checked($bookValues['no_charge']); ?>>
<?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" value="<?php echo esc_attr($bookValues['notes']); ?>"></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> |
+5 -1
View File
@@ -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>
+58 -7
View File
@@ -41,7 +41,7 @@ if (! defined('ABSPATH')) {
<?php else : ?>
<?php if ($accountScope) : ?>
<h2><?php esc_html_e('Account signup questions', 'unsupervised-schedular'); ?></h2>
<p><?php esc_html_e('Every new student answers these required-if-marked questions when they register — the account holder on the signup form itself, and once per student they are registering on behalf of.', 'unsupervised-schedular'); ?></p>
<p><?php esc_html_e('Every new student answers these questions when they register — the account holder on the signup form itself, and once per student they are registering on behalf of. Each question says who it is asked of, and can be required of the account holder, of the students, or of both.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<h2><?php echo esc_html(sprintf(/* translators: %s: offering title */ __('Questions for "%s"', 'unsupervised-schedular'), $selectedOffering->title)); ?></h2>
<?php endif; ?>
@@ -76,10 +76,31 @@ if (! defined('ABSPATH')) {
<th><label for="sort_order"><?php esc_html_e('Sort order', 'unsupervised-schedular'); ?></label></th>
<td><input type="number" name="sort_order" id="sort_order" min="0" step="1" value="0"></td>
</tr>
<tr>
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
<td><label><input type="checkbox" name="is_required" value="1"> <?php esc_html_e('Registrant must answer', 'unsupervised-schedular'); ?></label></td>
</tr>
<?php if ($accountScope) : ?>
<tr>
<th><label for="audience"><?php esc_html_e('Asked of', 'unsupervised-schedular'); ?></label></th>
<td>
<select name="audience" id="audience">
<option value="<?php echo esc_attr(Question::AUDIENCE_ALL); ?>"><?php esc_html_e('Everyone registering', 'unsupervised-schedular'); ?></option>
<option value="<?php echo esc_attr(Question::AUDIENCE_CHILD); ?>"><?php esc_html_e('Students only — not the account holder', 'unsupervised-schedular'); ?></option>
</select>
<p class="description"><?php esc_html_e('"Students only" leaves the question off the account holder\'s own section, for anything that only makes sense about a student someone is registering on behalf of.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr>
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
<td>
<label><input type="checkbox" name="is_required" value="1"> <?php esc_html_e('The account holder must answer for themselves', 'unsupervised-schedular'); ?></label><br>
<label><input type="checkbox" name="is_required_child" value="1"> <?php esc_html_e('Each student they register must answer', 'unsupervised-schedular'); ?></label>
<p class="description"><?php esc_html_e('Tick either, both, or neither — a question can be optional for an adult signing themselves up and still required for every student they enrol.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<?php else : ?>
<tr>
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
<td><label><input type="checkbox" name="is_required" value="1"> <?php esc_html_e('Registrant must answer', 'unsupervised-schedular'); ?></label></td>
</tr>
<?php endif; ?>
</table>
<?php submit_button(esc_html__('Add Question', 'unsupervised-schedular')); ?>
</form>
@@ -94,7 +115,12 @@ if (! defined('ABSPATH')) {
<th><?php esc_html_e('Order', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Type', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
<?php if ($accountScope) : ?>
<th><?php esc_html_e('Asked of', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Required of', 'unsupervised-schedular'); ?></th>
<?php else : ?>
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
<?php endif; ?>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
@@ -104,7 +130,32 @@ if (! defined('ABSPATH')) {
<td><?php echo esc_html((string) $question->sortOrder); ?></td>
<td><?php echo esc_html($question->label); ?></td>
<td><?php echo esc_html($question->fieldType); ?></td>
<td><?php echo $question->isRequired ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
<?php if ($accountScope) : ?>
<td>
<?php
echo $question->askedOfSelf()
? esc_html__('Everyone', 'unsupervised-schedular')
: esc_html__('Students only', 'unsupervised-schedular');
?>
</td>
<td>
<?php
// Named rather than two ticks, so "required of the
// students but not of you" reads as the deliberate
// setting it is rather than as a half-filled row.
$requiredOf = [];
if ($question->isRequiredForSelf()) {
$requiredOf[] = __('account holder', 'unsupervised-schedular');
}
if ($question->isRequiredForChild()) {
$requiredOf[] = __('students', 'unsupervised-schedular');
}
echo esc_html([] === $requiredOf ? __('—', 'unsupervised-schedular') : implode(', ', $requiredOf));
?>
</td>
<?php else : ?>
<td><?php echo $question->isRequired ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
<?php endif; ?>
<td>
<form method="post" style="display:inline;">
<?php wp_nonce_field('usc_question_action'); ?>
+49 -1
View File
@@ -15,6 +15,9 @@ if (! defined('ABSPATH')) {
* @var string $etransferEmail
* @var float $hstRate
* @var bool $stripeConfigured
* @var string $defaultMethod
* @var bool $stripeAnySet
* @var string $notice
* @var bool $openRegistration
* @var int $cancellationCutoffDays
*/
@@ -22,10 +25,18 @@ if (! defined('ABSPATH')) {
<div class="wrap">
<h1><?php esc_html_e('Studio Settings', 'unsupervised-schedular'); ?></h1>
<?php if ('' !== $notice) : ?>
<div class="notice notice-success inline">
<p><?php echo esc_html($notice); ?></p>
</div>
<?php endif; ?>
<div class="notice notice-info inline">
<p>
<?php if ($stripeConfigured) : ?>
<?php if ($stripeConfigured && 'card' === $defaultMethod) : ?>
<?php esc_html_e('Stripe is configured — new registrations default to credit-card billing.', 'unsupervised-schedular'); ?>
<?php elseif ($stripeConfigured) : ?>
<?php esc_html_e('Stripe is configured, but the studio default is e-transfer — only students you switch to credit card individually are billed by card.', 'unsupervised-schedular'); ?>
<?php else : ?>
<?php esc_html_e('Stripe is not configured — new registrations default to e-transfer, which a studio admin marks paid on receipt. Add your Stripe keys below to enable card billing.', 'unsupervised-schedular'); ?>
<?php endif; ?>
@@ -80,6 +91,31 @@ if (! defined('ABSPATH')) {
</tr>
</table>
<h2><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e('Default payment method', 'unsupervised-schedular'); ?></th>
<td>
<fieldset>
<label>
<input type="radio" name="default_payment_method" value="card" <?php checked($defaultMethod, 'card'); ?>>
<?php esc_html_e('Credit card (requires Stripe)', 'unsupervised-schedular'); ?>
</label><br>
<label>
<input type="radio" name="default_payment_method" value="etransfer" <?php checked($defaultMethod, 'etransfer'); ?>>
<?php esc_html_e('E-transfer', 'unsupervised-schedular'); ?>
</label>
<p class="description">
<?php esc_html_e('Applies to every student without their own billing method. Keep this on e-transfer while you trial card payments: switch individual students to Credit card under Students → student detail → Billing method, confirm their bookings charge correctly, then move the whole studio over by changing this setting.', 'unsupervised-schedular'); ?>
</p>
<?php if (! $stripeConfigured) : ?>
<p class="description"><?php esc_html_e('Credit card has no effect until Stripe keys are saved above — until then every student is billed by e-transfer.', 'unsupervised-schedular'); ?></p>
<?php endif; ?>
</fieldset>
</td>
</tr>
</table>
<h2><?php esc_html_e('E-transfer', 'unsupervised-schedular'); ?></h2>
<table class="form-table">
<tr>
@@ -124,4 +160,16 @@ if (! defined('ABSPATH')) {
</table>
<?php submit_button(esc_html__('Save Settings', 'unsupervised-schedular')); ?>
</form>
<?php if ($stripeAnySet) : ?>
<h2><?php esc_html_e('Clear Stripe configuration', 'unsupervised-schedular'); ?></h2>
<p class="description">
<?php esc_html_e('Forgets the publishable key, secret key and webhook signing secret, and returns the mode to Test. Billing falls back to e-transfer until Stripe is set up again; payments already recorded are untouched.', 'unsupervised-schedular'); ?>
</p>
<form method="post" onsubmit="return confirm('<?php echo esc_js(esc_html__('Clear the stored Stripe keys? Card billing stops until you enter them again.', 'unsupervised-schedular')); ?>');">
<?php wp_nonce_field('usc_settings_action'); ?>
<input type="hidden" name="usc_action" value="clear_stripe">
<?php submit_button(esc_html__('Clear Stripe configuration', 'unsupervised-schedular'), 'delete', 'submit', true); ?>
</form>
<?php endif; ?>
</div>
+53 -1
View File
@@ -8,6 +8,7 @@ if (! defined('ABSPATH')) {
}
/**
* @var array{name: string, email: string, birth_year: string, is_student: bool} $self The account holder's own details.
* @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children
* @var list<\Unsupervised\Schedular\Registration\Question> $questions Account-scope questions, asked once per child.
* @var string $error Validation error from the last submission, if any.
@@ -26,6 +27,52 @@ if (! defined('ABSPATH')) {
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
<?php endif; ?>
<form method="post" action="" class="us-family-self">
<?php wp_nonce_field('us_family'); ?>
<input type="hidden" name="us_family_action" value="self">
<h4><?php esc_html_e('Your details', 'unsupervised-schedular'); ?></h4>
<?php if ($self['email'] !== '') : ?>
<?php /* Shown, not editable: the address is the account's login, and changing it is a studio-side job. */ ?>
<p class="us-family-self-email">
<?php esc_html_e('Email', 'unsupervised-schedular'); ?>
<span><?php echo esc_html($self['email']); ?></span>
</p>
<?php endif; ?>
<p>
<label for="us-own-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="own_name" id="us-own-name" autocomplete="name" required value="<?php echo esc_attr($self['name']); ?>">
</p>
<p>
<label>
<input type="checkbox" name="is_student" value="1"<?php checked($self['is_student']); ?>>
<?php esc_html_e('I take lessons myself', 'unsupervised-schedular'); ?>
</label>
<span class="us-field-hint"><?php esc_html_e('Leave this unticked if you only book for the students below — you will not be offered as a student yourself.', 'unsupervised-schedular'); ?></span>
</p>
<?php
/*
* Asked of a student only, so `required` is deliberately absent: the box
* above is what decides, and the browser cannot be told to enforce a
* field conditionally without JavaScript this page does not load.
* `handleSelf()` enforces it on the server either way.
*/
?>
<p>
<label for="us-own-birth-year"><?php esc_html_e('Your birth year', 'unsupervised-schedular'); ?></label>
<input type="number" name="own_birth_year" id="us-own-birth-year" value="<?php echo esc_attr($self['birth_year']); ?>" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" autocomplete="bday-year" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>" aria-describedby="us-own-birth-year-hint">
<span class="us-field-hint" id="us-own-birth-year-hint"><?php esc_html_e('Needed only if you take lessons yourself.', 'unsupervised-schedular'); ?></span>
</p>
<p>
<button type="submit"><?php esc_html_e('Save my details', 'unsupervised-schedular'); ?></button>
</p>
</form>
<h4><?php esc_html_e('Your students', 'unsupervised-schedular'); ?></h4>
<?php if (empty($children)) : ?>
<p><?php esc_html_e('You have not added any students yet. Add one below to start booking lessons for them.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
@@ -94,7 +141,12 @@ if (! defined('ABSPATH')) {
<?php foreach ($questions as $question) : ?>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
echo QuestionField::render($question, 'us_answers[' . (int) $question->id . ']', 'us-family-q-' . (int) $question->id);
echo QuestionField::render(
$question,
'us_answers[' . (int) $question->id . ']',
'us-family-q-' . (int) $question->id,
isRequired: $question->isRequiredForChild()
);
?>
<?php endforeach; ?>
</fieldset>
+16 -3
View File
@@ -22,7 +22,7 @@ if (! defined('ABSPATH')) {
* @var string $loginUrl Where the post-confirmation sign-in link points.
* @var string $error
* @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
* @var list<Question> $accountQuestions Studio-wide questions, asked of every student the account holder included when they are one.
* @var list<Question> $accountQuestions Studio-wide questions, asked of every student being registered the account holder included when they are one, unless the question is for students only.
*/
?>
@@ -121,9 +121,21 @@ if (! defined('ABSPATH')) {
<input type="number" name="birth_year" id="us-reg-birth-year" aria-required="true" required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" autocomplete="bday-year" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p>
<?php foreach ($accountQuestions as $question) : ?>
<?php
// A "students only" question describes a child being registered,
// so it is never put to the account holder about themselves.
if (! $question->askedOfSelf()) {
continue;
}
?>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
echo QuestionField::render($question, 'us_answers[' . (int) $question->id . ']', 'us-reg-q-' . (int) $question->id);
echo QuestionField::render(
$question,
'us_answers[' . (int) $question->id . ']',
'us-reg-q-' . (int) $question->id,
isRequired: $question->isRequiredForSelf()
);
?>
<?php endforeach; ?>
</fieldset>
@@ -150,7 +162,8 @@ if (! defined('ABSPATH')) {
$question,
'children[0][answers][' . (int) $question->id . ']',
'us-child-0-q-' . (int) $question->id,
enforceRequired: false
enforceRequired: false,
isRequired: $question->isRequiredForChild()
);
?>
<?php endforeach; ?>
+154 -1
View File
@@ -1017,7 +1017,7 @@ class RegistrationPageTest extends TestCase
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7),
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, isRequiredChild: true, id: 7),
]);
Functions\when('email_exists')->justReturn(false);
@@ -1247,4 +1247,157 @@ class RegistrationPageTest extends TestCase
'The account holder answers the questions above the students they are adding.'
);
}
/**
* A "students only" question describes a child being registered, so it is put
* to each student and never to the account holder about themselves.
*/
public function testAStudentsOnlyQuestionIsAskedOfTheStudentsAndNotOfTheAccountHolder(): void
{
$this->stubRenderContext();
$question = new Question(
null,
'School and grade',
scope: Question::SCOPE_ACCOUNT,
audience: Question::AUDIENCE_CHILD,
isRequiredChild: true,
id: 7
);
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
$html = $this->ctx['page']->render([]);
self::assertStringNotContainsString('name="us_answers[7]"', $html);
self::assertStringContainsString('name="children[0][answers][7]"', $html);
}
/**
* The two required flags are read where each applies: the browser is asked to
* enforce the account holder's, and the students' block carries the marker
* without the attribute (it may not be in play at all).
*/
public function testTheFormMarksAQuestionRequiredWhereItActuallyIs(): void
{
$this->stubRenderContext();
$question = new Question(
null,
'Previous experience',
scope: Question::SCOPE_ACCOUNT,
isRequired: false,
isRequiredChild: true,
id: 7
);
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
$html = $this->ctx['page']->render([]);
// No `required` attribute on the account holder's copy, and no marker on
// its label — they may leave it blank.
self::assertStringContainsString('<input type="text" name="us_answers[7]" id="us-reg-q-7">', $html);
self::assertStringContainsString('<label for="us-reg-q-7">Previous experience</label>', $html);
// The student's copy is marked required, without the attribute: the block
// may not be in play at all, so the server is what enforces it.
self::assertStringContainsString('<label for="us-child-0-q-7">Previous experience <span class="us-required" aria-hidden="true">*</span></label>', $html);
self::assertStringContainsString('<input type="text" name="children[0][answers][7]" id="us-child-0-q-7">', $html);
}
/**
* The point of the two flags: an adult signing themselves up can leave the
* question blank, while every student they enrol must answer it.
*/
public function testAQuestionOptionalForYouIsStillRequiredOfEachStudent(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => '1990',
'us_registering_for' => RegistrationPage::FOR_BOTH,
'us_answers' => ['7' => ' '],
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => ' ']]],
];
$question = new Question(null, 'Instrument', isRequired: false, scope: Question::SCOPE_ACCOUNT, isRequiredChild: true, id: 7);
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
// The student's blank is what stopped it — the account holder's was fine.
self::assertStringContainsString('for each student', $result);
}
public function testTheAccountHolderMayLeaveBlankWhatTheirStudentsMustAnswer(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => '1990',
'us_registering_for' => RegistrationPage::FOR_BOTH,
'us_answers' => ['7' => ' '],
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
];
$question = new Question(null, 'Instrument', isRequired: false, scope: Question::SCOPE_ACCOUNT, isRequiredChild: true, id: 7);
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
$this->stubInviteSuccess();
$students = [];
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
static function (Answer $answer) use (&$students): int {
$students[] = $answer->studentId;
return 1;
}
);
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
// Only the student answered, so only the student has an answer stored.
self::assertSame([101], $students);
}
/**
* A question the account holder is never shown cannot be one they are held
* to, nor one an answer can be filed against them for a crafted post that
* supplies both is ignored on both counts.
*/
public function testAStudentsOnlyQuestionNeitherBlocksNorStoresAgainstTheAccountHolder(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => '1990',
'us_registering_for' => RegistrationPage::FOR_BOTH,
'us_answers' => ['7' => 'Crafted by hand'],
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Grade 4']]],
];
$question = new Question(
null,
'School and grade',
isRequired: true,
scope: Question::SCOPE_ACCOUNT,
audience: Question::AUDIENCE_CHILD,
isRequiredChild: true,
id: 7
);
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
$this->stubInviteSuccess();
$recorded = [];
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
static function (Answer $answer) use (&$recorded): int {
$recorded[] = [$answer->studentId, $answer->answerValue];
return 1;
}
);
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
self::assertSame([[101, 'Grade 4']], $recorded);
}
}
+44
View File
@@ -155,4 +155,48 @@ class RoleManagerTest extends TestCase
(new RoleManager())->createRoles();
}
/**
* The predicate every staff-side "register this person" path shares. It is
* deliberately the role and not `book_lesson`, so the two accounts that have
* that capability withheld a guardian's child and an unapproved signup are
* still people the studio can act for.
*/
public function testIsStudentAcceptsAnyHolderOfTheStudentRole(): void
{
Functions\when('get_userdata')->justReturn($this->userWithRoles([RoleManager::STUDENT]));
self::assertTrue(RoleManager::isStudent(5));
}
public function testIsStudentRejectsSomeoneWhoIsNotAStudent(): void
{
Functions\when('get_userdata')->justReturn($this->userWithRoles([RoleManager::INSTRUCTOR]));
self::assertFalse(RoleManager::isStudent(5));
}
public function testIsStudentRejectsAnAccountThatNoLongerExists(): void
{
Functions\when('get_userdata')->justReturn(false);
self::assertFalse(RoleManager::isStudent(5));
}
public function testIsStudentRejectsNoOneChosenWithoutLookingAnyoneUp(): void
{
Functions\expect('get_userdata')->never();
self::assertFalse(RoleManager::isStudent(0));
}
/** @param list<string> $roles */
private function userWithRoles(array $roles): \WP_User
{
$user = \Mockery::mock(\WP_User::class);
$user->ID = 5;
$user->roles = $roles;
return $user;
}
}
+5 -5
View File
@@ -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),
+17
View File
@@ -74,6 +74,23 @@ class BlockPreviewTest extends TestCase
self::assertStringContainsString('us-editor-note', $html);
}
/**
* The preview is what someone placing the block styles against, so it has to
* show both halves of the page your own details as well as your students'.
*/
public function testFamilyPreviewShowsTheAccountHoldersDetailsAndTheirStudents(): void
{
$html = BlockPreview::family();
self::assertStringContainsString('class="us-family-self"', $html);
self::assertStringContainsString('id="us-own-name"', $html);
self::assertStringContainsString('id="us-own-birth-year"', $html);
self::assertStringContainsString('I take lessons myself', $html);
self::assertStringContainsString('class="us-family-list"', $html);
self::assertStringContainsString('class="us-family-add"', $html);
self::assertStringContainsString('us-editor-note', $html);
}
public function testRegistrationPreviewShowsADisabledSampleForm(): void
{
$html = BlockPreview::registration();
+381
View File
@@ -0,0 +1,381 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\RoleManager;
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))
);
// The picker offers holders of the student role, and that is what the guard
// accepts; it is exercised on its own below.
Functions\when('get_userdata')->alias(fn (int $id): \WP_User => $this->user($id, 'Jane Doe'));
Functions\when('get_users')->justReturn([]);
// 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 testSomeoneWhoIsNotAStudentIsRefused(): void
{
Functions\when('get_userdata')->alias(fn (int $id): \WP_User => $this->user($id, 'Jane Doe', [RoleManager::INSTRUCTOR]));
$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 testAnAccountThatNoLongerExistsIsRefused(): void
{
Functions\when('get_userdata')->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 testNoStudentChosenIsRefusedWithoutLookingAnyoneUp(): void
{
Functions\expect('get_userdata')->never();
$this->availability->shouldReceive('findById')->never();
$result = $this->admin->book(0, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invalid_student', $result->get_error_code());
}
/**
* A child holds the student role but never `book_lesson` withheld so the
* account cannot book in its own name. The studio booking for them is the only
* route a child has to a lesson, so it must not be blocked by that.
*/
public function testBooksForAGuardiansChildWhoCannotBookThemselves(): void
{
Functions\when('user_can')->justReturn(false);
$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);
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
self::assertIsString($notice);
self::assertStringContainsString('30 min piano', $notice);
}
/**
* Same for a self-signup the studio has not approved yet: the front desk can
* still get them onto the calendar while the paperwork catches up.
*/
public function testBooksForAStudentStillAwaitingApproval(): void
{
Functions\when('user_can')->justReturn(false);
$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 => 42 === $l->studentId
))->andReturn(100);
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
self::assertIsString($notice);
self::assertStringContainsString('pending payment', $notice);
}
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,
);
}
/** @param list<string> $roles */
private function user(int $id, string $name, array $roles = [RoleManager::STUDENT]): \WP_User
{
$user = Mockery::mock(\WP_User::class);
$user->ID = $id;
$user->roles = $roles;
$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,
+4 -2
View File
@@ -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;
+302 -8
View File
@@ -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
@@ -233,11 +258,13 @@ class LessonControllerTest extends TestCase
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
$this->detail->shouldReceive('answers')->once()->with(1)->andReturn([
['question' => 'Skill level', 'answer' => 'Beginner'],
// 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', 'source' => 'Given online when booking'],
]);
$this->detail->shouldReceive('acceptances')->once()->with(1)->andReturn([
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'],
$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', 'source' => 'Given online when booking'],
]);
// The list of lessons must never be queried when routing to a detail view.
@@ -272,6 +299,273 @@ 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);
}
/**
* A refusal used to clear all five fields, so one mistake meant retyping the
* whole form and the panel is only ever reopened *because* something was
* refused.
*/
public function testARefusedBookingComesBackWithEveryFieldStillFilledIn(): void
{
$this->postBooking();
$this->offerPanel();
$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);
// Attribute spacing in the template is not what is under test here.
$tags = (string) preg_replace('/\s+/', ' ', $html);
// The panel is reopened, showing the student, time and lesson type as posted.
self::assertStringContainsString(' open>', $html);
self::assertStringContainsString('value="42" selected=\'selected\'', $tags);
self::assertStringContainsString('value="7" selected=\'selected\'', $tags);
self::assertStringContainsString('value="3" selected=\'selected\'', $tags);
// Both ticks and the note survive too.
self::assertSame(2, substr_count($html, "checked='checked'"));
self::assertStringContainsString('value="Make-up lesson"', $html);
}
public function testASuccessfulBookingLeavesAnEmptyFormForTheNextOne(): void
{
$this->postBooking();
$this->offerPanel();
$this->adminBooking->shouldReceive('book')->once()->andReturn('Booked.');
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
$html = $this->render();
self::assertStringContainsString('Booked.', $html);
// Nothing carried over, or the next booking silently inherits this one's.
self::assertStringNotContainsString("selected='selected'", $html);
self::assertStringNotContainsString("checked='checked'", $html);
self::assertStringContainsString('value=""', $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));
}
/** The panel with one of each choice, so a re-selected value has somewhere to land. */
private function offerPanel(): void
{
$this->adminBooking->shouldReceive('formData')->once()->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]],
]);
}
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();
-95
View File
@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Mockery;
use Unsupervised\Schedular\Booking\LessonDetail;
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\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class LessonDetailTest 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;
protected function setUp(): void
{
parent::setUp();
$this->answers = Mockery::mock(AnswerRepository::class);
$this->questions = Mockery::mock(QuestionRepository::class);
$this->acceptances = Mockery::mock(AcceptanceRepository::class);
$this->policies = Mockery::mock(PolicyRepository::class);
$this->versions = Mockery::mock(PolicyVersionRepository::class);
$this->detail = new LessonDetail(
$this->answers,
$this->questions,
$this->acceptances,
$this->policies,
$this->versions
);
}
public function testAnswersPairEachAnswerWithItsQuestionLabel(): void
{
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_LESSON, 7)->andReturn([
new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: 'Beginner'),
new Answer(questionId: 9, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: null),
]);
$this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Skill level', id: 2));
$this->questions->shouldReceive('findById')->with(9)->andReturn(null);
self::assertSame(
[
['question' => 'Skill level', 'answer' => 'Beginner'],
['question' => '#9', 'answer' => '—'],
],
$this->detail->answers(7)
);
}
public function testAcceptancesResolvePolicyTitleVersionAndAuditTrail(): void
{
$this->acceptances->shouldReceive('findByRegistration')->once()->with(PolicyAcceptance::REG_LESSON, 7)->andReturn([
new PolicyAcceptance(
policyVersionId: 4,
studentId: 5,
registrationType: PolicyAcceptance::REG_LESSON,
registrationId: 7,
ipAddress: '1.2.3.4',
acceptedAt: '2026-07-01 10:00:00'
),
]);
$this->versions->shouldReceive('findById')->with(4)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 4));
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
self::assertSame(
[
[
'policy' => 'Cancellation',
'version' => 'v2',
'accepted_at' => '2026-07-01 10:00:00',
'ip' => '1.2.3.4',
],
],
$this->detail->acceptances(7)
);
}
}
+18
View File
@@ -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;
+19 -1
View File
@@ -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());
}
}
@@ -7,6 +7,7 @@ use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
@@ -16,6 +17,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 +30,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 +45,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 +55,8 @@ class GroupClassControllerTest extends TestCase
$this->paymentService,
$this->invites,
$this->mailer,
$this->audit,
$this->intake,
);
Functions\when('current_user_can')->justReturn(true);
@@ -79,7 +88,8 @@ class GroupClassControllerTest extends TestCase
{
[$first, $last] = array_pad(explode(' ', $full, 2), 2, '');
$user = Mockery::mock(\WP_User::class);
$user = Mockery::mock(\WP_User::class);
$user->roles = [RoleManager::STUDENT];
$user->first_name = $first;
$user->last_name = $last;
$user->nickname = $full;
@@ -398,6 +408,8 @@ class GroupClassControllerTest extends TestCase
Functions\when('wp_unslash')->returnArg();
Functions\when('sanitize_email')->returnArg();
Functions\when('absint')->alias(static fn ($v) => abs((int) $v));
// Every posted id is vetted as a student before it is enrolled or granted.
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
// Render tail: no classes/enrolments to draw so the assertion targets the notice.
$this->offerings->shouldReceive('findAll')->with(3, Offering::KIND_GROUP_CLASS)->andReturn([]);
@@ -411,7 +423,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 +451,161 @@ 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([]);
}
/**
* The multi-select is built from the studio's students, but a posted id is just
* a number: it could name an instructor, an administrator, or an account
* deleted since the page was drawn. Enrolling one would put a non-student on
* the roster and raise a payment against them.
*/
public function testAddDirectIgnoresAnIdThatIsNotAStudent(): void
{
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
$instructor = Mockery::mock(\WP_User::class);
$instructor->roles = [RoleManager::INSTRUCTOR];
Functions\when('get_userdata')->justReturn($instructor);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
$this->enrollments->shouldReceive('insert')->never();
$this->paymentService->shouldReceive('createForRegistration')->never();
$this->access->shouldReceive('markEnrolled')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('0 student(s) added to the class.', $html);
}
public function testAddDirectIgnoresAnAccountThatNoLongerExists(): void
{
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
Functions\when('get_userdata')->justReturn(false);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
$this->enrollments->shouldReceive('insert')->never();
$this->paymentService->shouldReceive('createForRegistration')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('0 student(s) added to the class.', $html);
}
public function testGrantAccessIgnoresAnIdThatIsNotAStudent(): void
{
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
$instructor = Mockery::mock(\WP_User::class);
$instructor->roles = [RoleManager::INSTRUCTOR];
Functions\when('get_userdata')->justReturn($instructor);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
$this->access->shouldReceive('insert')->never();
$this->mailer->shouldReceive('sendClassAccessGranted')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('0 student(s) granted access.', $html);
}
public function testGrantAccessCreatesGrantAndEmailsStudent(): void
{
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
@@ -445,7 +616,8 @@ class GroupClassControllerTest extends TestCase
$this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(false);
$this->access->shouldReceive('insert')->once()->andReturn(1);
$user = Mockery::mock(\WP_User::class);
$user = Mockery::mock(\WP_User::class);
$user->roles = [RoleManager::STUDENT];
$user->user_email = '[email protected]';
Functions\when('get_userdata')->justReturn($user);
$this->mailer->shouldReceive('sendClassAccessGranted')->once()->with($user, 'Private Choir')->andReturn(true);
+190 -1
View File
@@ -76,9 +76,36 @@ class FamilyPageTest extends TestCase
return $page;
}
/**
* The account holder's own details, which every render reads.
*
* @param array{name?: string, email?: string, birth_year?: string, is_student?: bool} $overrides
*/
private function expectAccountHolder(array $overrides = []): void
{
$this->guardians->shouldReceive('accountHolder')->with(5)->andReturn($overrides + [
'name' => 'Grace',
'email' => '[email protected]',
'birth_year' => '1984',
'is_student' => true,
]);
}
/**
* A question required of everyone, or of nobody the shape every question
* had before the account holder and the students could differ, and the shape
* the upgrade backfill leaves them in.
*/
private function question(int $id, bool $required): Question
{
return new Question(offeringId: null, label: 'Instrument', isRequired: $required, scope: Question::SCOPE_ACCOUNT, id: $id);
return new Question(
offeringId: null,
label: 'Instrument',
isRequired: $required,
scope: Question::SCOPE_ACCOUNT,
isRequiredChild: $required,
id: $id
);
}
public function testLoggedOutVisitorIsOfferedALoginLink(): void
@@ -93,6 +120,7 @@ class FamilyPageTest extends TestCase
public function testRenderListsTheGuardiansChildren(): void
{
$this->expectAccountHolder();
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'],
]);
@@ -105,6 +133,101 @@ class FamilyPageTest extends TestCase
self::assertStringContainsString('Add a student', $html);
}
public function testRenderShowsTheAccountHoldersOwnDetails(): void
{
$this->expectAccountHolder();
$this->guardians->shouldReceive('children')->andReturn([]);
$this->questions->shouldReceive('findByScope')->andReturn([]);
$html = $this->page->render([]);
self::assertStringContainsString('Your details', $html);
self::assertStringContainsString('value="Grace"', $html);
self::assertStringContainsString('[email protected]', $html);
self::assertStringContainsString('value="1984"', $html);
// A student in their own right has the box ticked.
self::assertStringContainsString("checked='checked'", $html);
}
public function testAGuardianOnlyAccountRendersTheStudentBoxUnticked(): void
{
$this->expectAccountHolder(['is_student' => false]);
$this->guardians->shouldReceive('children')->andReturn([]);
$this->questions->shouldReceive('findByScope')->andReturn([]);
$html = $this->page->render([]);
self::assertStringContainsString('name="is_student"', $html);
self::assertStringNotContainsString("checked='checked'", $html);
}
/**
* The birth-year field must not carry `required`: it is asked of a student
* only, and the browser would otherwise block a guardian who books solely
* for other people from ever saving the form.
*/
public function testTheOwnBirthYearFieldIsNotBrowserRequired(): void
{
$this->expectAccountHolder(['is_student' => false, 'birth_year' => '']);
$this->guardians->shouldReceive('children')->andReturn([]);
$this->questions->shouldReceive('findByScope')->andReturn([]);
$html = $this->page->render([]);
self::assertMatchesRegularExpression('/<input[^>]*name="own_birth_year"(?![^>]*\brequired\b)[^>]*>/', $html);
}
public function testSavingOwnDetailsDelegatesToTheServiceAndRedirects(): void
{
$_POST = [
'us_family_action' => 'self',
'own_name' => 'Grace H',
'own_birth_year' => '1984',
'is_student' => '1',
];
$this->guardians->shouldReceive('updateSelf')->once()->with(5, 'Grace H', '1984', true)->andReturn(null);
$captured = null;
$this->capturingPage($captured)->maybeHandleSubmit();
self::assertSame('https://studio.test/family/?us_family=self', $captured);
}
/** An unticked checkbox is simply absent from the post — that is the "no". */
public function testAnAbsentStudentBoxSavesTheAccountAsGuardianOnly(): void
{
$_POST = [
'us_family_action' => 'self',
'own_name' => 'Grace H',
'own_birth_year' => '',
];
$this->guardians->shouldReceive('updateSelf')->once()->with(5, 'Grace H', '', false)->andReturn(null);
$captured = null;
$this->capturingPage($captured)->maybeHandleSubmit();
self::assertSame('https://studio.test/family/?us_family=self', $captured);
}
public function testOwnDetailsRefusalIsShownRatherThanRedirected(): void
{
$_POST = ['us_family_action' => 'self', 'own_name' => 'Grace', 'is_student' => '1'];
$this->guardians->shouldReceive('updateSelf')->once()->andReturn(
new \WP_Error('missing_birth_year', 'Please give your birth year.')
);
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void
{
$_POST = [
@@ -158,6 +281,71 @@ class FamilyPageTest extends TestCase
self::assertNull($captured);
}
/**
* This screen only ever adds a student, so the students' required-ness is the
* one that applies: a question required of the account holder alone must not
* stop a guardian adding a child.
*/
public function testAddIsNotBlockedByAQuestionRequiredOnlyOfTheAccountHolder(): void
{
$_POST = [
'us_family_action' => 'add',
'child_name' => 'Ada',
'child_birth_year' => '2015',
'us_answers' => [7 => ' '],
];
$question = new Question(
offeringId: null,
label: 'Instrument',
isRequired: true,
scope: Question::SCOPE_ACCOUNT,
isRequiredChild: false,
id: 7
);
$this->questions->shouldReceive('findByScope')->once()->andReturn([$question]);
$this->guardians->shouldReceive('createChild')->once()->andReturn(42);
// Nothing was typed, so nothing is stored — but the add went through.
$this->answers->shouldNotReceive('insert');
$captured = null;
$this->capturingPage($captured)->maybeHandleSubmit();
self::assertSame('https://studio.test/family/?us_family=added', $captured);
}
public function testAddIsBlockedByAQuestionRequiredOnlyOfTheStudents(): void
{
$_POST = [
'us_family_action' => 'add',
'child_name' => 'Ada',
'child_birth_year' => '2015',
'us_answers' => [7 => ''],
];
$question = new Question(
offeringId: null,
label: 'Instrument',
isRequired: false,
scope: Question::SCOPE_ACCOUNT,
isRequiredChild: true,
id: 7
);
$this->questions->shouldReceive('findByScope')->once()->andReturn([$question]);
$this->guardians->shouldNotReceive('createChild');
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testAddSurfacesAServiceErrorInsteadOfRedirecting(): void
{
$_POST = ['us_family_action' => 'add', 'child_name' => ''];
@@ -270,6 +458,7 @@ class FamilyPageTest extends TestCase
{
$_GET = ['us_family' => 'added'];
$this->expectAccountHolder();
$this->guardians->shouldReceive('children')->andReturn([]);
$this->questions->shouldReceive('findByScope')->andReturn([]);
@@ -321,6 +321,87 @@ class GuardianServiceTest extends TestCase
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
}
public function testUpdateSelfRenamesAndStoresTheBirthYear(): void
{
$this->meta[5][GuardianService::META_GUARDIAN_ONLY] = '1';
Functions\expect('wp_update_user')
->once()
->with(['ID' => 5, 'display_name' => 'Grace H', 'nickname' => 'Grace H'])
->andReturn(5);
self::assertNull($this->service->updateSelf(5, 'Grace H', '1984', true));
self::assertSame('1984', $this->meta[5][GuardianService::META_BIRTH_YEAR]);
// Saying they take lessons makes them a bookable student again.
self::assertFalse(GuardianService::isGuardianOnly(5));
}
public function testUpdateSelfMarksTheAccountGuardianOnly(): void
{
Functions\when('wp_update_user')->justReturn(5);
self::assertNull($this->service->updateSelf(5, 'Grace H', '', false));
self::assertSame('1', $this->meta[5][GuardianService::META_GUARDIAN_ONLY]);
}
/**
* "I only book for other people" says who books, not "forget my birth year"
* ticking the box back on should not have cost them what was on file.
*/
public function testUpdateSelfKeepsAStoredBirthYearWhenTheyAreNoLongerAStudent(): void
{
$this->meta[5][GuardianService::META_BIRTH_YEAR] = '1984';
Functions\when('wp_update_user')->justReturn(5);
self::assertNull($this->service->updateSelf(5, 'Grace H', '', false));
self::assertSame('1984', $this->meta[5][GuardianService::META_BIRTH_YEAR]);
}
public function testUpdateSelfRejectsABlankName(): void
{
Functions\expect('wp_update_user')->never();
self::assertInstanceOf(\WP_Error::class, $this->service->updateSelf(5, ' ', '1984', true));
}
/**
* The browser cannot enforce the year conditionally, so the server is the
* only thing standing between a student and a nonsense age on their record.
*
* @dataProvider unusableBirthYears
*/
public function testUpdateSelfRefusesAnUnusableBirthYearFromAStudent(string $submitted): void
{
Functions\expect('wp_update_user')->never();
self::assertInstanceOf(\WP_Error::class, $this->service->updateSelf(5, 'Grace H', $submitted, true));
}
public function testAccountHolderReportsTheirOwnDetails(): void
{
$this->meta[5][GuardianService::META_BIRTH_YEAR] = '1984';
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
self::assertSame(
['name' => 'Grace Hopper', 'email' => '[email protected]', 'birth_year' => '1984', 'is_student' => true],
$this->service->accountHolder(5)
);
}
public function testAccountHolderReportsAGuardianOnlyAccountAsNotAStudent(): void
{
$this->meta[5][GuardianService::META_GUARDIAN_ONLY] = '1';
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
self::assertFalse($this->service->accountHolder(5)['is_student']);
}
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void
{
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
@@ -22,13 +22,13 @@ class BillingMethodResolverTest extends TestCase
self::assertSame(Payment::METHOD_COMP, $resolver->resolve(5));
}
public function testDefaultsToCardWhenStripeConfigured(): void
public function testDefaultsToCardWhenStripeConfiguredAndCardChosen(): void
{
Functions\when('get_user_meta')->justReturn('');
$settings = Mockery::mock(StudioSettings::class);
$settings->shouldReceive('isStripeConfigured')->andReturn(true);
$resolver = new BillingMethodResolver($settings);
$resolver = new BillingMethodResolver(
$this->settings(Payment::METHOD_CARD, true)
);
self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5));
}
@@ -37,22 +37,56 @@ class BillingMethodResolverTest extends TestCase
{
Functions\when('get_user_meta')->justReturn('');
$settings = Mockery::mock(StudioSettings::class);
$settings->shouldReceive('isStripeConfigured')->andReturn(false);
$resolver = new BillingMethodResolver($settings);
$resolver = new BillingMethodResolver(
$this->settings(Payment::METHOD_CARD, false)
);
self::assertSame(Payment::METHOD_ETRANSFER, $resolver->resolve(5));
self::assertSame(Payment::METHOD_ETRANSFER, $resolver->defaultMethod());
}
public function testEtransferDefaultHoldsEvenWhenStripeIsLive(): void
{
Functions\when('get_user_meta')->justReturn('');
$resolver = new BillingMethodResolver(
$this->settings(Payment::METHOD_ETRANSFER, true)
);
self::assertSame(Payment::METHOD_ETRANSFER, $resolver->resolve(5));
self::assertSame(Payment::METHOD_ETRANSFER, $resolver->defaultMethod());
}
public function testPerStudentCardOverrideStillWinsUnderAnEtransferDefault(): void
{
// The trial path: the studio bills by e-transfer, one student is moved to
// card to prove Stripe end to end.
Functions\when('get_user_meta')->justReturn(Payment::METHOD_CARD);
$resolver = new BillingMethodResolver(
$this->settings(Payment::METHOD_ETRANSFER, true)
);
self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5));
}
public function testInvalidOverrideFallsBackToDefault(): void
{
Functions\when('get_user_meta')->justReturn('bogus');
$settings = Mockery::mock(StudioSettings::class);
$settings->shouldReceive('isStripeConfigured')->andReturn(true);
$resolver = new BillingMethodResolver($settings);
$resolver = new BillingMethodResolver(
$this->settings(Payment::METHOD_CARD, true)
);
self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5));
}
private function settings(string $default, bool $stripeConfigured): StudioSettings
{
$settings = Mockery::mock(StudioSettings::class);
$settings->shouldReceive('defaultPaymentMethod')->andReturn($default);
$settings->shouldReceive('isStripeConfigured')->andReturn($stripeConfigured);
return $settings;
}
}
+55
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Payment;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase;
@@ -103,6 +104,60 @@ class StudioSettingsTest extends TestCase
$this->applyMode(true);
}
public function testDefaultPaymentMethodIsCardWhenUnset(): void
{
Functions\when('get_option')->alias(static fn (string $name, $default) => $default);
self::assertSame(Payment::METHOD_CARD, (new StudioSettings())->defaultPaymentMethod());
}
public function testDefaultPaymentMethodReadsStoredEtransfer(): void
{
Functions\when('get_option')->alias(static fn (string $name) =>
$name === StudioSettings::OPT_DEFAULT_PAYMENT_METHOD ? Payment::METHOD_ETRANSFER : '');
self::assertSame(Payment::METHOD_ETRANSFER, (new StudioSettings())->defaultPaymentMethod());
}
public function testUnrecognisedStoredDefaultPaymentMethodFallsBackToCard(): void
{
// `comp` is a per-student choice only: it must never become the studio
// default and quietly stop billing anyone.
Functions\when('get_option')->alias(static fn (string $name) =>
$name === StudioSettings::OPT_DEFAULT_PAYMENT_METHOD ? Payment::METHOD_COMP : '');
self::assertSame(Payment::METHOD_CARD, (new StudioSettings())->defaultPaymentMethod());
}
public function testClearStripeConfigDeletesEveryStripeOption(): void
{
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_PUBLISHABLE);
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_SECRET);
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_WEBHOOK_SECRET);
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_MODE);
(new StudioSettings())->clearStripeConfig();
}
public function testClearStripeConfigLeavesNonStripeSettingsAlone(): void
{
$deleted = [];
Functions\when('delete_option')->alias(static function (string $name) use (&$deleted): bool {
$deleted[] = $name;
return true;
});
Functions\expect('update_option')->never();
(new StudioSettings())->clearStripeConfig();
self::assertNotContains(StudioSettings::OPT_CURRENCY, $deleted);
self::assertNotContains(StudioSettings::OPT_ETRANSFER_EMAIL, $deleted);
self::assertNotContains(StudioSettings::OPT_HST_RATE, $deleted);
self::assertNotContains(StudioSettings::OPT_REGISTRATION_MODE, $deleted);
self::assertNotContains(StudioSettings::OPT_CANCELLATION_CUTOFF_HOURS, $deleted);
}
private function applyMode(bool $enable): void
{
$method = new \ReflectionMethod(StudioSettings::class, 'applyRegistrationMode');
@@ -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;
+1 -1
View File
@@ -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);
+186
View File
@@ -0,0 +1,186 @@
<?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\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\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 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 IntakeAudit $detail;
protected function setUp(): void
{
parent::setUp();
$this->answers = Mockery::mock(AnswerRepository::class);
$this->questions = Mockery::mock(QuestionRepository::class);
$this->acceptances = Mockery::mock(AcceptanceRepository::class);
$this->policies = Mockery::mock(PolicyRepository::class);
$this->versions = Mockery::mock(PolicyVersionRepository::class);
$this->detail = new IntakeAudit(
$this->answers,
$this->questions,
$this->acceptances,
$this->policies,
$this->versions
);
}
public function testAnswersPairEachAnswerWithItsQuestionLabel(): void
{
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_LESSON, 7)->andReturn([
new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: 'Beginner'),
new Answer(questionId: 9, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: null),
]);
$this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Skill level', id: 2));
$this->questions->shouldReceive('findById')->with(9)->andReturn(null);
self::assertSame(
[
['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking'],
['question' => '#9', 'answer' => '—', 'source' => 'Given online when booking'],
],
$this->detail->answers($this->lesson(7))
);
}
public function testAcceptancesResolvePolicyTitleVersionAndAuditTrail(): void
{
$this->acceptances->shouldReceive('findByRegistration')->once()->with(PolicyAcceptance::REG_LESSON, 7)->andReturn([
new PolicyAcceptance(
policyVersionId: 4,
studentId: 5,
registrationType: PolicyAcceptance::REG_LESSON,
registrationId: 7,
ipAddress: '1.2.3.4',
acceptedAt: '2026-07-01 10:00:00'
),
]);
$this->versions->shouldReceive('findById')->with(4)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 4));
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
self::assertSame(
[
[
'policy' => 'Cancellation',
'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
// and the agreement were recorded once, against the anchor.
$occurrence = $this->lesson(12, seriesId: 7);
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_LESSON, 7)->andReturn([
new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: 'Beginner'),
]);
$this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Skill level', id: 2));
$this->acceptances->shouldReceive('findByRegistration')->once()->with(PolicyAcceptance::REG_LESSON, 7)->andReturn([
new PolicyAcceptance(
policyVersionId: 4,
studentId: 5,
registrationType: PolicyAcceptance::REG_LESSON,
registrationId: 7,
ipAddress: '1.2.3.4',
acceptedAt: '2026-07-01 10:00:00'
),
]);
$this->versions->shouldReceive('findById')->with(4)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 4));
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
self::assertSame(
[['question' => 'Skill level', 'answer' => 'Beginner', 'source' => 'Given online when booking']],
$this->detail->answers($occurrence)
);
self::assertSame(
[[
'policy' => 'Cancellation',
'version' => 'v2',
'accepted_at' => '2026-07-01 10:00:00',
'ip' => '1.2.3.4',
'source' => 'Given online when booking',
]],
$this->detail->acceptances($occurrence)
);
}
private function lesson(int $id, ?int $seriesId = null): Lesson
{
return new Lesson(
slotId: 1,
studentId: 5,
instructorId: 9,
offeringId: 1,
recurrence: null === $seriesId ? Lesson::RECURRENCE_SINGLE : Lesson::RECURRENCE_WEEKLY,
seriesId: $seriesId,
id: $id
);
}
}
@@ -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);
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Registration;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionField;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class QuestionFieldTest extends TestCase
{
public function testRendersATextInputNamedAndLabelledAsAsked(): void
{
$html = QuestionField::render(new Question(7, 'Your level?', id: 3), 'us_answers[3]', 'us-q-3');
self::assertStringContainsString('<label for="us-q-3">Your level?</label>', $html);
self::assertStringContainsString('<input type="text" name="us_answers[3]" id="us-q-3">', $html);
}
public function testARequiredQuestionIsMarkedAndEnforced(): void
{
$question = new Question(7, 'Your level?', isRequired: true, id: 3);
$html = QuestionField::render($question, 'us_answers[3]', 'us-q-3');
self::assertStringContainsString('us-required', $html);
self::assertStringContainsString(' required', $html);
}
/**
* A block that may not apply at all keeps the marker and drops the attribute,
* so the browser cannot refuse a submit over a field that is out of play.
*/
public function testNotEnforcingRequiredKeepsTheMarkerButDropsTheAttribute(): void
{
$question = new Question(7, 'Your level?', isRequired: true, id: 3);
$html = QuestionField::render($question, 'us_answers[3]', 'us-q-3', enforceRequired: false);
self::assertStringContainsString('us-required', $html);
self::assertStringNotContainsString(' required>', $html);
}
/**
* Which of the question's two required flags applies depends on whose block
* this is, and only the caller knows that.
*/
public function testTheCallerCanOverrideWhichRequiredFlagApplies(): void
{
$question = new Question(
null,
'Previous experience',
scope: Question::SCOPE_ACCOUNT,
isRequired: false,
isRequiredChild: true,
id: 3
);
$forSelf = QuestionField::render($question, 'us_answers[3]', 'us-q-3', isRequired: $question->isRequiredForSelf());
$forChild = QuestionField::render($question, 'children[0][answers][3]', 'us-child-0-q-3', isRequired: $question->isRequiredForChild());
self::assertStringNotContainsString('us-required', $forSelf);
self::assertStringNotContainsString(' required', $forSelf);
self::assertStringContainsString('us-required', $forChild);
self::assertStringContainsString(' required', $forChild);
}
public function testASelectRendersItsOptionsBehindAnEmptyChoice(): void
{
$question = new Question(
7,
'Pick a level',
fieldType: Question::FIELD_SELECT,
options: ['Beginner', 'Advanced'],
id: 3
);
$html = QuestionField::render($question, 'us_answers[3]', 'us-q-3');
self::assertStringContainsString('<select name="us_answers[3]" id="us-q-3">', $html);
self::assertStringContainsString('<option value="Beginner">Beginner</option>', $html);
self::assertStringContainsString('<option value="Advanced">Advanced</option>', $html);
}
}
@@ -156,6 +156,59 @@ class QuestionRepositoryTest extends TestCase
self::assertSame(30, $this->repo->insert($question));
}
public function testInsertStoresAudienceAndTheStudentsRequiredFlag(): void
{
Functions\expect('current_time')->andReturn('2026-04-01 12:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_questions',
Mockery::on(static function (array $data): bool {
return $data['audience'] === Question::AUDIENCE_CHILD
&& $data['is_required'] === 0
&& $data['is_required_child'] === 1;
}),
// One placeholder per column, in the same order.
Mockery::on(static fn (array $format): bool => count($format) === 11)
);
$this->db->insert_id = 31;
$question = new Question(
null,
'School and grade',
scope: Question::SCOPE_ACCOUNT,
audience: Question::AUDIENCE_CHILD,
isRequiredChild: true
);
self::assertSame(31, $this->repo->insert($question));
}
public function testBackfillChildRequiredCopiesTheOldRequiredFlagAcross(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/UPDATE %i SET is_required_child = 1 WHERE is_required = 1/'), 'wp_us_questions')
->andReturn('UPDATE `wp_us_questions` SET is_required_child = 1 WHERE is_required = 1');
$this->db->shouldReceive('query')
->once()
->with('UPDATE `wp_us_questions` SET is_required_child = 1 WHERE is_required = 1')
->andReturn(2);
self::assertTrue($this->repo->backfillChildRequired());
}
public function testBackfillChildRequiredReportsFailureWhenQueryFails(): void
{
$this->db->shouldReceive('prepare')->once()->andReturn('UPDATE ...');
$this->db->shouldReceive('query')->once()->andReturn(false);
self::assertFalse($this->repo->backfillChildRequired());
}
public function testFindByScopeActiveOnlyPreparesQuery(): void
{
$this->db->shouldReceive('prepare')
+102 -1
View File
@@ -101,11 +101,112 @@ class QuestionTest extends TestCase
$question = new Question(7, 'Label', Question::FIELD_TEXT, id: 9);
$arr = $question->toArray();
foreach (['id', 'offering_id', 'scope', 'label', 'field_type', 'options', 'is_required', 'sort_order', 'is_active'] as $key) {
foreach (['id', 'offering_id', 'scope', 'label', 'field_type', 'options', 'audience', 'is_required', 'is_required_child', 'sort_order', 'is_active'] as $key) {
self::assertArrayHasKey($key, $arr);
}
}
public function testDefaultsToBeingAskedOfEveryoneAndRequiredOfNobody(): void
{
$question = new Question(null, 'Instrument', scope: Question::SCOPE_ACCOUNT);
self::assertSame(Question::AUDIENCE_ALL, $question->audience);
self::assertTrue($question->askedOfSelf());
self::assertFalse($question->isRequiredForSelf());
self::assertFalse($question->isRequiredForChild());
}
public function testAChildAudienceQuestionIsNeverAskedOfTheAccountHolder(): void
{
$question = new Question(
null,
'School and grade',
scope: Question::SCOPE_ACCOUNT,
audience: Question::AUDIENCE_CHILD,
isRequired: true,
isRequiredChild: true
);
self::assertFalse($question->askedOfSelf());
// Required-ness cannot outlive the audience: a question the account
// holder is never shown must never be one they are held to.
self::assertFalse($question->isRequiredForSelf());
self::assertTrue($question->isRequiredForChild());
}
public function testAQuestionCanBeOptionalForYouAndRequiredForYourStudents(): void
{
$question = new Question(
null,
'Previous experience',
scope: Question::SCOPE_ACCOUNT,
isRequired: false,
isRequiredChild: true
);
self::assertTrue($question->askedOfSelf());
self::assertFalse($question->isRequiredForSelf());
self::assertTrue($question->isRequiredForChild());
}
public function testFromRowReadsAudienceAndTheStudentsRequiredFlag(): void
{
$row = (object) [
'id' => '6',
'offering_id' => null,
'scope' => Question::SCOPE_ACCOUNT,
'label' => 'School and grade',
'field_type' => Question::FIELD_TEXT,
'options' => null,
'audience' => Question::AUDIENCE_CHILD,
'is_required' => '0',
'is_required_child' => '1',
'sort_order' => '0',
'is_active' => '1',
];
$question = Question::fromRow($row);
self::assertSame(Question::AUDIENCE_CHILD, $question->audience);
self::assertFalse($question->askedOfSelf());
self::assertTrue($question->isRequiredForChild());
}
/**
* A row read before dbDelta has added the columns or one carrying a value
* no longer recognised falls back to the behaviour every question had
* before the distinction existed: asked of everyone.
*/
public function testFromRowFallsBackToEveryoneWhenAudienceIsMissingOrUnknown(): void
{
$base = [
'id' => '7',
'offering_id' => null,
'scope' => Question::SCOPE_ACCOUNT,
'label' => 'Instrument',
'field_type' => Question::FIELD_TEXT,
'options' => null,
'is_required' => '1',
'sort_order' => '0',
'is_active' => '1',
];
$missing = Question::fromRow((object) $base);
$unknown = Question::fromRow((object) ($base + ['audience' => 'grown-ups']));
self::assertSame(Question::AUDIENCE_ALL, $missing->audience);
self::assertTrue($missing->isRequiredForSelf());
self::assertFalse($missing->isRequiredForChild());
self::assertSame(Question::AUDIENCE_ALL, $unknown->audience);
}
public function testValidAudienceConstants(): void
{
self::assertContains(Question::AUDIENCE_ALL, Question::VALID_AUDIENCES);
self::assertContains(Question::AUDIENCE_CHILD, Question::VALID_AUDIENCES);
}
public function testValidFieldTypeConstants(): void
{
self::assertContains(Question::FIELD_TEXT, Question::VALID_FIELD_TYPES);
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.4.1
* Version: 1.5.5
* Requires at least: 6.2
* Requires PHP: 8.1
* Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit;
}
define('USC_VERSION', '1.4.1');
define('USC_VERSION', '1.5.5');
define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));