107 Commits
Author SHA1 Message Date
thatguygriff 3a25c397c5 Merge pull request 'Collapse the lesson-type filter behind a Show Only button' (#120) from feature/lesson-type-filter-collapsed into main
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / Tests (PHP 8.1) (push) Successful in 53s
CI / PHPStan (push) Successful in 2m56s
CI / Coding Standards (push) Successful in 2m58s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
CI / Build Plugin Zip (push) Successful in 2m48s
Release / Build and Publish Release (push) Successful in 2m49s
Release / Open next-version bump PR (push) Successful in 4s
Reviewed-on: #120
2026-07-28 16:20:25 +00:00
thatguygriffandClaude Opus 5 264d9cba01 Add per-embed lesson-type and section options to the booking block
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 54s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 2m56s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
Three sidebar options on the Lesson Booking block, all mirrored as shortcode
attributes and carried to the front end as data attributes on
#us-booking-app (or as omitted containers):

- Lesson type (lessonTypeId / lesson_type) pins the calendar to a single
  private-lesson type: only the times bookable as it are listed, and it is
  the only type bookable there, auto-selected on the registration form. A
  pinned type that is no longer offered says so instead of showing an empty
  calendar.
- Show the lesson-type filter (showTypeFilter / show_filter) drops the
  "Show Only" control for studios that do not want it.
- Sections (displayMode / show) embeds one half of the page — the booking
  calendar or the student's upcoming lessons — so the two can live on
  different pages. The script skips the work belonging to a missing half:
  no availability or catalog request for an upcoming-only embed, no
  bookings request for a booking-only one. An unrecognised value renders
  the whole page. The editor preview follows the same setting.

Also fixes the expanded filter's first lesson type sharing a line with the
"Lesson type" heading — the choices now sit in their own row beneath it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 13:16:18 -03:00
thatguygriff 689ec833f3 Merge pull request 'Let offering managers read the offerings catalogue' (#122) from fix/offerings-read-permission into main
CI / Tests (PHP 8.1) (push) Successful in 46s
CI / Tests (PHP 8.2) (push) Successful in 49s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m58s
CI / PHPStan (push) Successful in 2m57s
CI / Tests (PHP 8.3) (push) Successful in 2m41s
CI / Build Plugin Zip (push) Successful in 2m49s
Reviewed-on: #122
2026-07-28 16:07:09 +00:00
thatguygriffandClaude Opus 5 0f30f28e92 Let offering managers read the offerings catalogue
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 56s
CI / PHPStan (pull_request) Successful in 2m56s
CI / Coding Standards (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
The block editor's group-class picker fetches GET /offerings, whose
permission callback only accepted book_lesson — a capability held by
students alone. Administrators and instructors editing a page were
rejected with a 403 and the picker silently rendered an empty list.

Read access now accepts book_lesson or manage_offerings. The listing is
unchanged: active offerings only, public ones plus the invite-only
classes the caller has been granted, without the e-transfer email.

Closes #121

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:54:58 -03:00
thatguygriffandClaude Opus 5 9d11cc3b01 Collapse the lesson-type filter behind a Show Only button
CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / Tests (PHP 8.2) (pull_request) Successful in 54s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m52s
CI / PHPStan (pull_request) Successful in 3m3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
The filter took a row of the booking calendar before a student had asked for
it. The view toggle and a new "Show Only" button now share one control row,
and the lesson-type list is revealed between that row and the calendar.

The list stays open across re-renders once revealed, and collapsing it leaves
the filter applied — the button keeps its active styling and carries the
number of ticked types, so a collapsed filter is never invisible.

Closes #119

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:47:24 -03:00
thatguygriff add6605141 Merge pull request 'Filter booking calendar slots by available lesson type' (#118) from feature/lesson-type-filter into main
CI / Tests (PHP 8.1) (push) Successful in 53s
CI / Tests (PHP 8.2) (push) Successful in 52s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m49s
CI / Coding Standards (push) Successful in 2m58s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
CI / Build Plugin Zip (push) Successful in 2m49s
Reviewed-on: #118
2026-07-28 15:32:14 +00:00
thatguygriffandClaude Opus 5 edcacae816 Filter booking calendar slots by available lesson type
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 51s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m51s
CI / PHPStan (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped
Not every open time can be booked as every private-lesson type: a slot tied
to an offering takes that offering only, and a generic slot only takes types
whose length fits. Students had no way to see that before clicking a time.

The booking calendar now carries a lesson-type filter — a checkbox per active
private-lesson type, fetched once from GET /offerings?kind=private_lesson.
Ticking types narrows the calendar to the times bookable as one of them and
re-anchors the week view on the earliest match. The registration form's
Lesson type picker is narrowed the same way, and a lone remaining type is
pre-selected with its intake questions loaded.

Bookability is decided by offeringFitsSlot(), the client-side mirror of the
rule POST /bookings enforces; the filter is a browsing aid and the server
still validates every booking. No ticks means no filter, and the whole
control is hidden when the studio offers fewer than two private-lesson types.

Closes #117

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:27:37 -03:00
thatguygriff 17487cde46 Merge pull request 'Send students to a chosen page when registration succeeds' (#116) from feature/registration-success-redirect into main
CI / Tests (PHP 8.2) (push) Successful in 54s
CI / Tests (PHP 8.1) (push) Successful in 55s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m47s
CI / PHPStan (push) Successful in 2m56s
CI / Tests (PHP 8.3) (push) Successful in 2m41s
CI / Build Plugin Zip (push) Successful in 2m49s
Reviewed-on: #116
2026-07-28 15:04:44 +00:00
thatguygriffandClaude Opus 5 13d6b3e14e Send students to a chosen page when registration succeeds
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m0s
CI / Coding Standards (pull_request) Successful in 2m51s
CI / PHPStan (pull_request) Successful in 2m57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 2s
The Student Registration block's "After email confirmation" panel becomes
"After registration": the page it selects is now where a newly registered
student continues to, and a new autoRedirect toggle sends them there
instead of showing the link.

Only the two finished states qualify (RegistrationPage::isRegistrationComplete):
an invited student who is now logged in, and a self-signup back from the
emailed confirmation link. A validation error, an expired confirmation
link, and the intermediate "check your email" step all stay on the page so
their message is read.

The invited-student success previously had no link at all; it gains a
"Continue to your account" one. That path deliberately has no
WordPress-login-screen fallback — pointing someone already signed in at the
login screen helps nobody — so continueUrl() distinguishes "no page chosen"
from "page chosen", and the redirect does nothing until one is picked.

Closes #115

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 10:58:17 -03:00
thatguygriff d866aa7295 Merge pull request 'Omit the class description when the group block shows a single class' (#113) from feature/group-class-single-embed-description into main
CI / Tests (PHP 8.1) (push) Successful in 1m9s
CI / PHPStan (push) Successful in 2m56s
CI / Tests (PHP 8.3) (push) Successful in 2m41s
CI / Build Plugin Zip (push) Successful in 2m48s
CI / Tests (PHP 8.2) (push) Successful in 1m2s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 3m4s
Reviewed-on: #113
2026-07-28 13:48:07 +00:00
thatguygriffandClaude Opus 5 c4acdb7ca4 Omit the class description when the group block shows one class
CI / Tests (PHP 8.1) (pull_request) Successful in 1m13s
CI / Coding Standards (pull_request) Successful in 3m1s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m2s
CI / PHPStan (pull_request) Successful in 3m3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
The Group Classes block can be pinned to a single class via its Class
option so it can be embedded on a page dedicated to that class. On such a
page the surrounding copy already describes the class, so the card
repeated it. In single-class mode the description is now left out and the
card shows only the schedule, instructor, schedule note, price, enrolment
deadline and the enrol/withdraw controls.

The editor preview follows the same rule: BlockPreview::groupClasses()
takes the mode from the block's offeringId attribute, drops the sample
description when a class is pinned, and notes what the published page
shows. Its sample card also gained the .us-class-when and
.us-enrol-deadline elements the live markup has always rendered.

Closes #114

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 10:39:09 -03:00
thatguygriff 30928addf8 Merge pull request 'Bump version to 1.2.2' (#112) from release/bump-1.2.2 into main
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / Tests (PHP 8.1) (push) Successful in 48s
CI / No Debug Code (push) Successful in 3s
CI / Coding Standards (push) Successful in 2m47s
CI / PHPStan (push) Successful in 3m12s
CI / Tests (PHP 8.3) (push) Successful in 2m41s
CI / Build Plugin Zip (push) Successful in 2m48s
Reviewed-on: #112
2026-07-24 23:56:56 +00:00
Release Bot 27793fa0aa Bump version to 1.2.2 and open changelog section 2026-07-24 23:31:45 +00:00
thatguygriff c611268bdb Merge pull request 'Fix field-length saves, student wp-admin access, and empty instructor picker' (#111) from fix/field-length-student-admin-instructor-picker into main
CI / Tests (PHP 8.1) (push) Successful in 39s
CI / Tests (PHP 8.2) (push) Successful in 1m2s
CI / No Debug Code (push) Successful in 3s
CI / PHPStan (push) Successful in 2m54s
CI / Coding Standards (push) Successful in 2m58s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
Release / Build and Publish Release (push) Successful in 2m59s
Release / Open next-version bump PR (push) Successful in 5s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #111
2026-07-24 23:27:30 +00:00
thatguygriffandClaude Opus 4.8 721c4be1d6 Fix field-length saves, student wp-admin access, and empty instructor picker
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m47s
CI / PHPStan (pull_request) Successful in 3m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Three bug fixes for the 1.2.1 section:

- Fixed-size fields (question labels, offering titles/notes/e-transfer
  email, policy titles/slugs) no longer silently fail to save when the
  value exceeds its column length. The REST endpoints reject over-long
  values with a 400, the admin controllers refuse to insert them, and the
  form inputs carry a maxlength so the browser blocks over-long entry.
  Limits are MAX_* constants on the value objects, kept in lockstep with
  the schema columns.

- Students are kept out of wp-admin entirely. New StudentAdminGuard
  redirects front-end-only users (no back-office capability) away from the
  dashboard and hides the admin bar for them, while administrators, studio
  admins, and instructors keep full access.

- The Add/Edit Offering instructor picker now includes WordPress
  administrators when they act as instructors (the default single-account
  setup), so a solo studio owner is selectable instead of the dropdown
  being empty.

composer test (618), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 20:22:04 -03:00
thatguygriff 3aa65bad06 Merge pull request 'Bump version to 1.2.1' (#110) from release/bump-1.2.1 into main
CI / Tests (PHP 8.1) (push) Successful in 41s
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m52s
CI / Coding Standards (push) Successful in 3m4s
CI / Tests (PHP 8.3) (push) Successful in 2m41s
CI / Build Plugin Zip (push) Successful in 2m49s
Reviewed-on: #110
2026-07-24 19:34:30 +00:00
Release Bot f3ba09b195 Bump version to 1.2.1 and open changelog section 2026-07-24 19:34:11 +00:00
thatguygriff 771942be8b Merge pull request 'Fix invite sign-in, add customizable invite-only text, repair account questions' (#109) from fix/registration-signin-and-account-questions into main
CI / Tests (PHP 8.1) (push) Successful in 40s
CI / Tests (PHP 8.2) (push) Successful in 1m5s
CI / No Debug Code (push) Successful in 3s
CI / Coding Standards (push) Successful in 2m53s
CI / PHPStan (push) Successful in 2m52s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
Release / Build and Publish Release (push) Successful in 3m1s
Release / Open next-version bump PR (push) Successful in 4s
CI / Build Plugin Zip (push) Successful in 2m47s
Reviewed-on: #109
2026-07-24 19:29:52 +00:00
thatguygriffandClaude Opus 4.8 242150569b Fix invite sign-in persistence, add invite-only text option, repair account questions
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 3m6s
CI / Build Plugin Zip (pull_request) Skipped
Three registration fixes reported from live use:

- Accepting an invite now keeps the student signed in. The form was
  processed inside render() during the_content, so wp_set_auth_cookie()
  ran after headers were sent and the cookie never persisted — the new
  student was bounced back to the logged-out registration page. The
  submission is now handled on template_redirect (before output) with a
  post/redirect/get, so the cookie sticks and the student lands logged in.

- The "registration is by invitation only" message is now customisable via
  a new block attribute (inviteOnlyMessage / shortcode invite_only_message),
  falling back to the default wording when blank.

- Account-registration questions save again. dbDelta does not reliably
  relax a column from NOT NULL to NULL, so sites created before account-
  scope questions kept us_questions.offering_id NOT NULL and rejected
  account inserts ("Column 'offering_id' cannot be null"). A one-time,
  self-healing migration (guarded by its own option, not the version gate)
  re-applies the nullable definition on next load.

composer test, composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 16:24:15 -03:00
thatguygriff fae1fd08ba Merge pull request 'Add group-class withdrawal deadline and kind-aware offering form' (#108) from feature/group-class-withdrawal-deadline into main
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.2) (push) Successful in 47s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m58s
CI / PHPStan (push) Successful in 3m18s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #108
2026-07-24 19:01:48 +00:00
thatguygriffandClaude Opus 4.8 2c4b481077 Add group-class withdrawal deadline and kind-aware offering form
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / Tests (PHP 8.2) (pull_request) Successful in 59s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Group classes now carry an optional per-class withdrawal deadline. Up to
that day a student may withdraw themselves from the class; the withdrawal
frees the seat and voids any pending payment but never issues an account
credit. After the deadline self-withdrawal closes and a studio admin must
withdraw the student by hand (the admin path is never subject to the
deadline). A blank deadline keeps self-withdrawal open indefinitely.

Also make the Add/Edit Offering form show only the fields relevant to the
selected kind: group settings for group classes, weekly reservation for
private lessons. Progressive enhancement — without JS every field renders.

- New nullable us_offerings.withdrawal_deadline column; Offering model gains
  $withdrawalDeadline + isWithdrawalOpen().
- New student endpoint POST /enrollments/{id}/withdraw, gated by the deadline
  (403 withdrawal_closed), ownership-checked, idempotent.
- Front-end group-class page shows a Withdraw button while open.
- No USC_VERSION bump: 1.2.0 is unreleased and accumulates schema changes
  under its section, matching the scheduled-billing and credit features.

Tests: composer test (596), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 15:56:40 -03:00
thatguygriff f552c3952a Merge pull request 'Credit students for cancelled paid lessons' (#107) from feature/credit-cancelled-paid-lessons into main
CI / Tests (PHP 8.1) (push) Successful in 40s
CI / Tests (PHP 8.2) (push) Successful in 1m2s
CI / No Debug Code (push) Successful in 3s
CI / PHPStan (push) Successful in 2m52s
CI / Coding Standards (push) Successful in 2m57s
CI / Tests (PHP 8.3) (push) Successful in 2m38s
CI / Build Plugin Zip (push) Successful in 2m46s
Reviewed-on: #107
2026-07-24 18:40:10 +00:00
thatguygriffandClaude Opus 4.8 e8e66eef3c Credit students for cancelled paid lessons
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m52s
Cancelling a lesson that was already paid for now credits the student
that money instead of leaving it as a manual refund, and the daily
scheduled-billing scan applies any available credit against their due
charges before emailing the notice.

- New us_credits ledger + us_payments.credit_applied column (Payment::netDue).
- PaymentService::creditForCancelledLesson issues a per-lesson share of the
  covering payment's total; wired into all three cancel paths (student
  self-cancel, instructor status update, admin student-detail cancel).
- PaymentService::applyCredits draws credit down FIFO across a run's charges,
  marking a fully-covered charge paid-by-credit; the notice shows the credit
  applied and reduced total, and the admin queue shows net due.
- Student detail page shows a student's credit balance and history.

Ships as part of the unreleased 1.2.0 (same release as scheduled billing).

Tests: composer test (585), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 15:32:20 -03:00
thatguygriff cf296329a0 Merge pull request 'Consolidate changelog into 1.2.0 section ahead of release' (#106) from docs/changelog-1.2.0 into main
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / Tests (PHP 8.1) (push) Successful in 48s
CI / PHPStan (push) Successful in 2m52s
CI / Coding Standards (push) Successful in 3m8s
CI / Build Plugin Zip (push) Successful in 2m50s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 2m38s
Reviewed-on: #106
2026-07-24 18:14:15 +00:00
thatguygriffandClaude Opus 4.8 3f9aef7746 Consolidate changelog into 1.2.0 section
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Failing after 2m33s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
The plugin header carries 1.2.0 but CHANGELOG.md still topped out at the
untagged 1.1.3 section, and two shipped features (#104 lesson booking
detail, #105 weekly/monthly scheduled billing) were unrecorded. Neither
1.1.2 nor 1.1.3 was ever tagged, so their changes belong to the 1.2.0
release. Merge the untagged sections into a single 1.2.0 section and add
the two missing features so the release workflow publishes real notes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 12:55:08 -03:00
thatguygriff d1dd30dc60 Merge pull request 'Add weekly and monthly scheduled billing for offerings' (#105) from feature/weekly-monthly-billing into main
CI / Tests (PHP 8.2) (push) Successful in 47s
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m46s
CI / PHPStan (push) Successful in 3m16s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
CI / Build Plugin Zip (push) Successful in 2m46s
Reviewed-on: #105
2026-07-24 15:38:59 +00:00
thatguygriffandClaude Opus 4.8 4328e8fb5f Add weekly and monthly scheduled billing for offerings
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m12s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m39s
CI / Build Plugin Zip (pull_request) Skipped
Offerings can now bill weekly (a pending payment 24h before each lesson)
or monthly (one payment on the 1st for that month's lessons), alongside
one-time and full-term. Applies to both private lessons and group classes.

- Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()`
- Booking/enrolment defer payment for scheduled modes; a single lesson
  booked after its due date has passed (e.g. an add-on in an already-billed
  month) is charged at booking instead
- ScheduledBillingRunner: daily WP-Cron scan generates due payments across
  four cases (private/group × weekly/monthly), deduped via lesson.payment_id
  and payments.period_key
- PaymentDueMailer: one consolidated itemised email per student per scan
- Notice batch: payments emailed together share a reference; the admin
  Payments queue groups them with a lump-sum total for e-transfer reconciliation
- Cancellation never voids a scheduled payment (Payment::isScheduled())
- Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0

composer test, composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 12:06:37 -03:00
thatguygriff 36e7178158 Merge pull request 'Show booked lesson info on upcoming lists and add admin booking detail' (#104) from feature/lesson-booking-detail into main
CI / Tests (PHP 8.2) (push) Successful in 39s
CI / Coding Standards (push) Successful in 2m52s
CI / PHPStan (push) Successful in 2m55s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
CI / Tests (PHP 8.1) (push) Successful in 55s
CI / No Debug Code (push) Successful in 2s
CI / Build Plugin Zip (push) Successful in 2m46s
Reviewed-on: #104
2026-07-24 14:12:33 +00:00
thatguygriffandClaude Opus 4.8 32619a1b75 Show booked lesson info on upcoming lists and add admin booking detail
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
Front end: the student "upcoming lessons" panel now shows each booked
offering's name and length next to the time, and renders only the soonest
five lessons with a "Show all" reveal. GET /bookings returns offering_title
and duration_minutes so the list needs no extra request.

Admin: the Scheduler and My Lessons week/list views now show the booked
offering, and each lesson links to a detail view showing the policy versions
the student accepted (with acceptance time and IP) and their intake answers.
On My Lessons an instructor may only open their own lessons; the studio
Scheduler may open any.

composer test / composer lint / composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 11:07:06 -03:00
thatguygriff 1a447743b3 Merge pull request 'Group-class enrolment deadline with instructor late-enrolment override' (#103) from feature/group-class-enrollment-deadline into main
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / PHPStan (push) Successful in 2m52s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Tests (PHP 8.2) (push) Successful in 47s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 3m4s
CI / Build Plugin Zip (push) Successful in 2m47s
Reviewed-on: #103
2026-07-24 13:45:54 +00:00
thatguygriffandClaude Opus 4.8 fc7c0fa966 Show "Enrol by <date>" on the group-class card while enrolment is open
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m47s
CI / Coding Standards (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
The front end used the deadline only to gate the Enrol button; students had
no way to see when enrolment closes. Add an "Enrol by <date>" line to each
class card, shown while enrolment is still open, for the effective deadline
(the instructor's date, or the first class day by default).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 10:36:51 -03:00
thatguygriffandClaude Opus 4.8 bf29162587 Add group-class enrolment deadline with instructor late-enrolment override
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 3m2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped
Group classes gain an instructor-set enrolment deadline (new
us_offerings.enrollment_deadline column) that defaults to the first day of
the class (term_start). Past the deadline students can no longer self-enrol:
the enrolment endpoint rejects it (403 enrollment_closed) and the front-end
class list shows "Enrolment has closed." in place of the Enrol button.

Instructors keep a manual path: the "Add students directly" control on each
class's details page now renders for public classes too (not just
invite-only) and deliberately bypasses the deadline and capacity, so a
student can be added as a late enrolment after the class has closed. Past
the deadline the details page labels these as late enrolments.

Bumps USC_VERSION to 1.1.3 for the schema change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 10:19:38 -03:00
thatguygriff 991ed2f5ad Merge pull request 'Bump version to 1.1.2' (#102) from release/bump-1.1.2 into main
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
CI / Coding Standards (push) Successful in 2m45s
CI / PHPStan (push) Successful in 3m15s
CI / Build Plugin Zip (push) Successful in 2m53s
Reviewed-on: #102
2026-07-24 11:56:08 +00:00
Release Bot ad2ddefebf Bump version to 1.1.2 and open changelog section 2026-07-24 11:54:08 +00:00
thatguygriff 1fe28d5575 Merge pull request 'Show the Enable auto-updates toggle for the self-updater' (#101) from feature/auto-update-toggle into main
CI / Coding Standards (push) Successful in 2m52s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 40s
CI / Tests (PHP 8.1) (push) Successful in 54s
CI / PHPStan (push) Successful in 2m49s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
Release / Build and Publish Release (push) Successful in 3m1s
Release / Open next-version bump PR (push) Successful in 5s
CI / Build Plugin Zip (push) Successful in 2m47s
Reviewed-on: #101
2026-07-24 11:47:24 +00:00
thatguygriffandClaude Opus 4.8 51dd032668 Show the Enable auto-updates toggle for the self-updater
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m7s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Build Plugin Zip (pull_request) Skipped
WordPress only renders the "Enable auto-updates" toggle for a plugin that
appears in the update_plugins transient's response or no_update list, which
is what sets core's update-supported flag. UpdateChecker only populated the
response side (when a newer release existed), so between releases the plugin
was absent from the transient and the toggle never showed.

provideUpdate() now returns a no_update payload (installed version, empty
package) whenever no newer release is offered — including when the release
lookup fails — so the plugin stays in the transient and the toggle appears.
The response path (one-click and unattended updates) is unchanged.

Bumps to 1.1.1 so the fix ships to installed sites via the self-updater.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 08:46:31 -03:00
thatguygriff 37ec8a3315 Merge pull request 'Bump version to 1.1.1' (#100) from release/bump-1.1.1 into main
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / Tests (PHP 8.1) (push) Successful in 46s
CI / PHPStan (push) Successful in 2m47s
CI / Coding Standards (push) Successful in 2m50s
CI / Build Plugin Zip (push) Successful in 2m49s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
Reviewed-on: #100
2026-07-23 21:29:37 +00:00
Release Bot f93c5aba05 Bump version to 1.1.1 and open changelog section 2026-07-23 21:09:01 +00:00
thatguygriff 013558019e Merge pull request 'Group-class scheduling, instructor assignment, and details/invite management' (#99) from feature/group-class-scheduling into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 49s
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / Coding Standards (push) Successful in 2m54s
CI / PHPStan (push) Successful in 2m54s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Build Plugin Zip (push) Successful in 2m46s
Release / Build and Publish Release (push) Successful in 2m47s
Release / Open next-version bump PR (push) Successful in 4s
Reviewed-on: #99
2026-07-23 20:56:59 +00:00
thatguygriffandClaude Opus 4.8 87cfe921a9 Show instructor real name or nickname in group-class views, not the login
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m47s
CI / Coding Standards (pull_request) Successful in 2m51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Add Auth\UserName::format(), which prefers a user's first + last name, then
their nickname, avoiding display_name (which can be the login/username).
Route the instructor name through it in both the front-end offerings response
(instructor_name) and the back-end group-class summary and details views.

Tests: composer test (513), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 17:50:34 -03:00
thatguygriff 00376799b9 Merge pull request 'Group-class scheduling, instructor assignment, and details/invite management' (#98) from feature/group-class-scheduling into main
CI / Coding Standards (push) Successful in 2m51s
CI / PHPStan (push) Successful in 2m55s
CI / Tests (PHP 8.3) (push) Successful in 2m37s
CI / Tests (PHP 8.2) (push) Successful in 37s
CI / Tests (PHP 8.1) (push) Successful in 51s
CI / No Debug Code (push) Successful in 2s
CI / Build Plugin Zip (push) Successful in 2m47s
Reviewed-on: #98
2026-07-23 20:34:16 +00:00
thatguygriffandClaude Opus 4.8 b066bef353 Add group-class scheduling, instructor assignment, and details/invite management
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m52s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
Group classes now carry a specific class time (alongside date and duration)
and an assigned instructor:

- Schema: add `class_time` (TIME) to `us_offerings`; `Offering` gains
  `normalizeTime`/`sessionWindows`. (Rides the pending 1.0.0->1.1.0 dbDelta
  upgrade, so no version bump.)
- Offering form: class-time field, plus a studio-admin instructor picker
  (plain instructors always own their own classes).
- `ClassSlotReconciler`: assigning an instructor clears their open booking
  slots overlapping each session and flags already-booked lessons that clash
  (a booked lesson is never deleted). Uses new
  `AvailabilityRepository::findOverlapping`.
- Front end: `GET /offerings` exposes `instructor_name`; the enrolment page
  shows who teaches each class and when it meets.

Back-office group-class views redesigned:

- Instructor **My Group Classes** and studio-admin **Group Classes** are now
  per-class summaries with enrolment counts, not flat student lists.
- Each links through (`?class_id=<id>`) to a per-class **details page**
  (schedule panel, roster with payment status, and — for invite-only classes
  — the add/make-available/invite-by-email controls). Invite-only membership
  is managed entirely from this page.
- Invite actions are allowed for the class's owning instructor or any
  `view_all_lessons` studio admin, so an owner-operator (studio admin who also
  teaches) can reach every class's roster and invites from the Group Classes
  page.

Tests: composer test (508), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 17:29:48 -03:00
thatguygriff bba24566a6 Merge pull request 'Add CHANGELOG and publish it as release notes; auto-open next-version bump PR' (#97) from feature/changelog-release-notes into main
CI / Tests (PHP 8.1) (push) Successful in 39s
CI / Tests (PHP 8.2) (push) Successful in 48s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m53s
CI / Coding Standards (push) Successful in 2m56s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
CI / Build Plugin Zip (push) Successful in 2m48s
Reviewed-on: #97
2026-07-23 19:54:40 +00:00
thatguygriffandClaude Opus 4.8 4bca2fbc96 Add CHANGELOG and publish it as release notes; auto-open next-version bump PR
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 7m54s
CI / PHPStan (pull_request) Successful in 9m2s
CI / Coding Standards (pull_request) Successful in 9m12s
CI / Build Plugin Zip (pull_request) Skipped
Add CHANGELOG.md (one section per version, newest first; the top section
always reflects the current plugin header version — release status is
purely a matter of tagging).

The Release workflow now extracts the tagged version's changelog section
and publishes it as the Gitea release body (POST on create, PATCH when the
release was pre-created via the UI). After a stable release, a new
bump-version job bumps the plugin to the next patch version, opens a fresh
changelog section, and opens a PR. Pre-release tags are skipped.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 16:34:52 -03:00
thatguygriff 06c8d42cc0 Merge pull request 'Add invite-only group classes' (#96) from feature/invite-only-group-classes into main
CI / Tests (PHP 8.1) (push) Successful in 46s
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m49s
CI / Coding Standards (push) Successful in 2m58s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #96
2026-07-23 16:55:37 +00:00
thatguygriffandClaude Opus 4.8 a281935811 Add invite-only group classes
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Group classes can now be marked invite-only (us_offerings.access_mode).
Invite-only classes are hidden from the public catalog and reachable only
when the instructor lets someone in via one of three paths, managed from
My Lessons -> My Group Classes:

- Add students directly: enrols them now with a pending payment.
- Make available: grants registered students access to self-enrol through
  the normal paid flow (multi-select, emailed a notice).
- Invite by email: tokenised registration invite tied to the class for a
  non-account address; after they register the class becomes enrollable.
  Reuses an existing pending invite instead of sending a second link.

New us_group_access table records grants; GET /offerings merges granted
invite-only classes for the caller; enrolment requires a grant
(403 invite_required) and flips it to enrolled on success.

composer test (487), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 13:51:02 -03:00
thatguygriff 25aeba9dc1 Merge pull request 'Add instructor group-class roster view under My Lessons' (#95) from feature/instructor-group-classes into main
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m53s
CI / PHPStan (push) Successful in 2m49s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Build Plugin Zip (push) Successful in 2m49s
Reviewed-on: #95
2026-07-23 15:58:09 +00:00
thatguygriffandClaude Opus 4.8 5f9d5ffc4f Add instructor group-class roster view under My Lessons
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / Tests (PHP 8.1) (pull_request) Successful in 38s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m50s
CI / PHPStan (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
Instructors can now see their own group classes under My Lessons →
My Group Classes (view_own_lessons): each class shows its active
enrolment count against capacity plus a roster of enrolled students
with enrolment and payment status.

GroupClassController gains renderInstructorPage(), backed by the
existing per-instructor enrolment query and a newly injected
PaymentRepository for payment status. Wired as a submenu under the
existing My Lessons menu, inside the same !view_all_lessons guard so
owner-operators don't get a duplicate item.

Closes #71

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 12:52:27 -03:00
thatguygriff 90fdde8c06 Merge pull request 'Cancellation cutoff: limit how close to a lesson a student can cancel' (#94) from feature/cancellation-cutoff into main
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / Tests (PHP 8.1) (push) Successful in 49s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m47s
CI / Coding Standards (push) Successful in 2m59s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Build Plugin Zip (push) Successful in 2m44s
Reviewed-on: #94
2026-07-23 15:14:56 +00:00
thatguygriffandClaude Opus 4.8 169f7b6a13 Accept whole days only for the studio cancellation cutoff
CI / Tests (PHP 8.2) (pull_request) Successful in 38s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m54s
CI / Coding Standards (pull_request) Successful in 3m2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
The Studio Settings cutoff field now takes an integer number of days (step 1,
coerced with Val::int) instead of allowing half-day fractions, and displays the
stored hours rounded to whole days.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 12:10:55 -03:00
thatguygriffandClaude Opus 4.8 8b90b8d78d Add cancellation cutoff limiting how close to a lesson a student can cancel
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
Students can no longer cancel their own lesson online once it starts within a
configured window; instructors and studio admins can always cancel.

- Studio default `us_cancellation_cutoff_hours` (stored/computed in hours,
  entered and displayed in days under Studio Settings → Cancellations).
- Optional per-offering override `cancellation_cutoff_hours` (entered in hours);
  blank inherits the studio default, 0 allows anytime cancellation.
- `Booking\CancellationPolicy` resolves the effective window and decides;
  `BookingEndpoint::cancel()` returns a 403 `cancellation_closed` when too late.
  The instructor status endpoint and studio-admin student actions bypass it.

Closes #93

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 11:57:18 -03:00
thatguygriff a777f5d05a Merge pull request 'Point plugin metadata links at Unsupervised and Gitea' (#92) from feature/view-details-gitea-releases into main
CI / Tests (PHP 8.1) (push) Successful in 42s
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / PHPStan (push) Successful in 2m46s
CI / Coding Standards (push) Successful in 3m2s
CI / Build Plugin Zip (push) Successful in 2m46s
Reviewed-on: #92
2026-07-23 14:26:51 +00:00
thatguygriffandClaude Opus 4.8 83be388186 Point plugin metadata links at Unsupervised and Gitea
CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m21s
CI / Coding Standards (pull_request) Successful in 2m52s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
Make the "By Unsupervised" author link go to https://unsupervised.ca
(via a new Author URI header) and the plugin site / "View details" link
point at the Gitea project instead of WordPress.org.

Core's "View details" link opened a thickbox iframe against the
WordPress.org plugin-information API, which 404s ("Plugin not found")
for this off-directory plugin. A plugin_row_meta filter now replaces it
with a new-tab link to the matching Gitea release tag page; embedding
Gitea in the iframe is blocked by its X-Frame-Options anyway.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 11:14:44 -03:00
thatguygriff 8d6615da67 Merge pull request 'Studio-defined account-registration questions' (#91) from feature/account-registration-questions into main
CI / Tests (PHP 8.1) (push) Successful in 43s
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / Build Plugin Zip (push) Successful in 2m48s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m48s
CI / PHPStan (push) Successful in 2m56s
CI / Tests (PHP 8.3) (push) Successful in 2m35s
Reviewed-on: #91
2026-07-23 13:50:34 +00:00
thatguygriffandClaude Opus 4.8 49c59a950c Add studio-defined account-registration questions
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m46s
CI / PHPStan (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Studio admins can now define registration questions that every new student
answers as a required second step during signup, with each student's answers
shown under a "Registration Information" section in the admin.

Extends the existing Registration domain: us_questions gains a scope column
(offering | account) and a nullable offering_id, and account answers reuse
us_question_answers with registration_type = 'account'. Authoring reuses the
Offerings -> Questions page via an "Account signup" scope (studio-admin only).
The registration form becomes two steps (progressive enhancement via
assets/js/register.js; works without JS); required answers are validated before
the account is created and apply to all signup paths (invite, group link,
self-approval). StudentHistory::registrationInfo() powers the admin section.

Bumps the plugin version to 1.1.0 so dbDelta runs the schema migration.

Closes #90

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 09:59:59 -03:00
thatguygriff 34e8f660ab Merge pull request 'Bump version to 1.0.0 for the first stable release' (#89) from release/1.0.0 into main
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / Coding Standards (push) Successful in 2m47s
CI / PHPStan (push) Successful in 2m54s
CI / Tests (PHP 8.3) (push) Successful in 2m38s
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / No Debug Code (push) Successful in 2s
CI / Build Plugin Zip (push) Successful in 2m34s
Release / Build and Publish Release (push) Successful in 2m45s
Reviewed-on: #89
2026-07-22 15:02:46 +00:00
thatguygriffandClaude Fable 5 358deda868 Bump version to 1.0.0 for the first stable release
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 38s
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Coding Standards (pull_request) Successful in 2m50s
CI / PHPStan (pull_request) Successful in 2m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m34s
CI / Build Plugin Zip (pull_request) Skipped
Version header, USC_VERSION, and the README version line move from
1.0.0-rc.3 (README was stale at rc.1) to 1.0.0. Tag v1.0.0 on the merge
commit to publish the release.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 11:59:17 -03:00
thatguygriff 9d8d93ea60 Merge pull request 'Bump plugin version so the us_invites schema migration actually runs' (#88) from fix/invite-schema-migration into main
CI / Build Plugin Zip (push) Successful in 2m34s
CI / Tests (PHP 8.1) (push) Successful in 45s
CI / Tests (PHP 8.2) (push) Successful in 52s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m48s
CI / Coding Standards (push) Successful in 2m52s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
Reviewed-on: #88
2026-07-22 14:41:20 +00:00
thatguygriffandClaude Fable 5 0d9aafbb5b Bump plugin version so the us_invites schema migration actually runs
CI / Tests (PHP 8.2) (pull_request) Successful in 37s
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / PHPStan (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
PR #83 added kind and expires_at to us_invites and the repository started
writing them, but USC_VERSION stayed at 1.0.0-rc.2 — Plugin::boot() only
re-runs Installer/dbDelta on a version mismatch, so upgraded sites never got
the columns. Every invite insert then failed silently: nothing appeared under
Pending Invites while the admin was still shown a registration link whose
token hash was never stored.

- Version / USC_VERSION -> 1.0.0-rc.3 (triggers dbDelta on next load).
- InviteRepository::insert() returns 0 on failure instead of a stale
  insert_id, and the Invites page now shows an error notice instead of a
  dead link when creation fails (personal and group forms), including
  clearer validation messages.
- CLAUDE.md: schema changes must bump the version.

Closes #87

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 11:37:14 -03:00
thatguygriff c9eaa6f3bc Merge pull request 'Hide the My Lessons menu for users who already see Scheduler' (#86) from fix/hide-my-lessons-menu into main
CI / Tests (PHP 8.1) (push) Successful in 40s
CI / Coding Standards (push) Successful in 2m46s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 41s
CI / PHPStan (push) Successful in 2m44s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
CI / Build Plugin Zip (push) Successful in 2m43s
Reviewed-on: #86
2026-07-22 14:19:05 +00:00
thatguygriff 1bd0e33401 Merge pull request 'Default the availability admin page to the week view' (#84) from fix/availability-week-default into main
CI / Tests (PHP 8.1) (push) Successful in 43s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / No Debug Code (push) Successful in 3s
CI / PHPStan (push) Successful in 2m44s
CI / Coding Standards (push) Successful in 2m46s
CI / Tests (PHP 8.3) (push) Successful in 2m41s
CI / Build Plugin Zip (push) Successful in 2m46s
Reviewed-on: #84
2026-07-22 14:18:21 +00:00
thatguygriffandClaude Fable 5 e324c5d585 Hide the My Lessons menu for users who already see Scheduler
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / No Debug Code (pull_request) Successful in 1s
CI / Coding Standards (pull_request) Successful in 2m46s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m34s
CI / Build Plugin Zip (pull_request) Skipped
Scheduler (view_all_lessons) is a superset of My Lessons — same template,
every instructor's lessons, same payment edit forms — so for an
owner-operator both menu items showed the same data twice. The My Lessons
menu item is now only registered for users without view_all_lessons;
instructors are unaffected.

Closes #85

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 11:11:22 -03:00
thatguygriffandClaude Fable 5 266f572884 Default the availability admin page to the week view too
CI / Tests (PHP 8.2) (pull_request) Successful in 38s
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m49s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
Follow-up demo feedback: the My Availability page now opens in its weekly
calendar (usc_view=list opts back into the table, which keeps the bulk-delete
form), matching the new lessons defaults.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 11:09:23 -03:00
thatguygriff 5808defd1a Merge pull request 'Add multi-use group invite links with expiry and auto-approval on email confirmation' (#83) from feature/group-invite-links into main
CI / Tests (PHP 8.1) (push) Successful in 38s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m50s
CI / PHPStan (push) Successful in 2m49s
CI / Tests (PHP 8.3) (push) Successful in 2m38s
CI / Build Plugin Zip (push) Successful in 2m47s
Reviewed-on: #83
2026-07-22 13:49:15 +00:00
thatguygriffandClaude Fable 5 356d9f984d Add multi-use group invite links with expiry and auto-approval on email confirmation
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m46s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
A studio admin can generate a shareable group invite link (e.g. for a
newsletter) from the Invites page, choosing a required expiry date. Anyone
with the link may register while it is valid, in any registration mode: the
form collects their own email, they must confirm it via the usual hashed
token, and confirming approves the account immediately — group signups never
enter the Pending Students queue.

- us_invites grows kind (personal/group) and expires_at; an explicit expiry
  wins over the personal 14-day window. Group links stay pending (multi-use)
  until revoked or expired.
- RegistrationPage: group signups create the account pending with the
  us_auto_approve marker and send the confirmation email; no auto-login.
- EmailConfirmationHandler: auto-approve accounts are approved on
  confirmation, emailed the approved notice, and redirected to a new
  us_confirmed=ready notice with a sign-in link.

Closes #77

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 10:44:16 -03:00
thatguygriff b89a44047d Merge pull request 'Lock the registration email to the invite only when the invite is redeemable' (#82) from fix/invite-email-lock into main
CI / Build Plugin Zip (push) Successful in 2m45s
CI / Tests (PHP 8.1) (push) Successful in 40s
CI / Tests (PHP 8.2) (push) Successful in 38s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m45s
CI / PHPStan (push) Successful in 2m50s
CI / Tests (PHP 8.3) (push) Successful in 2m35s
Reviewed-on: #82
2026-07-22 13:31:17 +00:00
thatguygriff 4c2cc31c34 Merge pull request 'Charge weekly reservations for every claimed occurrence and confirm the whole series' (#81) from fix/recurring-payment-amount into main
CI / Tests (PHP 8.1) (push) Successful in 38s
CI / Coding Standards (push) Successful in 2m47s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / PHPStan (push) Successful in 2m51s
CI / Build Plugin Zip (push) Successful in 2m43s
Reviewed-on: #81
2026-07-22 13:26:15 +00:00
thatguygriffandClaude Fable 5 681fc5ae07 Lock the registration email to the invite only when the invite is redeemable
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / Tests (PHP 8.2) (pull_request) Successful in 37s
CI / PHPStan (pull_request) Successful in 2m45s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
The register form keyed the read-only, prefilled email off any invite row
matching the token. A stale token (expired / accepted / revoked) with open
registration on therefore showed the stale invite's address read-only while
the submit handler took the open branch and required a posted email the
locked field never submits, dead-ending the form. The lock now applies
exactly when the invite is acceptable; otherwise the editable field renders.

Closes #78

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 10:24:44 -03:00
thatguygriffandClaude Fable 5 ff059909a5 Charge weekly reservations for every claimed occurrence and confirm the whole series
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m50s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
A weekly booking on a per-lesson (one_time) priced offering was creating its
single upfront payment for one week's price while reserving up to 12 weeks,
and settling that payment confirmed only the anchor lesson, leaving the rest
of the series pending forever.

- BookingEndpoint now charges price x claimed occurrences for one_time
  billing; a full_term price is still charged once since it covers the term.
- PaymentService::confirmRegistration resolves the anchor lesson's series and
  confirms every non-cancelled row via the new
  BookingRepository::updateStatusForSeries().

Closes #79

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 10:19:53 -03:00
thatguygriff 972c0624c1 Merge pull request 'Default lessons to a week view on the booking page and in wp-admin' (#80) from fix/week-view-default into main
CI / Tests (PHP 8.2) (push) Successful in 38s
CI / Tests (PHP 8.1) (push) Successful in 43s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m52s
CI / PHPStan (push) Successful in 2m53s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
CI / Build Plugin Zip (push) Successful in 2m46s
Reviewed-on: #80
2026-07-22 13:19:47 +00:00
thatguygriffandClaude Fable 5 14f43232c9 Default lessons to a week view on the booking page and in wp-admin
CI / Tests (PHP 8.2) (pull_request) Successful in 1m15s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m16s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 3m15s
CI / PHPStan (pull_request) Successful in 3m14s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped
The front-end booking calendar now opens in the Week view (anchored to the
week of the earliest open slot) with List still available. The Scheduler and
My Lessons admin pages gain a week calendar (usc_view/usc_week, bucketed via
a new generic WeekCalendar::bucket()) and open in it by default; the original
table remains as the List view since it carries the HST / e-transfer forms.

Closes #76

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 10:13:04 -03:00
thatguygriff caa402778d Merge pull request 'Student detail: history sections and admin actions (cancel, withdraw, edit account)' (#75) from feature/student-detail-history into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.2) (push) Successful in 37s
CI / Tests (PHP 8.1) (push) Successful in 43s
CI / Coding Standards (push) Successful in 2m56s
CI / PHPStan (push) Successful in 2m57s
CI / Tests (PHP 8.3) (push) Successful in 2m37s
CI / Build Plugin Zip (push) Successful in 2m44s
Reviewed-on: #75
2026-07-18 21:28:53 +00:00
thatguygriffandClaude Fable 5 5808523140 Add admin actions to the student detail view: cancel, withdraw, edit account
CI / Tests (PHP 8.2) (pull_request) Successful in 43s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / Coding Standards (pull_request) Successful in 2m43s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Build Plugin Zip (pull_request) Skipped
Adds the #70 follow-up onto the student detail page: studio admins can now
cancel an upcoming lesson (same path as student cancellation — slot freed,
pending payment voided), withdraw an active group-class enrolment (seat
freed, pending payment voided), and edit the student's display name and
email with validation and uniqueness checks.

Action logic lives in the new Auth\StudentActions (unit-tested with mocked
repositories); the controller routes nonce-protected POSTs to it and shows
success/error notices.

Closes #70

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 18:25:12 -03:00
thatguygriffandClaude Fable 5 c49171695a Add policy, intake, and payment history to the admin student detail view
CI / Coding Standards (pull_request) Successful in 2m47s
CI / PHPStan (pull_request) Successful in 2m56s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m39s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 43s
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 2s
The student-administration spec deferred three detail-view sections until
Payments landed. Adds them now: policy-acceptance history (title, version,
context, date), intake answers (label, answer, context), and — gated on
manage_billing — payment history with HST breakdown and receipt numbers.

New Auth\StudentHistory builds the display rows from per-student queries
added to AcceptanceRepository, AnswerRepository, and PaymentRepository;
the Payment model now carries created_at so unpaid rows still have a date.

Closes #69

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 18:17:20 -03:00
thatguygriff 05d1728248 Merge pull request 'Update README and group-classes doc to reflect shipped Stripe payments' (#74) from docs/readme-payments-status into main
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / Coding Standards (push) Successful in 2m48s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 38s
CI / PHPStan (push) Successful in 2m45s
CI / Tests (PHP 8.3) (push) Successful in 2m34s
CI / Build Plugin Zip (push) Successful in 2m48s
Reviewed-on: #74
2026-07-18 21:08:07 +00:00
thatguygriffandClaude Fable 5 da9a449d55 Update README and group-classes doc to reflect shipped Stripe payments
CI / Tests (PHP 8.2) (pull_request) Successful in 38s
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 1s
CI / Coding Standards (pull_request) Successful in 2m49s
CI / PHPStan (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
The README still listed Payments as partial with the Stripe card charge
pending, and group-classes.md still described the pre-#7 payment seam.
Both are behind the code: StripeGateway/PaymentEndpoint ship the live
card charge, and enrolments create and link payments via PaymentService.

Fixes #73

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 18:06:58 -03:00
thatguygriff ca2607e5ec Merge pull request 'Show a sign-in link instead of the registration form after email confirmation' (#68) from feature/confirmed-signin-link into main
CI / Tests (PHP 8.1) (push) Successful in 37s
CI / Coding Standards (push) Successful in 2m46s
CI / Build Plugin Zip (push) Successful in 2m44s
CI / Tests (PHP 8.2) (push) Successful in 47s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m53s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
Reviewed-on: #68
2026-07-18 21:00:22 +00:00
thatguygriffandClaude Fable 5 7b00811133 Show a sign-in link instead of the form after email confirmation
CI / Tests (PHP 8.2) (pull_request) Successful in 41s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m45s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m35s
CI / Build Plugin Zip (pull_request) Skipped
Closes #67

When a student lands on the registration page from the confirmation
email (?us_confirmed=1), replace the registration form with the
confirmation message and a "Sign in to your account" link — the form
is useless at that point and re-submitting would only produce an
"account already exists" error. A confirmed-but-unapproved student can
already log in (the pending gate only withholds booking), so signing in
is the natural next step.

The link target follows the booking block's pattern: a loginPageId
block attribute (page picker in the editor sidebar) or login_page_id
shortcode attribute, falling back to wp_login_url(). The expired-link
notice keeps the form as before.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 17:53:38 -03:00
thatguygriff 8d79fbdb1f Merge pull request 'Auto-update the plugin from tagged Gitea releases' (#66) from feature/plugin-self-update into main
CI / Tests (PHP 8.1) (push) Successful in 41s
CI / PHPStan (push) Successful in 2m51s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 2m35s
CI / Coding Standards (push) Successful in 3m59s
CI / Build Plugin Zip (push) Successful in 2m43s
Reviewed-on: #66
2026-07-18 14:22:05 +00:00
thatguygriffandClaude Fable 5 ab055c7a0c Serve plugin updates from tagged Gitea releases
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m43s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m34s
CI / Build Plugin Zip (pull_request) Skipped
Closes #65

Declare an Update URI header and answer core's update_plugins_{hostname}
filter from a new Update\UpdateChecker that offers the latest published
Gitea release's zip asset when it is newer than the installed version,
with transient caching and silent degradation on API failures.

Add a release workflow that fires on v* tag pushes: verifies the tag
matches the plugin Version header, runs the tests, builds the plugin zip,
and attaches it to the release (reusing a UI-created release, flagging
hyphenated versions as pre-release so /releases/latest skips them).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-18 11:09:50 -03:00
thatguygriff e4af0c327c Merge pull request 'Open student registration with email confirmation and admin approval' (#64) from feature/student-self-signup into main
CI / Tests (PHP 8.1) (push) Successful in 36s
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / No Debug Code (push) Successful in 4s
CI / Coding Standards (push) Successful in 2m46s
CI / PHPStan (push) Successful in 2m54s
CI / Tests (PHP 8.3) (push) Successful in 2m37s
CI / Build Plugin Zip (push) Successful in 2m48s
Reviewed-on: #64
2026-07-18 13:58:00 +00:00
thatguygriffandClaude Opus 4.8 7370755951 Add open student registration with email confirmation and approval
CI / Tests (PHP 8.1) (pull_request) Successful in 1m18s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m18s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 3m20s
CI / Coding Standards (pull_request) Successful in 3m25s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m33s
CI / Build Plugin Zip (pull_request) Skipped
Students could previously join by invite only. Add an optional
self-approval mode, toggled from Studio Settings → Registration: anyone
may sign up on the existing [us_student_register] page, confirm their
email via a tokenised link, and then be approved by a studio admin
before the account is usable.

- Enabling the toggle mirrors WordPress's own membership settings
  (users_can_register + default_role = us_student) and snapshots their
  previous values so disabling restores them.
- WordPress's native registration form is blocked while open
  registration is on (login_init redirect + registration_errors
  fail-safe + register_url) so it cannot bypass signup policy acceptance.
- Pending accounts: unconfirmed email cannot log in; confirmed but
  unapproved can log in but the booking capability is withheld and the
  booking page shows an "awaiting approval" screen.
- Approve/reject from Students → Pending Students; reject hard-deletes
  the account so the email is freed to re-apply.
- Invite registration is unchanged; both modes coexist.

Account lifecycle lives in user meta (RegistrationStatus); no new tables.

Closes #63

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 10:50:21 -03:00
thatguygriff e7d8257973 Merge pull request 'Show enrolment status on the student group classes page' (#62) from feature/enrolled-status into main
CI / Tests (PHP 8.1) (push) Successful in 46s
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
CI / Build Plugin Zip (push) Successful in 2m45s
CI / PHPStan (push) Successful in 1m41s
CI / Coding Standards (push) Successful in 2m48s
Reviewed-on: #62
2026-07-06 02:44:20 +00:00
thatguygriffandClaude Fable 5 30b0112431 Show enrolment status on the student group classes page
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 1m17s
CI / Tests (PHP 8.3) (pull_request) Successful in 37s
CI / Coding Standards (pull_request) Successful in 2m47s
CI / Build Plugin Zip (pull_request) Has been skipped
The class list now loads the student's own enrolments alongside the
catalog; a class they already have an active enrolment in shows "You
are enrolled in this class." instead of the Enrol button, in both the
browse-all catalog and the single-class embed mode. Previously the
button always rendered and a duplicate attempt walked the student
through the whole questions/policies flow before failing with 409
already_enrolled. A cancelled enrolment does not block re-enrolling,
matching the server-side duplicate rule.

Closes #61

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 23:41:01 -03:00
thatguygriff a9f0b8c066 Merge pull request 'Group class term dates, single-class embed mode, and offering editing' (#60) from feature/group-class-terms into main
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 32s
CI / Build Plugin Zip (push) Successful in 1m42s
CI / Tests (PHP 8.1) (push) Successful in 43s
CI / PHPStan (push) Successful in 49s
CI / Coding Standards (push) Successful in 2m43s
Reviewed-on: #60
2026-07-06 02:22:06 +00:00
thatguygriffandClaude Fable 5 cde8704267 Group class term dates, single-class embed mode, and offering editing
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 1m14s
CI / PHPStan (pull_request) Successful in 1m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 37s
CI / Build Plugin Zip (pull_request) Has been skipped
Group class offerings now carry real dates: the add/edit form takes a
start date plus a sessions control (one-off, or weekly for N sessions;
the end date is computed as start + (N-1) weeks via
Offering::weeklyTermEnd). Dates are validated strictly (Y-m-d) and shown
in the offerings list and on the student-facing class card, including
the weekly session count.

[us_group_classes offering="<id>"] (block attribute offeringId, chosen
from a dropdown of active classes fetched from the public offerings
endpoint) restricts the page to a single class so the enrolment flow can
be embedded on a page dedicated to that class; a pinned class that is no
longer offered reports itself closed instead of falling back to the
catalog.

Offerings are now editable from the admin screen: an Edit button
prefills the shared add/edit form and saving posts usc_action=update.
Updates always preserve the original owner and currency, and non-admin
instructors can only load and update their own offerings. The form also
gains the previously missing description field and an Active toggle (the
admin-UI counterpart of the REST is_active flag) so an edit cannot wipe
data the form never collected.

Closes #59

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 23:18:59 -03:00
thatguygriff 9d8924132c Merge pull request 'Bulk delete availability slots from the admin list view' (#58) from feature/availability-bulk-delete into main
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.2) (push) Successful in 47s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 1m11s
CI / Tests (PHP 8.3) (push) Successful in 35s
CI / Coding Standards (push) Successful in 2m14s
CI / Build Plugin Zip (push) Successful in 1m42s
Reviewed-on: #58
2026-07-06 01:32:31 +00:00
thatguygriffandClaude Fable 5 c743ed5459 Bulk delete availability slots from the admin list view
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 1m40s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / Tests (PHP 8.3) (pull_request) Successful in 1m34s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Build Plugin Zip (pull_request) Has been skipped
The list view of Current Slots gets a checkbox per unbooked slot, a
select-all header checkbox, and a Delete selected button submitting a
new bulk_delete form action. Each id is ownership-checked through the
same path as single delete; the repository's is_booked guard refuses
booked slots as a second layer. Row checkboxes attach to the bulk form
via the HTML form attribute because the table already contains the
per-row delete forms and forms cannot nest.

Closes #57

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 22:25:35 -03:00
thatguygriff aaa24e524f Merge pull request 'Require an offering on every lesson booking, with a student-facing picker' (#56) from fix/require-booking-offering into main
CI / Tests (PHP 8.1) (push) Successful in 42s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m17s
CI / Tests (PHP 8.3) (push) Successful in 2m2s
CI / Coding Standards (push) Successful in 2m49s
CI / Build Plugin Zip (push) Successful in 2m15s
Reviewed-on: #56
2026-07-06 01:14:18 +00:00
thatguygriffandClaude Fable 5 c7d5d72c9c Require an offering on every lesson booking, with a student-facing picker
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 1m40s
CI / PHPStan (pull_request) Successful in 2m24s
CI / Coding Standards (pull_request) Successful in 2m49s
CI / Build Plugin Zip (pull_request) Has been skipped
Generic slots (no tied offering) were bookable with no offering at all:
free, instantly confirmed, and with no intake questions. POST /bookings
now rejects offering-less bookings (400 offering_required), and a
student-chosen offering must be an active private-lesson type owned by
the slot's instructor whose duration matches the slot.

The registration form gains a Lesson type field: locked to the slot's
tied offering (title, duration, price) so the student sees what they
are booking, or a required picker of fitting offerings for generic
slots, with intake questions following the selection.

Fixes #55

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 22:09:27 -03:00
thatguygriffandClaude Fable 5 ab90dae638 Let students cancel their own lessons from the booking page
CI / No Debug Code (push) Successful in 3s
CI / Coding Standards (push) Successful in 46s
CI / Tests (PHP 8.1) (push) Successful in 45s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / PHPStan (push) Successful in 1m11s
CI / Tests (PHP 8.3) (push) Successful in 1m0s
CI / Build Plugin Zip (push) Successful in 1m10s
Adds POST /bookings/{id}/cancel (owner-only, idempotent): marks the lesson
cancelled, releases the availability slot for rebooking, and voids a
still-pending payment so it leaves the admin confirmation queue. Paid
payments are untouched — refunds stay a manual admin decision.

The instructor PATCH /bookings/{id}/status path now does the same slot
release and payment voiding on cancellation (previously cancelled lessons
left their slot permanently booked), and reinstating a cancelled lesson
re-claims the slot, rejecting with 409 if the freed time was rebooked.

The "Your upcoming lessons" panel gets a Cancel button with a confirm
prompt; on success both the lesson list and the slot calendar refresh.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 17:17:30 -03:00
thatguygriff 9dd3c39ddd Merge pull request 'Skip payment step for unpriced bookings, confirm them immediately, show students their lessons' (#54) from fix/unpriced-booking-flow into main
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 1m45s
CI / Tests (PHP 8.3) (push) Successful in 1m4s
CI / Coding Standards (push) Successful in 2m23s
CI / Build Plugin Zip (push) Successful in 2m16s
Reviewed-on: #54
2026-07-05 20:06:46 +00:00
thatguygriffandClaude Fable 5 5888032ed7 Skip payment step for unpriced bookings, confirm them immediately, show students their lessons
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / PHPStan (pull_request) Successful in 2m46s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Coding Standards (pull_request) Successful in 47s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m35s
CI / Build Plugin Zip (pull_request) Has been skipped
Booking a slot with no priced offering created the lesson but no payment,
yet the front end still called POST /payments/intent, which 400ed with
"Could not start payment for this registration" — the student saw an error
while the backend held a claimed slot and a lesson stuck at pending.

- POST /bookings and POST /enrollments now return a `payment` summary
  ({id, method, status}) or null when nothing is owed; the JS only runs
  the payment step when a payment exists.
- Bookings with nothing owed are confirmed at creation — there is no
  payment step that would ever confirm them later.
- The booking page now shows the student's upcoming lessons (GET /bookings,
  now scoped to upcoming non-cancelled lessons with slot start/end times)
  with a pending-payment/confirmed status badge.

Fixes #53

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 17:02:38 -03:00
thatguygriff 93dccd6352 Merge pull request 'Block options for login/booking link targets with optional auto-redirect' (#52) from feature/block-link-targets into main
CI / Tests (PHP 8.1) (push) Successful in 54s
CI / PHPStan (push) Successful in 57s
CI / Tests (PHP 8.2) (push) Successful in 53s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 1m53s
CI / Tests (PHP 8.3) (push) Successful in 2m38s
CI / Build Plugin Zip (push) Successful in 53s
Reviewed-on: #52
2026-07-05 19:19:30 +00:00
thatguygriffandClaude Fable 5 9d89bc6d0e Add link-target and auto-redirect options to booking/login blocks
CI / Coding Standards (pull_request) Successful in 51s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / Tests (PHP 8.3) (pull_request) Successful in 1m2s
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 53s
CI / PHPStan (pull_request) Successful in 1m24s
The booking block gains a loginPageId attribute choosing which page its
logged-out "log in to book a lesson" link points to (default remains the
WordPress login screen), and the student-login block gains a
bookingPageId attribute controlling the logged-in "View available
lessons" link and the post-login redirect target (default remains the
current page). Both blocks also gain an autoRedirect toggle, off by
default, that sends the visitor straight to the target page; block
rendering starts after output, so the redirect runs on
template_redirect by parsing the queried page's content for the block,
with a self-target guard against redirect loops. The link targets are
also available to the shortcodes as login_page_id/booking_page_id.

Also fixes a pre-existing fatal: WordPress passes an empty string (not
an array) to shortcode callbacks when a shortcode is used without
attributes, so bare [us_booking] etc. threw a TypeError against the
strictly-typed render(array $atts) methods. ShortcodeRegistrar now
wraps each callback to normalize non-array attribute values.

Closes #51

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 16:16:52 -03:00
thatguygriff 43497503b9 Merge pull request 'Split availability windows into bookable lesson-length slots; weekly calendar views; 12-hour times' (#50) from feature/slot-splitting-and-week-view into main
CI / PHPStan (push) Successful in 1m42s
CI / Tests (PHP 8.2) (push) Successful in 40s
CI / Tests (PHP 8.1) (push) Successful in 56s
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.3) (push) Successful in 34s
CI / Coding Standards (push) Successful in 2m45s
CI / Build Plugin Zip (push) Successful in 1m14s
Reviewed-on: #50
2026-07-05 19:14:50 +00:00
thatguygriffandClaude Fable 5 dbf61e8593 Word-bound the no-debug CI grep so method calls like ->add() don't match dd(
CI / Tests (PHP 8.2) (pull_request) Successful in 38s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 1m20s
CI / PHPStan (pull_request) Successful in 1m43s
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / Tests (PHP 8.3) (pull_request) Successful in 1m5s
The unanchored dd\( pattern matched the substring in DateTimeImmutable::add(),
failing the check on non-debug code.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 16:12:37 -03:00
thatguygriffandClaude Fable 5 b7d5e3039e Split availability windows into bookable lesson-length slots with weekly calendar views
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / PHPStan (pull_request) Successful in 2m48s
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / No Debug Code (pull_request) Failing after 2s
CI / Coding Standards (pull_request) Successful in 52s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
Availability windows were stored and served as a single bookable row, so a
9:00 AM-4:00 PM window showed to students as one giant slot and booking it
consumed the whole day; past and multi-day windows also leaked into the
booking page as nonsense entries.

- Split windows into consecutive lesson-length slots on save (REST and admin
  form); each chunk is independently bookable and weekly recurrence creates a
  series per chunk so "reserve this time weekly" holds the same hour each week
- Reject windows spanning multiple days or shorter than the lesson length
  (400 invalid_window)
- Never return slots whose start has passed from GET /availability
- Migrate pre-split rows: Plugin::boot re-runs the Installer on version change
  and AvailabilityRepository::splitOversizedWindows() rewrites unbooked
  same-day oversized windows in place
- Display all times in 12-hour AM/PM form (booking page, wp-admin lists,
  editor previews)
- Add a List | Week view toggle to the student booking page and the
  instructor availability page, with previous/next-week navigation honouring
  the site's start_of_week option (new WeekCalendar helper)

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 16:00:47 -03:00
thatguygriff 66f308e1da Merge pull request 'Replace Slot ID column in lessons list with the lesson's date/time' (#48) from feature/lessons-datetime-column into main
CI / Tests (PHP 8.1) (push) Successful in 54s
CI / PHPStan (push) Successful in 1m38s
CI / Tests (PHP 8.2) (push) Successful in 51s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m44s
CI / Build Plugin Zip (push) Successful in 1m43s
CI / Tests (PHP 8.3) (push) Successful in 2m35s
Reviewed-on: #48
2026-07-05 18:57:16 +00:00
thatguygriffandClaude Fable 5 6ff733a71f Replace Slot ID column in lessons list with the lesson's date/time
CI / Tests (PHP 8.2) (pull_request) Successful in 1m20s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m21s
CI / Coding Standards (pull_request) Successful in 1m45s
CI / PHPStan (pull_request) Successful in 3m22s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Has been skipped
The admin dashboard and instructor My Lessons pages showed the raw
availability-slot database ID, which is meaningless to admins and
instructors. LessonController now takes AvailabilityRepository, looks up
each lesson's slot, and renders its window as e.g.
"Jul 6, 2026 9:00 AM-10:00 AM" via mysql2date. The date is repeated on
the end time only when a slot crosses midnight, and lessons whose slot
row no longer exists show an em dash.

Closes #47

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 15:51:54 -03:00
thatguygriff aea731c2f8 Merge pull request 'Upgrade PHPStan to 2.x and raise analysis level from 6 to 10' (#46) from chore/phpstan-2-upgrade into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 45s
CI / Tests (PHP 8.2) (push) Successful in 52s
CI / Tests (PHP 8.3) (push) Successful in 56s
CI / PHPStan (push) Successful in 1m2s
CI / Coding Standards (push) Successful in 1m5s
CI / Build Plugin Zip (push) Successful in 56s
Reviewed-on: #46
2026-06-12 16:54:57 +00:00
thatguygriffandClaude Fable 5 1d6ac46ba3 Upgrade PHPStan to 2.x and raise analysis level from 6 to 10
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.3) (pull_request) Successful in 52s
CI / Coding Standards (pull_request) Successful in 57s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m1s
CI / PHPStan (pull_request) Successful in 1m11s
CI / Build Plugin Zip (pull_request) Has been skipped
- Bump phpstan/phpstan ^2.0 and szepeviktor/phpstan-wordpress ^2.0
- Move the analysis level into phpstan.neon (single source) and raise it to 10
- Add Val, a runtime coercion helper that narrows untyped WordPress boundary
  values (wpdb rows, REST params, superglobals, options) with explicit checks
  instead of blind casts, plus unit tests
- Type value-object fromRow() params as stdClass (what wpdb returns) and map
  columns through Val so unexpected shapes degrade safely
- Use %i identifier placeholders for table names in all wpdb::prepare() calls
  so every query string is a literal and identifiers are escaped by WordPress;
  raises the minimum WordPress version to 6.2 where %i was introduced
- Guard wpdb::prepare() null result before wpdb::query() in updateTax()
- Fix nullable get_permalink()/strtotime() handling, list types at REST and
  capability call sites, dead null-coalescing on checked superglobals, and
  narrow get_users() results before mapping
- Register Val method names with the ValidatedSanitizedInput sniff so it
  validates the real sanitizer around each superglobal read
- Update repository unit tests for the %i placeholder arguments

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-12 13:42:50 -03:00
thatguygriff b23508f726 Merge pull request 'Gutenberg dynamic-block wrappers for shortcodes with editor previews' (#45) from feature/editor-blocks into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.2) (push) Successful in 54s
CI / Tests (PHP 8.1) (push) Successful in 54s
CI / Tests (PHP 8.3) (push) Successful in 1m6s
CI / Coding Standards (push) Successful in 1m12s
CI / PHPStan (push) Successful in 1m12s
CI / Build Plugin Zip (push) Successful in 1m27s
Reviewed-on: #45
2026-06-12 15:14:02 +00:00
thatguygriffandClaude Fable 5 fc70cde9d5 Add Gutenberg dynamic-block wrappers for the front-end shortcodes
CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 52s
CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / Tests (PHP 8.3) (pull_request) Successful in 1m29s
CI / Coding Standards (pull_request) Successful in 1m57s
CI / PHPStan (pull_request) Successful in 2m14s
CI / Build Plugin Zip (pull_request) Has been skipped
Wrap the four shortcodes (us_booking, us_student_login,
us_student_register, us_group_classes) in dynamic blocks so pages can be
previewed and styled in the block editor. Front-end rendering delegates
to the same page objects the shortcodes use; in the editor's
block-renderer REST preview a static, script-free BlockPreview is
rendered instead (no live REST calls, redirects, or Stripe.js). The
editor script (vanilla JS, no build step) registers each block with
wp.serverSideRender previews and shortcode transforms; frontend.css is
attached as the block style so previews pick up theme styling.

Resolves #44

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-12 12:03:27 -03:00
thatguygriff 63e2fbcc5b Merge pull request 'Security fixes: CSV injection, policy body output, invite token hashing, slot datetime validation' (#43) from feature/security-review-fixes into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / Tests (PHP 8.1) (push) Successful in 50s
CI / Tests (PHP 8.3) (push) Successful in 54s
CI / Coding Standards (push) Successful in 1m1s
CI / PHPStan (push) Successful in 1m9s
CI / Build Plugin Zip (push) Successful in 1m6s
Reviewed-on: #43
2026-06-10 19:51:20 +00:00
thatguygriffandClaude Fable 5 f3f5c7801f Security fixes: CSV injection, policy body output, invite hashing, slot datetimes
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / Tests (PHP 8.3) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 59s
CI / Coding Standards (pull_request) Successful in 1m11s
CI / PHPStan (pull_request) Successful in 1m20s
CI / Build Plugin Zip (pull_request) Has been skipped
Four fixes from a security review pass:

- Neutralise CSV formula injection in the payments export: fields with a
  leading =, +, -, @, tab, or CR (e.g. a hostile student display name) are
  apostrophe-prefixed in PaymentReport::csvLine() so they open as text in
  Excel/Google Sheets. Fixes #39.
- Sanitise policy bodies with wp_kses_post at output in
  PolicyEndpoint::index() (the booking JS renders that HTML raw), so a
  future write path that forgets kses can never become stored XSS.
  Fixes #40.
- Store invite tokens hashed (SHA-256) at rest: a database leak can no
  longer redeem pending invites. The registration link is shown once, at
  creation; the pending list shows email/invited date; lookups hash the
  submitted token. Existing plaintext pending invites must be re-issued.
  Fixes #41.
- Validate availability slot datetimes on both creation paths (REST and
  admin form) via AvailabilitySlot::normalizeDateTime(): canonical and
  datetime-local forms normalise to Y-m-d H:i:s, garbage and end <= start
  are rejected (REST 400) instead of reaching the DATETIME column or
  throwing inside the weekly-series date arithmetic. Fixes #42.

composer test (204 tests, 594 assertions), PHPStan L6, and PHPCS all green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 16:36:26 -03:00
thatguygriff 693246c1c1 Merge pull request 'Security hardening: booking auth, offering exposure, payments, invites (#31–#37)' (#38) from feature/security-fixes into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.2) (push) Successful in 51s
CI / Coding Standards (push) Successful in 58s
CI / PHPStan (push) Successful in 1m2s
CI / Tests (PHP 8.3) (push) Successful in 1m41s
CI / Build Plugin Zip (push) Successful in 55s
Reviewed-on: #38
2026-06-09 20:11:34 +00:00
183 changed files with 19356 additions and 929 deletions
+2 -1
View File
@@ -97,7 +97,8 @@ jobs:
- uses: actions/checkout@v4
- name: Check for debug statements
run: |
if grep -rn --include="*.php" -E "(var_dump|var_export|print_r|error_log|dd\(|dump\()" src/; then
# \b keeps method calls like DateTimeImmutable::add() from matching dd(.
if grep -rn --include="*.php" -E "\b(var_dump|var_export|print_r|error_log|dd|dump)\s*\(" src/; then
echo "Debug code found in src/ — please remove before merging."
exit 1
fi
+165
View File
@@ -0,0 +1,165 @@
name: Release
# Fires when a v* tag is pushed — including tags created through Gitea's
# "New Release" UI. Builds the distributable plugin zip and attaches it to
# the release for that tag (creating the release if only a bare tag was
# pushed). The attached zip is what UpdateChecker serves to WordPress
# sites as the update package.
on:
push:
tags:
- 'v*'
jobs:
release:
name: Build and Publish Release
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
# A tag that disagrees with the plugin header would make sites see a
# phantom update forever (or never see a real one), so fail fast.
- name: Verify tag matches plugin version
id: meta
run: |
tag_version="${GITHUB_REF_NAME#v}"
header_version="$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*([^[:space:]]+).*/\1/p' unsupervised-schedular.php | head -1)"
if [ "$tag_version" != "$header_version" ]; then
echo "Tag ${GITHUB_REF_NAME} does not match plugin header Version: ${header_version}" >&2
exit 1
fi
echo "version=${header_version}" >> "$GITHUB_OUTPUT"
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Run tests
run: composer test
- name: Build plugin zip
run: composer build
# Pull the section for this version out of CHANGELOG.md so it can become
# the release body. Matches "## [x.y.z]" and prints every line up to the
# next "## " heading. A missing section is a warning, not a failure — the
# release still publishes with empty notes.
- name: Extract changelog notes
run: |
version="${{ steps.meta.outputs.version }}"
awk -v ver="$version" '
$0 ~ "^## \\[" ver "\\]" { found = 1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md | sed -e '/./,$!d' | tac | sed -e '/./,$!d' | tac > release-notes.md
if [ ! -s release-notes.md ]; then
echo "::warning::No CHANGELOG.md section found for version ${version}"
fi
- name: Publish release with zip asset
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
version="${{ steps.meta.outputs.version }}"
zip="dist/unsupervised-schedular-${version}.zip"
# Pre-release versions (1.2.3-rc.1) are flagged so Gitea's
# /releases/latest endpoint — and therefore the update checker —
# skips them.
prerelease=false
case "$version" in *-*) prerelease=true ;; esac
# Reuse the release if the tag was created via Gitea's release UI.
release_id="$(curl -sS -H "Authorization: token ${TOKEN}" \
"${api}/releases/tags/${GITHUB_REF_NAME}" | jq -r '.id // empty' || true)"
if [ -z "$release_id" ]; then
body="$(jq -Rs --arg tag "${GITHUB_REF_NAME}" --argjson pre "${prerelease}" \
'{tag_name:$tag, name:$tag, prerelease:$pre, body:.}' release-notes.md)"
release_id="$(curl -fsS -X POST "${api}/releases" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "${body}" | jq -r '.id')"
else
# Release pre-created via the UI: fill in the notes from the changelog.
body="$(jq -Rs '{body:.}' release-notes.md)"
curl -fsS -X PATCH "${api}/releases/${release_id}" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "${body}" > /dev/null
fi
echo "Attaching ${zip} to release ${release_id}"
curl -fsS -X POST \
"${api}/releases/${release_id}/assets?name=unsupervised-schedular-${version}.zip" \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${zip}" > /dev/null
# After a stable release, move main forward: bump the plugin to the next patch
# version and open a fresh changelog section for it, via a PR. Skipped for
# pre-releases (tags containing a hyphen, e.g. v1.2.0-rc.1) — those don't
# advance the mainline version.
bump-version:
name: Open next-version bump PR
needs: release
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Compute next patch version
id: next
run: |
current="$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*([^[:space:]]+).*/\1/p' unsupervised-schedular.php | head -1)"
base="${current%%-*}" # drop any pre-release suffix
major="${base%%.*}"
rest="${base#*.}"
minor="${rest%%.*}"
patch="${rest#*.}"
next="${major}.${minor}.$((patch + 1))"
echo "next=${next}" >> "$GITHUB_OUTPUT"
- name: Apply version bump and open changelog section
run: |
next="${{ steps.next.outputs.next }}"
# Plugin header + USC_VERSION constant must stay in lockstep.
sed -i -E "s/^([[:space:]]*\*?[[:space:]]*Version:[[:space:]]*).*/\1${next}/" unsupervised-schedular.php
sed -i -E "s/(define\('USC_VERSION', ')[^']+('\);)/\1${next}\2/" unsupervised-schedular.php
# Insert an empty section for the new version above the current top one
# (the first "## [" heading in the file).
awk -v ver="$next" '
!done && /^## \[/ { print "## [" ver "]"; print ""; done = 1 }
{ print }
' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
- name: Open pull request
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
next="${{ steps.next.outputs.next }}"
branch="release/bump-${next}"
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
git config user.name 'Release Bot'
git config user.email '[email protected]'
git checkout -b "${branch}"
git commit -am "Bump version to ${next} and open changelog section"
git push origin "${branch}"
curl -fsS -X POST "${api}/pulls" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg head "${branch}" --arg title "Bump version to ${next}" \
'{head:$head, base:"main", title:$title, body:"Automated post-release bump to the next patch version. Record changes for this version under its changelog heading."}')" \
> /dev/null
+72
View File
@@ -0,0 +1,72 @@
# Changelog
All notable changes to Unsupervised Scheduler are documented in this file, one
section per version, newest first. The top section is always the version the code
on `main` currently carries (the `Version:` header in `unsupervised-schedular.php`).
Whether a version is "released" is purely a matter of whether its tag exists — the
changelog itself does not track that.
When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
`## [x.y.z]` section verbatim as the Gitea release notes, then opens a PR that bumps
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.2.2]
### Added
- The **Lesson Booking** block gained three embedding options in its sidebar. **Lesson type** pins the block to a single private-lesson type — only the times bookable as that type are listed and it is the only thing bookable there, auto-selected on the registration form — so a page about one lesson type can carry its own calendar. **Show the lesson-type filter** turns the **Show Only** control on or off. **Sections** embeds just one half of the page: booking calendar only, or the student's upcoming lessons only, so the two can live on different pages. All three are available to the shortcode as `[us_booking lesson_type="…" show_filter="no" show="booking|upcoming"]`, and the block's editor preview follows the chosen sections.
- The booking calendar now has a **Show Only** button beside the List/Week toggle that opens a lesson-type filter, so a student browsing open times can narrow them to the types they actually want. Because not every open time can be booked as every private-lesson type — some times are tied to a specific type, others only take types of a matching length — the filter shows just the times bookable as the ticked types, and re-anchors the week view on the earliest one so it never opens on an empty week. Picking one of those times narrows the **Lesson type** picker on the registration form to the same list, and when only one type is left it is chosen automatically with its questions loaded. The type list starts collapsed and can be tucked away again without losing the filter; the button shows how many types are ticked. Tick nothing (or use **Show all types**) to see every open time as before. The filter is hidden when the studio only offers one private-lesson type.
- The **Group Classes** block can now be pinned to a single class, under **Classes shown → Class** in the block sidebar (shortcode: `[us_group_classes offering="…"]`). Pick a class and the block shows only that one, so it can be embedded on a page that describes the class. In this mode the class's own description is left out to avoid repeating the page copy — the card shows the schedule, instructor, price, enrolment deadline and the enrol/withdraw controls. Leaving it on **All classes** keeps the full browsable catalog with descriptions.
- The **Student Registration** block can now send students onward to a page of your choosing once they finish registering. Its **After email confirmation** panel is now **After registration**: the page you pick there is where the link shown to a newly registered student points — the "Sign in to your account" link after they confirm their email, and a "Continue to your account" link for an invited student, who is signed in immediately. A new **Redirect automatically** option takes them straight there instead of showing the link. Registration errors are never skipped — a failed sign-up and an expired confirmation link still show their message on the page, as does the "check your email to confirm your address" step. The redirect needs a page to be chosen; with none set, students see the link (or, for invited students, just the confirmation) as before.
## [1.2.1]
### Fixed
- Registration questions, offering titles/notes, and policy names longer than their storage limit are no longer silently discarded. Previously typing a fixed-size field past its maximum length reported success but saved nothing — the database quietly rejected the over-long value. These fields now cap the input in the form, and the API rejects an over-long value with a clear error.
- Students can no longer reach the WordPress dashboard. A student who navigates to `wp-admin` is redirected to the site front end and the admin toolbar is hidden for them, so they only ever see the studio's booking pages. Anyone who runs the studio — administrators, studio admins, and instructors — keeps full `wp-admin` access.
- The instructor picker on the **Add/Edit Offering** form no longer comes up empty for a solo studio owner. When the person running the studio teaches from a WordPress administrator account (the default single-account setup), they now appear in the instructor dropdown and can be assigned to a class.
## [1.2.0]
### Added
- Offerings can now bill on a schedule: **weekly** (a pending payment 24 hours before each lesson) or **monthly** (one payment on the 1st for that month's lessons), alongside the existing one-time and full-term modes. Applies to both private lessons and group classes. A daily job generates due payments, and each student receives one consolidated itemised email per scan; batched payments share a reference so the admin Payments queue groups them with a lump-sum total for e-transfer reconciliation. Cancelling a lesson never voids a scheduled payment.
- Cancelling a lesson that was **already paid for** now credits the student that money instead of leaving it as a manual refund. The credit is one lesson's share of what they paid — the whole amount for a single lesson, or a per-lesson slice of a monthly charge or a full-term series. The daily billing scan automatically applies any available credit against a student's upcoming weekly/monthly charges before emailing their notice, which shows the credit applied and the reduced total due; a charge fully covered by credit is settled and leaves the admin Payments queue. A student's outstanding credit balance is shown on their **student detail** page in the studio admin. Still-pending (unpaid) payments continue to be voided on cancellation as before.
- Group classes now carry an **enrolment deadline** the instructor sets on the offering. It defaults to the first day of the class, and once it passes students can no longer enrol — the enrolment page shows the class as closed and the API rejects late enrolments. While enrolment is open, each class card shows an "Enrol by" date.
- Group classes now also carry a **withdrawal deadline** the instructor sets per class. Up to that day a student can withdraw themselves from the class (the group-class page shows a **Withdraw** button) — this frees their seat and voids any pending payment but does **not** credit their account. After the deadline self-withdrawal closes and the student must ask the studio, who can still withdraw them by hand from the student detail page. Leaving the deadline blank keeps self-withdrawal open indefinitely.
- The **Add/Edit Offering** form now shows only the fields relevant to the selected kind: the group-class settings (capacity, dates, times, enrolment/withdrawal deadlines, sessions, schedule note, invite-only) appear only for a group class, and the weekly-reservation option only for a private lesson.
- Instructors can add students to any group class by hand from its details page (**Add students directly**), which now appears for public classes too, not just invite-only ones. This bypasses the enrolment deadline and capacity, so a student can be enrolled as a **late enrolment** after the class has closed to self-enrolment.
- Studio admins and instructors can open a **lesson detail view** from the Scheduler and My Lessons lists, showing the offering booked, the policy versions the student accepted (with acceptance time and IP), and their intake answers. On My Lessons an instructor may only open their own lessons; the studio Scheduler may open any.
- The **Student Registration** block's "registration is by invitation only" message is now customisable, under a new **Invitation-only notice** panel (shortcode: `invite_only_message`). Leaving it blank keeps the default wording.
### Changed
- The student **upcoming lessons** panel now shows each booked offering's name and length beside the time, and lists only the soonest five lessons with a "Show all" reveal. The Scheduler and My Lessons week/list views likewise show the booked offering.
### Fixed
- Accepting an invitation now keeps the student signed in. Previously the registration form processed the submission after the page had started rendering, so the sign-in cookie was never sent and the new student was bounced back to the (logged-out) registration page; it is now handled before any output, and the student lands logged in.
- Account-registration questions now save. On sites first installed before account-scope questions existed, the `us_questions.offering_id` column was left `NOT NULL` (the schema migration relied on `dbDelta`, which does not reliably relax a column to allow `NULL`), so saving an account question failed with "Column 'offering_id' cannot be null". A one-time, self-healing migration relaxes the column on the next load.
## [1.1.1]
### Fixed
- The **Enable auto-updates** toggle now appears for the plugin on the Plugins screen. The self-updater now reports the plugin to WordPress even when it is already current, so core marks it update-supported and shows the toggle; previously the toggle was hidden between releases.
## [1.1.0]
### Added
- Invite-only group classes, so a class can be restricted to students who hold an invite.
- Instructor group-class roster view under **My Lessons**.
- Cancellation cutoff that limits how close to a lesson a student can cancel.
- Studio-defined account-registration questions collected during student sign-up.
- Group classes now carry a specific class time (alongside the date and duration), and studio admins can assign the teaching instructor. Assigning an instructor clears their open booking slots at the class time and flags any already-booked lesson that clashes.
- Students see who teaches each group class and when it meets on the enrolment page. Instructor names in the group-class views (front and back end) show the instructor's real name (first + last) or nickname, never their login/username.
### Changed
- Plugin metadata links now point at Unsupervised and the Gitea repository.
- The instructor **My Group Classes** view is now a summary of classes with enrolment counts; each class links through to a per-class **details page** (class schedule, roster, and — for invite-only classes — the controls to add or invite students), rather than listing every student inline. Managing who is in an invite-only class is now done from that details page. The studio-admin **Group Classes** page is likewise a per-class summary that links through to the same details page, so a studio admin — including an owner-operator who also teaches — can view any class's roster and manage its invite-only membership.
## [1.0.0]
First stable release: instructor/student lesson scheduling for WordPress, including
availability management, one-on-one and group-class booking, Stripe payments, student
self-registration with email confirmation and admin approval, group invite links, and
self-update from tagged Gitea releases.
+7 -1
View File
@@ -38,6 +38,8 @@ src/ — All plugin PHP (PSR-4 namespace: Unsupervised\Schedula
AdminMenu.php — Registers wp-admin menu pages
RestRegistrar.php — Registers all REST routes under us-scheduler/v1
ShortcodeRegistrar.php — Registers [us_booking] and [us_student_login] shortcodes
BlockRegistrar.php — Registers Gutenberg dynamic-block wrappers for the shortcodes
BlockPreview.php — Static editor-preview markup for the blocks
templates/ — PHP view files included by controllers/shortcodes
assets/ — CSS and JS (vanilla JS, no build step)
tests/Unit/ — PHPUnit unit tests (PSR-4: Unsupervised\Schedular\Tests\)
@@ -66,6 +68,9 @@ All database access goes through repository classes within their domain package.
| `AdminMenu` | Registers wp-admin menu pages |
| `RestRegistrar` | Registers all REST routes under `us-scheduler/v1` |
| `ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes |
| `BlockRegistrar` | Registers Gutenberg dynamic-block wrappers for the shortcodes |
| `BlockPreview` | Static editor-preview markup for the blocks |
| `Val` | Runtime coercion of untyped WP boundary values (wpdb rows, REST params, superglobals) |
| `Auth\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps |
| `Auth\LoginPage` | Renders front-end student login form |
| `Availability\AvailabilitySlot` | Immutable value object for a slot row |
@@ -95,6 +100,7 @@ All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()
- When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method
### Adding a Feature
0. **If the feature touches `Schema.php`, bump both the `Version:` header and `USC_VERSION` in `unsupervised-schedular.php`.** `Plugin::boot()` only re-runs `Installer`/`dbDelta` when the stored `us_schedular_version` differs, so a schema change without a version bump never reaches existing sites and inserts into new columns fail silently.
1. Write the feature doc in `docs/features/<feature-name>.md` (data model, API, classes, test paths).
2. Create a domain package under `src/<Domain>/` containing all classes for that feature.
3. Add template(s) under `templates/` if needed.
@@ -104,6 +110,6 @@ All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()
### CI
Gitea Actions (`.gitea/workflows/ci.yml`) runs on every push and pull request:
- **lint** — PHPCS WordPress coding standards
- **static-analysis** — PHPStan level 6
- **static-analysis** — PHPStan level 10
- **test** — PHPUnit on PHP 8.1, 8.2, 8.3
- **no-debug** — rejects commits with `var_dump`, `error_log`, etc. in `src/`
+5 -9
View File
@@ -2,9 +2,9 @@
A WordPress plugin for instructor/student lesson scheduling — private lessons and
group classes — with offerings, intake questions, versioned policies, account
registration, and (coming) online payments.
registration, and online payments.
**Version:** 1.0.0-rc.1 · **Requires:** WordPress 6.0+, PHP 8.1+ · **License:** GPL-2.0-or-later
**Version:** 1.0.0 · **Requires:** WordPress 6.0+, PHP 8.1+ · **License:** GPL-2.0-or-later
> Pre-release. The booking platform is being built feature-by-feature; see
> [Implementation status](#implementation-status) below.
@@ -31,17 +31,13 @@ model, REST API, classes, and tests. For contributor/architecture guidance see
| Availability (durations, weekly recurrence, calendar) | [availability-management.md](docs/features/availability-management.md) | ✅ Implemented |
| Registration questions (per-offering intake) | [registration-questions.md](docs/features/registration-questions.md) | ✅ Implemented |
| Policies (drafting, versioning, tracked acceptance) | [policies.md](docs/features/policies.md) | ✅ Implemented |
| Account registration (invite-only, signup policy acceptance) | [account-registration.md](docs/features/account-registration.md) | ✅ Implemented |
| Account registration (invite or open self-approval, email confirmation, signup policy acceptance) | [account-registration.md](docs/features/account-registration.md) | ✅ Implemented |
| Lesson booking (offering → questions → policies) | [lesson-booking.md](docs/features/lesson-booking.md) | ✅ Implemented |
| Group classes (capacity-enforced enrolment) | [group-classes.md](docs/features/group-classes.md) | ✅ Implemented |
| Student administration (studio-admin view) | [student-administration.md](docs/features/student-administration.md) | ✅ Implemented |
| Payments (e-transfer/comp + receipts + HST; Stripe card charge pending) | [payments.md](docs/features/payments.md) | 🟡 Partial |
| Payments (Stripe card charge + e-transfer/comp + receipts + HST) | [payments.md](docs/features/payments.md) | ✅ Implemented |
| Payment reporting (monthly per-instructor + HST + CSV) | [payment-reporting.md](docs/features/payment-reporting.md) | ✅ Implemented |
> Payments are deliberately deferred to the end: booking and enrolment ship with a
> clean seam (a lesson lands `pending`, an enrolment `active`, with `payment_id`
> null) into which the pay→confirm + receipt step plugs later.
## Shortcodes
| Shortcode | Purpose |
@@ -49,7 +45,7 @@ model, REST API, classes, and tests. For contributor/architecture guidance see
| `[us_booking]` | Student calendar + private-lesson registration flow |
| `[us_group_classes]` | Browse and enrol in group classes |
| `[us_student_login]` | Front-end student login |
| `[us_student_register]` | Invite-based account registration (accepts signup policies) |
| `[us_student_register]` | Account registration — invite-based, or open self-signup with email confirmation + admin approval (accepts signup policies) |
## REST API
+224
View File
@@ -33,3 +33,227 @@
color: #c00;
margin-top: 8px;
}
.us-my-lessons {
margin-bottom: 24px;
}
.us-my-lesson {
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px 16px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.us-my-lesson-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.us-my-lesson-title {
font-size: 1.05em;
}
.us-my-lesson-duration {
font-weight: normal;
color: #666;
}
.us-my-lesson-when {
color: #555;
}
.us-my-lesson-actions {
display: flex;
gap: 12px;
align-items: center;
}
.us-show-all-lessons {
background: transparent;
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 14px;
cursor: pointer;
}
.us-show-all-lessons:hover {
border-color: #888;
}
.us-cancel-lesson {
background: transparent;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px 12px;
cursor: pointer;
color: #c00;
}
.us-cancel-lesson:hover {
border-color: #c00;
}
.us-lesson-status {
font-size: 0.85em;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
background: #eee;
}
.us-lesson-status-confirmed {
background: #e2f5e5;
color: #1a7d2e;
}
.us-lesson-status-pending {
background: #fdf3d7;
color: #8a6d1a;
}
.us-calendar-controls {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.us-filter-toggle {
padding: 6px 16px;
border: 1px solid #ccc;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.us-filter-toggle.us-active {
background: #333;
border-color: #333;
color: #fff;
}
.us-type-filter {
margin-bottom: 12px;
padding: 8px 12px;
border: 1px solid #eee;
border-radius: 4px;
}
.us-type-filter-heading {
display: block;
margin-bottom: 6px;
font-weight: 600;
}
.us-type-filter-choices {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
}
.us-type-filter-choice {
display: inline-flex;
align-items: center;
gap: 6px;
}
.us-type-filter-clear {
margin-left: auto;
padding: 4px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.us-view-toggle {
display: flex;
gap: 8px;
}
.us-view-toggle button {
padding: 6px 16px;
border: 1px solid #ccc;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.us-view-toggle button.us-active {
background: #333;
border-color: #333;
color: #fff;
}
.us-week-nav {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.us-week-nav button {
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.us-week-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
}
.us-week-day {
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px;
min-height: 90px;
}
.us-week-day-heading {
margin: 0 0 8px;
font-size: 0.85em;
text-align: center;
}
.us-week-slot {
display: block;
width: 100%;
margin-bottom: 6px;
}
.us-week-empty {
display: block;
text-align: center;
opacity: 0.4;
}
@media (max-width: 640px) {
.us-week-grid {
grid-template-columns: 1fr;
}
.us-week-day {
min-height: 0;
}
}
/* Shown only in block-editor previews (see BlockPreview). */
.us-editor-note {
font-size: 0.85em;
font-style: italic;
opacity: 0.7;
}
+316
View File
@@ -0,0 +1,316 @@
/* global wp */
(function () {
'use strict';
const { registerBlockType } = wp.blocks;
const { createElement: el, useState, useEffect } = wp.element;
const { useBlockProps, InspectorControls } = wp.blockEditor;
const { PanelBody, SelectControl, ToggleControl, TextareaControl } = wp.components;
const { useSelect } = wp.data;
const apiFetch = wp.apiFetch;
const ServerSideRender = wp.serverSideRender;
const { __ } = wp.i18n;
/**
* Dropdown of published pages with a leading "default" choice.
* Values are page IDs; 0 means the default behaviour.
*/
function PageSelect(props) {
const pages = useSelect(
(select) => select('core').getEntityRecords('postType', 'page', {
per_page: -1,
orderby: 'title',
order: 'asc',
status: 'publish',
_fields: 'id,title',
}),
[]
);
const options = [{ label: props.defaultLabel, value: '0' }].concat(
(pages || []).map((page) => ({
label: (page.title && page.title.rendered) || __('(no title)', 'unsupervised-schedular'),
value: String(page.id),
}))
);
return el(SelectControl, {
label: props.label,
help: props.help,
value: String(props.value || 0),
options: options,
onChange: (value) => props.onChange(parseInt(value, 10) || 0),
});
}
/**
* Dropdown of active group classes fetched from the plugin's public
* offerings endpoint. Values are offering IDs; 0 means all classes.
*/
function GroupClassSelect(props) {
const [offerings, setOfferings] = useState(null);
useEffect(() => {
apiFetch({ path: '/us-scheduler/v1/offerings?kind=group_class' })
.then(setOfferings)
.catch(() => setOfferings([]));
}, []);
const options = [{ label: __('All classes', 'unsupervised-schedular'), value: '0' }].concat(
(offerings || []).map((o) => ({
label: o.title || __('(no title)', 'unsupervised-schedular'),
value: String(o.id),
}))
);
// A previously chosen class that is no longer offered (deleted or
// deactivated) keeps its stored id visible instead of silently
// pretending "All classes" is selected.
const value = String(props.value || 0);
if (offerings !== null && !options.some((opt) => opt.value === value)) {
options.push({
label: __('Unavailable class #', 'unsupervised-schedular') + value,
value: value,
});
}
return el(SelectControl, {
label: props.label,
help: props.help,
value: value,
options: options,
onChange: (newValue) => props.onChange(parseInt(newValue, 10) || 0),
});
}
/**
* Dropdown of active private-lesson types fetched from the plugin's public
* offerings endpoint. Values are offering IDs; 0 means every type.
*/
function LessonTypeSelect(props) {
const [offerings, setOfferings] = useState(null);
useEffect(() => {
apiFetch({ path: '/us-scheduler/v1/offerings?kind=private_lesson' })
.then(setOfferings)
.catch(() => setOfferings([]));
}, []);
const options = [{ label: __('All lesson types', 'unsupervised-schedular'), value: '0' }].concat(
(offerings || []).map((o) => ({
label: o.duration_minutes
? `${o.title} (${o.duration_minutes} min)`
: (o.title || __('(no title)', 'unsupervised-schedular')),
value: String(o.id),
}))
);
// A previously chosen type that is no longer offered keeps its stored
// id visible instead of silently pretending "All lesson types" is set.
const value = String(props.value || 0);
if (offerings !== null && !options.some((opt) => opt.value === value)) {
options.push({
label: __('Unavailable lesson type #', 'unsupervised-schedular') + value,
value: value,
});
}
return el(SelectControl, {
label: props.label,
help: props.help,
value: value,
options: options,
onChange: (newValue) => props.onChange(parseInt(newValue, 10) || 0),
});
}
const blocks = [
{
name: 'us-scheduler/booking',
title: __('Lesson Booking', 'unsupervised-schedular'),
description: __('Lets students browse availability and book lessons. Shows a styled preview in the editor.', 'unsupervised-schedular'),
icon: 'calendar-alt',
keywords: ['booking', 'lesson', 'schedule'],
shortcode: 'us_booking',
attributes: {
loginPageId: { type: 'number', default: 0 },
autoRedirect: { type: 'boolean', default: false },
lessonTypeId: { type: 'number', default: 0 },
showTypeFilter: { type: 'boolean', default: true },
displayMode: { type: 'string', default: 'both' },
},
inspector: (attributes, setAttributes) => [
el(
PanelBody,
{ title: __('What to show', 'unsupervised-schedular'), key: 'display' },
el(SelectControl, {
label: __('Sections', 'unsupervised-schedular'),
help: __('Split the page in two: a booking calendar here, the students upcoming lessons somewhere else.', 'unsupervised-schedular'),
value: attributes.displayMode || 'both',
options: [
{ label: __('Booking and upcoming lessons', 'unsupervised-schedular'), value: 'both' },
{ label: __('Booking only', 'unsupervised-schedular'), value: 'booking' },
{ label: __('Upcoming lessons only', 'unsupervised-schedular'), value: 'upcoming' },
],
onChange: (displayMode) => setAttributes({ displayMode }),
})
),
el(
PanelBody,
{ title: __('Lesson types', 'unsupervised-schedular'), key: 'lesson-types' },
el(LessonTypeSelect, {
label: __('Lesson type', 'unsupervised-schedular'),
help: __('Show only the times bookable as one lesson type, for embedding on a page dedicated to it. That type is then the only one students can book here.', 'unsupervised-schedular'),
value: attributes.lessonTypeId,
onChange: (lessonTypeId) => setAttributes({ lessonTypeId }),
}),
el(ToggleControl, {
label: __('Show the lesson-type filter', 'unsupervised-schedular'),
help: __('Offer students the “Show Only” button that narrows the calendar to chosen lesson types. Not used when a single lesson type is set above.', 'unsupervised-schedular'),
checked: attributes.showTypeFilter !== false,
onChange: (showTypeFilter) => setAttributes({ showTypeFilter }),
})
),
el(
PanelBody,
{ title: __('Logged-out visitors', 'unsupervised-schedular'), key: 'logged-out' },
el(PageSelect, {
label: __('Login page', 'unsupervised-schedular'),
help: __('Where the log-in link sends visitors who are not logged in.', 'unsupervised-schedular'),
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }),
}),
el(ToggleControl, {
label: __('Redirect automatically', 'unsupervised-schedular'),
help: __('Send logged-out visitors straight to the login page instead of showing a link.', 'unsupervised-schedular'),
checked: !!attributes.autoRedirect,
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
})
),
],
},
{
name: 'us-scheduler/student-login',
title: __('Student Login', 'unsupervised-schedular'),
description: __('The front-end login form for students.', 'unsupervised-schedular'),
icon: 'admin-users',
keywords: ['login', 'student', 'sign in'],
shortcode: 'us_student_login',
attributes: {
bookingPageId: { type: 'number', default: 0 },
autoRedirect: { type: 'boolean', default: false },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('Logged-in visitors', 'unsupervised-schedular') },
el(PageSelect, {
label: __('Booking page', 'unsupervised-schedular'),
help: __('Where students are sent after logging in, and where the link shown to already-logged-in visitors points.', 'unsupervised-schedular'),
defaultLabel: __('This page', 'unsupervised-schedular'),
value: attributes.bookingPageId,
onChange: (bookingPageId) => setAttributes({ bookingPageId }),
}),
el(ToggleControl, {
label: __('Redirect automatically', 'unsupervised-schedular'),
help: __('Send logged-in visitors straight to the booking page instead of showing a link. Requires a booking page to be chosen.', 'unsupervised-schedular'),
checked: !!attributes.autoRedirect,
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
})
),
},
{
name: 'us-scheduler/student-register',
title: __('Student Registration', 'unsupervised-schedular'),
description: __('The invite-only student registration form.', 'unsupervised-schedular'),
icon: 'welcome-add-page',
keywords: ['register', 'student', 'invite'],
shortcode: 'us_student_register',
attributes: {
loginPageId: { type: 'number', default: 0 },
autoRedirect: { type: 'boolean', default: false },
inviteOnlyMessage: { type: 'string', default: '' },
},
inspector: (attributes, setAttributes) => [
el(
PanelBody,
{ title: __('After registration', 'unsupervised-schedular'), key: 'confirmation' },
el(PageSelect, {
label: __('Sign-in page', 'unsupervised-schedular'),
help: __('Where students are sent once registration finishes — after they confirm their email address, or straight away for an invited student.', 'unsupervised-schedular'),
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }),
}),
el(ToggleControl, {
label: __('Redirect automatically', 'unsupervised-schedular'),
help: __('Send students straight to that page instead of showing the link. Requires a page to be chosen; errors and the "check your email" step are never skipped.', 'unsupervised-schedular'),
checked: !!attributes.autoRedirect,
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
})
),
el(
PanelBody,
{ title: __('Invitation-only notice', 'unsupervised-schedular'), key: 'invite-only' },
el(TextareaControl, {
label: __('Message', 'unsupervised-schedular'),
help: __('Shown when registration is invite-only and the visitor has no valid invite link. Leave blank to use the default wording.', 'unsupervised-schedular'),
value: attributes.inviteOnlyMessage,
onChange: (inviteOnlyMessage) => setAttributes({ inviteOnlyMessage }),
})
),
],
},
{
name: 'us-scheduler/group-classes',
title: __('Group Classes', 'unsupervised-schedular'),
description: __('Lets students browse and enrol in group classes. Shows a styled preview in the editor.', 'unsupervised-schedular'),
icon: 'groups',
keywords: ['group', 'class', 'enrol'],
shortcode: 'us_group_classes',
attributes: {
offeringId: { type: 'number', default: 0 },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('Classes shown', 'unsupervised-schedular') },
el(GroupClassSelect, {
label: __('Class', 'unsupervised-schedular'),
help: __('Show only one group class, for embedding on a page dedicated to it. That classs description is left out — the card shows just the schedule, price and enrolment controls.', 'unsupervised-schedular'),
value: attributes.offeringId,
onChange: (offeringId) => setAttributes({ offeringId }),
})
),
},
];
blocks.forEach((def) => {
registerBlockType(def.name, {
apiVersion: 3,
title: def.title,
description: def.description,
icon: def.icon,
category: 'widgets',
keywords: def.keywords,
supports: { html: false, multiple: false },
attributes: def.attributes || {},
example: {},
edit: function Edit(props) {
const inspector = def.inspector
? el(InspectorControls, {}, def.inspector(props.attributes, props.setAttributes))
: null;
return el(
'div',
useBlockProps(),
inspector,
el(ServerSideRender, { block: def.name, attributes: props.attributes })
);
},
save: () => null,
transforms: {
from: [{ type: 'shortcode', tag: def.shortcode }],
},
});
});
}());
+478 -27
View File
@@ -6,10 +6,16 @@
if (!app) return;
const slotList = document.getElementById('us-slot-list');
const myLessons = document.getElementById('us-my-lessons');
const confirm = document.getElementById('us-booking-confirmation');
const errorBox = document.getElementById('us-booking-error');
const { restUrl, nonce } = usScheduler;
// Per-instance options from the block/shortcode: pin the page to a single
// lesson type, and whether the "Show Only" filter is offered at all.
const pinnedTypeId = Number(app.dataset.lessonType) || 0;
const filterEnabled = app.dataset.typeFilter !== '0';
function apiFetch(path, options = {}) {
return fetch(restUrl + path, {
...options,
@@ -43,7 +49,13 @@
}
const dayKey = (dt) => String(dt).slice(0, 10);
const timeOf = (dt) => String(dt).slice(11, 16);
// "2026-07-06 14:30:00" → "2:30 PM"
function timeOf(dt) {
const hours = Number(String(dt).slice(11, 13));
const minutes = String(dt).slice(14, 16);
return `${hours % 12 || 12}:${minutes} ${hours < 12 ? 'AM' : 'PM'}`;
}
function dayLabel(key) {
const date = new Date(key + 'T00:00:00');
@@ -53,6 +65,12 @@
});
}
function shortDayLabel(key) {
const date = new Date(key + 'T00:00:00');
if (Number.isNaN(date.getTime())) return key;
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
function groupByDay(slots) {
const groups = new Map();
slots.forEach((slot) => {
@@ -63,14 +81,128 @@
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}
// Agenda-style calendar: available slots grouped by day.
function renderSlots(slots) {
if (!slots.length) {
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
return;
// --- calendar view state (week is the default; week keeps its position) ---
let allSlots = [];
let view = 'week';
let weekStart = null;
// Every active private-lesson type the student may book, across instructors.
let catalog = [];
// Lesson types the student has filtered the calendar down to; empty means
// "no filter" — every open slot is shown. The list starts collapsed behind
// the "Show Only" button and stays open across re-renders once revealed.
const selectedTypeIds = new Set();
let filterOpen = false;
// Whether an offering can be booked into a slot — the client-side mirror of
// the rule `POST /bookings` enforces: a slot tied to an offering takes that
// offering only, and a generic slot takes any of its instructor's types
// whose length fits.
function offeringFitsSlot(offering, slot) {
if (Number(offering.instructor_id) !== Number(slot.instructor_id)) return false;
const tiedId = Number(slot.offering_id) || 0;
if (tiedId) return Number(offering.id) === tiedId;
return !offering.duration_minutes
|| Number(offering.duration_minutes) === Number(slot.duration_minutes);
}
slotList.innerHTML = groupByDay(slots).map(([key, daySlots]) => `
const filterActive = () => selectedTypeIds.size > 0;
const typeSelected = (offering) => !filterActive() || selectedTypeIds.has(Number(offering.id));
// The lesson types this slot could be booked as, honouring the filter.
function slotChoices(slot) {
return catalog.filter((o) => offeringFitsSlot(o, slot) && typeSelected(o));
}
// With a filter set, a slot is only shown when one of the chosen lesson
// types can actually be booked into it.
function visibleSlots() {
if (!filterActive()) return allSlots;
return allSlots.filter((slot) => slotChoices(slot).length > 0);
}
const pad = (n) => String(n).padStart(2, '0');
const toKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
function addDays(key, days) {
const date = new Date(key + 'T00:00:00');
date.setDate(date.getDate() + days);
return toKey(date);
}
// First day of the week containing `key`, honouring the site's
// start-of-week setting (0 = Sunday … 6 = Saturday).
function weekStartOf(key) {
const startOfWeek = Number(usScheduler.startOfWeek) || 0;
const date = new Date(key + 'T00:00:00');
return addDays(key, -((date.getDay() - startOfWeek + 7) % 7));
}
// The calendar's control row: the view toggle, and the button that reveals
// the lesson-type filter beneath it.
function controlsHtml() {
return `
<div class="us-calendar-controls">
<div class="us-view-toggle" role="group" aria-label="Calendar view">
<button type="button" id="us-view-list" class="${view === 'list' ? 'us-active' : ''}">List</button>
<button type="button" id="us-view-week" class="${view === 'week' ? 'us-active' : ''}">Week</button>
</div>
${filterToggleHtml()}
</div>`;
}
// Nothing to filter with a single bookable type, so the control only
// appears once there is a choice to make.
function filterToggleHtml() {
if (!filterEnabled || catalog.length < 2) return '';
const count = filterActive() ? ` (${selectedTypeIds.size})` : '';
return `
<button type="button" id="us-filter-toggle" class="us-filter-toggle${filterActive() ? ' us-active' : ''}"
aria-expanded="${filterOpen}" aria-controls="us-type-filter">Show Only${count}</button>`;
}
// "Piano Lesson (30 min)" — the instructor's name is only worth the space
// when the catalog spans more than one of them.
function filterLabel(offering) {
const duration = offering.duration_minutes ? ` (${offering.duration_minutes} min)` : '';
const instructors = new Set(catalog.map((o) => Number(o.instructor_id)));
const who = instructors.size > 1 && offering.instructor_name
? `${offering.instructor_name}`
: '';
return `${offering.title}${duration}${who}`;
}
// The lesson-type list itself — collapsed until the student opens it, and
// rendered between the control row and the calendar.
function filterHtml() {
if (!filterEnabled || catalog.length < 2 || !filterOpen) return '';
const choices = catalog.map((o) => `
<label class="us-type-filter-choice">
<input type="checkbox" class="us-type-filter-option" value="${o.id}" ${selectedTypeIds.has(Number(o.id)) ? 'checked' : ''}>
${escHtml(filterLabel(o))}
</label>
`).join('');
return `
<div class="us-type-filter" id="us-type-filter" role="group" aria-label="Filter by lesson type">
<span class="us-type-filter-heading">Lesson type</span>
<div class="us-type-filter-choices">
${choices}
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''}
</div>
</div>`;
}
// Agenda-style calendar: available slots grouped by day.
function listHtml(slots) {
return groupByDay(slots).map(([key, daySlots]) => `
<div class="us-day">
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
${daySlots.map((slot) => `
@@ -81,10 +213,124 @@
`).join('')}
</div>
`).join('');
}
slotList.querySelectorAll('.us-book-btn').forEach((btn) => {
const slot = slots.find((s) => String(s.id) === btn.dataset.slotId);
btn.addEventListener('click', () => openRegistration(slot));
// Weekly calendar: seven day columns with a bookable button per slot.
function weekHtml(slots) {
const byDay = new Map(groupByDay(slots));
const days = [...Array(7).keys()].map((i) => addDays(weekStart, i));
const columns = days.map((key) => {
const daySlots = byDay.get(key) || [];
const buttons = daySlots.map((slot) => `
<button data-slot-id="${slot.id}" class="us-book-btn us-week-slot" title="${escHtml(String(slot.duration_minutes))} min">
${escHtml(timeOf(slot.start_dt))}
</button>
`).join('');
return `
<div class="us-week-day">
<h4 class="us-week-day-heading">${escHtml(shortDayLabel(key))}</h4>
${buttons || '<span class="us-week-empty" aria-hidden="true">—</span>'}
</div>`;
}).join('');
return `
<div class="us-week-nav">
<button type="button" id="us-week-prev">&lsaquo; Previous week</button>
<strong class="us-week-label">Week of ${escHtml(shortDayLabel(weekStart))}</strong>
<button type="button" id="us-week-next">Next week &rsaquo;</button>
</div>
<div class="us-week-grid">${columns}</div>`;
}
function render() {
const slots = visibleSlots();
// The pinned lesson type is no longer on offer (deactivated or
// deleted), so this page has nothing it is allowed to book.
if (pinnedTypeId && !catalog.length) {
slotList.innerHTML = '<p>This lesson type is not available for booking right now.</p>';
return;
}
// Nothing open at all: there is nothing for the controls to act on.
if (!allSlots.length) {
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
return;
}
if (!slots.length) {
const message = pinnedTypeId
? '<p>No open times for this lesson type right now.</p>'
: '<p>No open times match the selected lesson types.</p>';
slotList.innerHTML = controlsHtml() + filterHtml() + message;
wireControlEvents();
return;
}
// Anchor the week view to the week of the earliest matching slot (the
// API returns slots ordered by start), so the first look is never empty.
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(slots[0].start_dt));
slotList.innerHTML = controlsHtml() + filterHtml() + (view === 'week' ? weekHtml(slots) : listHtml(slots));
wireControlEvents();
wireCalendarEvents();
}
function wireControlEvents() {
document.getElementById('us-view-list').addEventListener('click', () => {
view = 'list';
render();
});
document.getElementById('us-view-week').addEventListener('click', () => {
view = 'week';
render();
});
const toggle = document.getElementById('us-filter-toggle');
if (toggle) {
toggle.addEventListener('click', () => {
filterOpen = !filterOpen;
render();
});
}
slotList.querySelectorAll('.us-type-filter-option').forEach((input) => {
input.addEventListener('change', () => {
const id = Number(input.value);
if (input.checked) {
selectedTypeIds.add(id);
} else {
selectedTypeIds.delete(id);
}
// The nearest matching time may be weeks away, so re-anchor the
// week view instead of leaving the student on an empty week.
weekStart = null;
render();
});
});
const clear = document.getElementById('us-type-filter-clear');
if (clear) {
clear.addEventListener('click', () => {
selectedTypeIds.clear();
weekStart = null;
render();
});
}
}
function wireCalendarEvents() {
const prev = document.getElementById('us-week-prev');
const next = document.getElementById('us-week-next');
if (prev) prev.addEventListener('click', () => { weekStart = addDays(weekStart, -7); render(); });
if (next) next.addEventListener('click', () => { weekStart = addDays(weekStart, 7); render(); });
slotList.querySelectorAll('.us-book-btn[data-slot-id]').forEach((btn) => {
const slot = allSlots.find((s) => String(s.id) === btn.dataset.slotId);
if (slot) btn.addEventListener('click', () => openRegistration(slot));
});
}
@@ -114,23 +360,77 @@
</div>`;
}
// "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)"
function offeringLabel(o) {
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
const price = Number(o.price) > 0
? `$${Number(o.price).toFixed(2)} ${o.currency}`
: 'Free';
return `${o.title} (${duration}${price})`;
}
function openRegistration(slot) {
clearError();
const offeringId = Number(slot.offering_id) || 0;
const qPath = offeringId ? `offerings/${offeringId}/questions` : null;
Promise.all([
qPath ? apiFetch(qPath) : Promise.resolve([]),
apiFetch('policies?scope=booking'),
])
.then(([questions, policies]) => {
renderRegistration(slot, offeringId, questions, policies);
})
apiFetch('policies?scope=booking')
.then((policies) => renderRegistration(slot, policies))
.catch((err) => showError(err.message));
}
function renderRegistration(slot, offeringId, questions, policies) {
function offeringFieldHtml(tied, tiedId, choices) {
if (tiedId) {
// The slot is tied to one offering: show it locked so the student
// sees exactly what they are booking.
const label = tied ? offeringLabel(tied) : `Offering #${tiedId}`;
return `
<p class="us-offering">
<label>Lesson type<br>
<select id="us-offering" disabled><option>${escHtml(label)}</option></select></label>
</p>`;
}
// Only one type is left to book this slot as — usually because the
// filter narrowed it down — so it is chosen for the student.
if (choices.length === 1) {
return `
<p class="us-offering">
<label>Lesson type<br>
<select id="us-offering" required>
<option value="${choices[0].id}" selected>${escHtml(offeringLabel(choices[0]))}</option>
</select></label>
</p>`;
}
return `
<p class="us-offering">
<label>Lesson type<br>
<select id="us-offering" required>
<option value="">— Choose a lesson type —</option>
${choices.map((o) => `<option value="${o.id}">${escHtml(offeringLabel(o))}</option>`).join('')}
</select></label>
</p>`;
}
function renderRegistration(slot, policies) {
const tiedId = Number(slot.offering_id) || 0;
const tied = tiedId ? catalog.find((o) => Number(o.id) === tiedId) : null;
// Generic slots offer every lesson type that fits the slot — narrowed to
// the filtered types when the student has set a filter.
const choices = tiedId ? [] : slotChoices(slot);
if (!tiedId && !choices.length) {
// The server rejects offering-less bookings, so without a matching
// lesson type this time cannot be booked online.
slotList.innerHTML = `
<div class="us-register">
<p>This time cannot be booked online right now. Please contact the instructor.</p>
<p><button type="button" id="us-cancel" class="us-cancel-btn">Back</button></p>
</div>`;
document.getElementById('us-cancel').addEventListener('click', loadSlots);
return;
}
const weekly = slot.recurrence_group
? `<p><label><input type="checkbox" id="us-weekly"> Reserve this time weekly for the term</label></p>`
: '';
@@ -139,7 +439,8 @@
<div class="us-register">
<h3>${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}${escHtml(timeOf(slot.end_dt))}</h3>
<form id="us-register-form">
${questions.map(questionField).join('')}
${offeringFieldHtml(tied, tiedId, choices)}
<div id="us-questions"></div>
${policies.map(policyField).join('')}
${weekly}
<p>
@@ -149,10 +450,44 @@
</form>
</div>`;
// The intake questions belong to the selected offering, so they follow
// the picker instead of being fixed at render time. A tied slot — or a
// lone remaining type — is already decided, so its questions load
// straight away.
let selectedId = tiedId || (choices.length === 1 ? Number(choices[0].id) : 0);
let questions = [];
const questionsBox = document.getElementById('us-questions');
function loadQuestions() {
questions = [];
questionsBox.innerHTML = '';
if (!selectedId) return;
apiFetch(`offerings/${selectedId}/questions`)
.then((qs) => {
questions = qs;
questionsBox.innerHTML = qs.map(questionField).join('');
})
.catch((err) => showError(err.message));
}
if (!tiedId) {
document.getElementById('us-offering').addEventListener('change', (e) => {
selectedId = Number(e.target.value) || 0;
loadQuestions();
});
}
loadQuestions();
document.getElementById('us-cancel').addEventListener('click', loadSlots);
document.getElementById('us-register-form').addEventListener('submit', (e) => {
e.preventDefault();
submitBooking(e.target, slot, offeringId, questions);
if (!selectedId) {
showError('Please choose a lesson type.');
return;
}
submitBooking(e.target, slot, selectedId, questions);
});
}
@@ -179,23 +514,139 @@
accepted_policy_version_ids: accepted,
}),
})
.then((res) => window.usPayment.collect('lesson', (res.ids || [])[0], slotList))
.then((result) => showConfirmation(window.usPayment.message(result)))
// A booking with nothing owed has no payment, so there is no payment
// step to run — the booking is already confirmed server-side.
.then((res) => (res.payment
? window.usPayment.collect('lesson', (res.ids || [])[0], slotList)
: null))
.then((result) => {
loadMyLessons();
showConfirmation(window.usPayment.message(result));
})
.catch((err) => showError(err.message));
}
function lessonStatusLabel(status) {
if (status === 'pending') return 'Pending payment';
if (status === 'confirmed') return 'Confirmed';
return status.charAt(0).toUpperCase() + status.slice(1);
}
// How many upcoming lessons to show before the "Show all" reveal.
const INITIAL_LESSON_COUNT = 5;
function lessonRowHtml(l) {
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson';
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
return `
<div class="us-my-lesson">
<span class="us-my-lesson-info">
<strong class="us-my-lesson-title">${title}${duration}</strong>
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span>
</span>
<span class="us-my-lesson-actions">
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
</span>
</div>`;
}
function renderMyLessons(lessons) {
const upcoming = lessons.filter((l) => l.start_dt);
if (!upcoming.length) {
myLessons.innerHTML = '';
return;
}
// Show only the soonest few by default; the rest sit hidden behind a
// reveal so a busy student's list stays short.
const visible = upcoming.slice(0, INITIAL_LESSON_COUNT);
const hidden = upcoming.slice(INITIAL_LESSON_COUNT);
myLessons.innerHTML = `
<div class="us-my-lessons">
<h3>Your upcoming lessons</h3>
${visible.map(lessonRowHtml).join('')}
${hidden.length ? `
<div class="us-my-lessons-more" hidden>${hidden.map(lessonRowHtml).join('')}</div>
<button type="button" class="us-show-all-lessons">Show all ${upcoming.length} lessons</button>
` : ''}
</div>`;
const moreBox = myLessons.querySelector('.us-my-lessons-more');
const showAll = myLessons.querySelector('.us-show-all-lessons');
if (showAll && moreBox) {
showAll.addEventListener('click', () => {
moreBox.hidden = false;
showAll.remove();
});
}
myLessons.querySelectorAll('.us-cancel-lesson').forEach((btn) => {
btn.addEventListener('click', () => cancelLesson(Number(btn.dataset.lessonId)));
});
}
function cancelLesson(id) {
if (!window.confirm('Cancel this lesson? The time will be released for other students.')) {
return;
}
clearError();
apiFetch(`bookings/${id}/cancel`, { method: 'POST' })
.then(loadSlots)
.catch((err) => showError(err.message));
}
function loadMyLessons() {
if (!myLessons) return;
// The lesson list is a bonus panel: never let it break slot browsing.
apiFetch('bookings')
.then(renderMyLessons)
.catch(() => { myLessons.innerHTML = ''; });
}
function showConfirmation(message) {
confirm.textContent = message;
slotList.style.display = 'none';
confirm.style.display = 'block';
}
// The private-lesson catalog drives both the filter and the registration
// form's lesson-type picker, and it does not change while the student
// browses — so it is fetched once and kept.
let catalogLoaded = false;
function loadCatalog() {
if (catalogLoaded) return Promise.resolve(catalog);
return apiFetch('offerings?kind=private_lesson').then((list) => {
// A pinned lesson type is the only one this page may book, so the
// catalog is narrowed to it and the filter is fixed on it. With a
// single type left the "Show Only" control hides itself.
catalog = pinnedTypeId
? list.filter((o) => Number(o.id) === pinnedTypeId)
: list;
if (pinnedTypeId) selectedTypeIds.add(pinnedTypeId);
catalogLoaded = true;
return catalog;
});
}
function loadSlots() {
clearError();
loadMyLessons();
// An upcoming-lessons-only embed has no calendar to fill.
if (!slotList) return;
slotList.style.display = 'block';
confirm.style.display = 'none';
apiFetch('availability')
.then(renderSlots)
Promise.all([apiFetch('availability'), loadCatalog()])
.then(([slots]) => {
allSlots = slots;
render();
})
.catch((err) => showError(err.message));
}
+120 -8
View File
@@ -10,6 +10,13 @@
const errorBox = document.getElementById('us-group-error');
const { restUrl, nonce } = usScheduler;
// When the shortcode/block pins a single offering, only that class is
// shown, so the page can be embedded alongside a full class description.
// The class's own description is then omitted from the card — the page it
// sits on already describes the class — leaving the schedule, price and
// enrolment controls.
const singleOfferingId = Number(app.dataset.offering || 0);
function apiFetch(path, options = {}) {
return fetch(restUrl + path, {
...options,
@@ -68,20 +75,95 @@
</div>`;
}
function renderClasses(offerings) {
const groups = offerings.filter((o) => o.kind === 'group_class');
// Parse a Y-m-d date into local time; new Date('Y-m-d') would parse as
// UTC midnight and can display as the previous day in western timezones.
function formatDate(ymd) {
const [y, m, d] = ymd.split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function termLabel(o) {
if (!o.term_start) return '';
if (!o.term_end || o.term_end === o.term_start) {
return formatDate(o.term_start);
}
const weekMs = 7 * 24 * 60 * 60 * 1000;
const sessions = Math.round((new Date(o.term_end) - new Date(o.term_start)) / weekMs) + 1;
return `${formatDate(o.term_start)} ${formatDate(o.term_end)} (${sessions} weekly sessions)`;
}
// Format a stored H:i(:s) class time as a friendly local-clock label.
function timeLabel(o) {
if (!o.class_time) return '';
const [h, m] = o.class_time.split(':').map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
// The "when" line combines the date (or date range) with the class time.
function whenLabel(o) {
return [termLabel(o), timeLabel(o)].filter(Boolean).join(' · ');
}
// Today as a Y-m-d string in the visitor's local timezone, for lexicographic
// comparison against the class's Y-m-d enrolment deadline.
function todayYmd() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
// The effective enrolment deadline: the instructor's set deadline, or the
// first class day by default. Empty when the class has no dates at all.
function enrolmentDeadline(o) {
return o.enrollment_deadline || o.term_start || '';
}
// Enrolment closes at the end of the deadline day. Mirrors the server-side
// Offering::isEnrollmentOpen() gate.
function isEnrollmentOpen(o) {
const deadline = enrolmentDeadline(o);
return !deadline || todayYmd() <= deadline;
}
// Self-withdrawal closes at the end of the withdrawal-deadline day. Unlike
// enrolment there is no implicit default: an unset deadline keeps withdrawal
// open. Mirrors the server-side Offering::isWithdrawalOpen() gate.
function isWithdrawalOpen(o) {
return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline;
}
function renderClasses(offerings, enrolledMap) {
let groups = offerings.filter((o) => o.kind === 'group_class');
if (singleOfferingId) {
groups = groups.filter((o) => Number(o.id) === singleOfferingId);
}
if (!groups.length) {
list.innerHTML = '<p>No group classes are open for enrolment right now.</p>';
list.innerHTML = singleOfferingId
? '<p>This class is not open for enrolment right now.</p>'
: '<p>No group classes are open for enrolment right now.</p>';
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>` : ''}
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>
${!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('');
@@ -89,6 +171,20 @@
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
btn.addEventListener('click', () => openEnrolment(offering));
});
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId));
});
}
function withdraw(enrollmentId) {
clearError();
if (!window.confirm('Withdraw from this class? Your seat is released and any pending payment is cancelled.')) {
return;
}
apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' })
.then(loadClasses)
.catch((err) => showError(err.message));
}
function openEnrolment(offering) {
@@ -142,7 +238,11 @@
accepted_policy_version_ids: accepted,
}),
})
.then((res) => window.usPayment.collect('enrollment', res.id, list))
// An enrolment with nothing owed has no payment, so there is no
// payment step to run.
.then((res) => (res.payment
? window.usPayment.collect('enrollment', res.id, list)
: null))
.then((result) => showConfirmation(window.usPayment.message(result)))
.catch((err) => showError(err.message));
}
@@ -157,8 +257,20 @@
clearError();
list.style.display = 'block';
confirm.style.display = 'none';
apiFetch('offerings?kind=group_class')
.then(renderClasses)
// The student's own enrolments are fetched alongside the catalog so a
// class they already have an active enrolment in shows its status
// instead of offering to enrol them again (the API would reject the
// duplicate anyway). A cancelled enrolment does not block re-enrolling.
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]))
))
.catch((err) => showError(err.message));
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Progressive enhancement for the two-step student registration form.
*
* When account-signup questions are configured the form renders two panels
* (`[data-step="1"]` account details, `[data-step="2"]` the questions) inside a
* single form marked `data-steps="1"`. This script hides step two behind a
* "Next" button that only advances once step one passes native validation.
* Without JS both panels stay visible and the single submit still works.
*/
(function () {
'use strict';
function enhance(form) {
var step1 = form.querySelector('[data-step="1"]');
var step2 = form.querySelector('[data-step="2"]');
var next = form.querySelector('.us-reg-next');
var back = form.querySelector('.us-reg-back');
if (!step1 || !step2 || !next) {
return;
}
function show(step) {
step1.hidden = step !== 1;
step2.hidden = step !== 2;
}
show(1);
next.addEventListener('click', function () {
var fields = step1.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) {
if (!fields[i].checkValidity()) {
fields[i].reportValidity();
return;
}
}
show(2);
});
if (back) {
back.addEventListener('click', function () {
show(1);
});
}
}
document.addEventListener('DOMContentLoaded', function () {
var forms = document.querySelectorAll('.us-register-form form[data-steps="1"]');
for (var i = 0; i < forms.length; i++) {
enhance(forms[i]);
}
});
})();
+3 -3
View File
@@ -11,8 +11,8 @@
"phpunit/phpunit": "^10.5",
"brain/monkey": "^2.6",
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^1.10",
"szepeviktor/phpstan-wordpress": "^1.3",
"phpstan/phpstan": "^2.0",
"szepeviktor/phpstan-wordpress": "^2.0",
"php-stubs/wordpress-stubs": "^6.0",
"squizlabs/php_codesniffer": "^3.7",
"wp-coding-standards/wpcs": "^3.0"
@@ -30,7 +30,7 @@
"scripts": {
"test": "phpunit --configuration phpunit.xml",
"test:coverage": "phpunit --configuration phpunit.xml --coverage-html coverage/",
"lint": "phpstan analyse src/ --level=6 --configuration phpstan.neon --memory-limit=1G",
"lint": "phpstan analyse --configuration phpstan.neon --memory-limit=1G",
"cs": "phpcs --standard=phpcs.xml.dist",
"cs:fix": "phpcbf --standard=phpcs.xml.dist",
"build": "bash bin/build-zip.sh"
+116 -20
View File
@@ -4,27 +4,87 @@
People register for a student account through a front-end page, accepting any
signup-scoped policies at that time. Registration is **invite-only** by default: a
studio admin sends an invite, and the invitee completes signup via a tokenised
link. A settings seam (`us_registration_mode`) allows switching to open
self-registration with approval later.
link. A studio can instead switch on **open (self-approval) registration**, where
anyone may sign up, confirm their email, and then be approved by a studio admin
before the account can be used. Both modes coexist — invites keep working when
open registration is on.
A studio admin can also generate a **group invite link** — a multi-use, tokenised
link with an explicit expiry date (e.g. for a newsletter). Anyone with the link
may register while it is valid, regardless of the registration mode: they supply
their own email, must confirm it, and are then **approved automatically**
group-link signups never enter the Pending Students queue.
## Registration Modes
Stored in the `us_registration_mode` option (default `invite`):
- `invite` — only a valid, pending invite token grants access to the registration form. *(implemented)*
- `self_approval` — anyone may register; the account is created in a pending state until a studio admin approves it. *(reserved for a later iteration)*
Stored in the `us_registration_mode` option (default `invite`), toggled from
**Studio Settings → Registration**:
- `invite` — only a valid, pending invite token grants access to the registration form.
- `self_approval` — anyone may register on the registration page; each account is created in a pending state, must confirm its email, and is then approved (or rejected) by a studio admin.
### Enabling open registration
The Studio Settings toggle is the source of truth. Enabling it mirrors into the
two core WordPress options the flow relies on, and **snapshots** their previous
values (`us_registration_prev_can_register`, `us_registration_prev_default_role`):
- `users_can_register``1` (Settings → General "Anyone can register")
- `default_role``us_student`
Disabling restores the snapshot, so the toggle never permanently overwrites a
site's own membership settings. Only enable/disable *transitions* touch the core
options — saving unrelated settings leaves them alone.
See `Payment\StudioSettings::applyRegistrationMode()`.
### Blocking the native registration form
Because `users_can_register=1` also switches on WordPress's own
`wp-login.php?action=register` form — which cannot collect the required signup
policy acceptances — that form is blocked while open registration is on, so it can
never be used to create a policy-less account (`Auth\EmailConfirmationHandler`):
- `register_url` filter points WordPress's "Register" links at the registration page.
- `login_init` action redirects any `action=register` request (GET **and** POST) to the registration page before any processing runs.
- `registration_errors` filter is a fail-safe that rejects `register_new_user()` outright.
## Account Lifecycle (self-approval)
State lives entirely in user meta (`Auth\RegistrationStatus`). Only the raw
confirmation token's SHA-256 hash is stored; the token expires after 48h
(`EMAIL_CONFIRM_EXPIRY_HOURS`).
| State | User meta | Login | Booking |
|---|---|---|---|
| Email unconfirmed | `us_awaiting_approval=1`, `us_email_confirm_token`(hash) + `us_email_confirm_expires` set | blocked ("confirm your email") | — |
| Confirmed, awaiting approval | `us_awaiting_approval=1`, `us_email_confirmed=1`, token/expiry cleared | allowed | withheld → pending screen |
| Approved / active | `us_awaiting_approval` deleted, `us_email_confirmed=1` | allowed | full student |
| Rejected | account hard-deleted (`wp_delete_user`) | n/a | n/a |
| Invite/admin-created student | none of these metas | allowed | full student |
- **Login gate** (`Auth\RegistrationLoginGate`): the `wp_authenticate_user` filter blocks login while the email is unconfirmed; the `user_has_cap` filter withholds `book_lesson` while `us_awaiting_approval` is set, so a confirmed-but-unapproved student only reaches the "awaiting approval" screen on the booking page.
- **Email confirmation** (`Auth\EmailConfirmationHandler` on `template_redirect`): opening the emailed `?us_confirm=<token>` link confirms the email, notifies the studio admins, and redirects back to the registration page with `?us_confirmed=1` (or `expired`). On `?us_confirmed=1` the registration page replaces the form with the confirmation message plus a "Sign in to your account" link — the configured sign-in page (block `loginPageId` / shortcode `login_page_id` attribute), falling back to the WordPress login screen. The `expired` notice keeps the form.
- **Approval** (`Auth\RegistrationApprovalController`, **Students → Pending Students**, `manage_students`): approve clears the pending flags and emails the student; reject emails them and hard-deletes the account so the email is freed to re-apply.
- **Emails**: `Auth\RegistrationMailer` sends the confirmation link, the admin heads-up, and the approval/rejection notices.
## Data Model — `{prefix}us_invites`
| Column | Type | Notes |
|--------------------|------------------|--------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `email` | VARCHAR(191) | Invited email address |
| `token` | VARCHAR(64) | Opaque token embedded in the registration link |
| `email` | VARCHAR(191) | Invited email address; empty string for group links |
| `token` | VARCHAR(64) | SHA-256 hash of the token embedded in the registration link (raw token is never stored) |
| `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) |
| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` |
| `kind` | VARCHAR(10) | `personal` (single-use, per email) or `group` (multi-use link) |
| `offering_id` | BIGINT UNSIGNED | Set when a personal invite is tied to an invite-only group class (see `group-classes.md`); NULL otherwise |
| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` (group links stay `pending` until revoked/expired) |
| `invited_by` | BIGINT UNSIGNED | WordPress user ID of the studio admin who invited |
| `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending |
| `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending / for group links |
| `created_at` | DATETIME | Insertion time |
| `accepted_at` | DATETIME | When accepted; NULL while pending |
| `accepted_at` | DATETIME | When accepted; NULL while pending / for group links |
| `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) |
## Registration Questions (signup step two)
When the studio has configured **account-scope** registration questions
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), the
registration form becomes two steps: name/email/password/policies first, then the required
questions. This applies to **every** signup path (invite, group link, self-approval).
Required answers are validated before the account is created, and are stored against the new
user (`us_question_answers`, `registration_type = 'account'`). A studio admin reviews them
under **Registration Information** on the student's admin screen.
## Policy Acceptance Scope
Policies declare **when** they must be accepted via `us_policies.acceptance_scope`:
@@ -34,19 +94,46 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
`registration_id = <new user ID>`.
## Flow (invite mode)
1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created with a token and a registration link.
2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`).
3. The form pre-fills the email and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox.
4. On submit, the token is re-validated; a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in.
1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite.
2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash.
3. The form shows the invited email **pre-filled and read-only** (the server always uses the invite's address on submit, so a tampered value is ignored) and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. A token that is no longer redeemable (expired / accepted / revoked) renders the normal editable email field instead when open registration is on.
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. The submission is processed on `template_redirect` (`RegistrationPage::maybeHandleSubmit()`) **before** any page output so `wp_set_auth_cookie()` actually persists — it then post/redirect/gets back to the page with `?us_registered=invite`, where the now-logged-in student sees the "created and logged in" confirmation. (Processing the form inside `render()`, which runs during `the_content`, sent the cookie after headers and left the student logged out on the next view.) If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`.
## Flow (self-approval mode)
1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`).
2. Anyone opens `[us_student_register]`; the form collects an editable email, display name, password, and the required signup policies.
3. On submit a `us_student` user is created in the pending state (`RegistrationStatus::markPending()`), acceptances are recorded (`account` type), a confirmation email is sent, and the user is **not** logged in.
4. The applicant opens the emailed `?us_confirm=<token>` link → email confirmed, studio admins notified.
5. Studio admin approves under **Students → Pending Students** → pending flags cleared, student emailed; they can now log in and book. Rejection deletes the account.
## Flow (group invite link)
1. Studio admin opens **Invites** and generates a **group link**, choosing the expiry date (required; the link stops working at the end of that day). The link is shown **once**, like personal invite links.
2. Anyone opens the link while it is pending and unexpired — in **any** registration mode — and the form collects an **editable email**, display name, password, and the signup policies.
3. On submit the account is created pending with the auto-approve marker (`RegistrationStatus::markPending($userId, autoApprove: true)`, meta `us_auto_approve`) and a confirmation email is sent. The invite row is **not** marked accepted — the link remains usable by others.
4. Opening the `?us_confirm=<token>` link confirms the email and **approves the account immediately** (`EmailConfirmationHandler`): no admin heads-up, no Pending Students entry; the student gets the "approved" email and the page shows a "ready to use" notice (`?us_confirmed=ready`) with a sign-in link.
5. The link can be revoked at any time from the Invites page.
## Admin Interface
**Invites** in wp-admin (`manage_students`, studio admin only):
- Select the **registration page** (the page hosting `[us_student_register]`), stored in the `us_registration_page_id` option; invitation links point there (falling back to the home page if unset)
- Invite an email (creates a pending invite + link)
- List pending invites; revoke an invite
- Invite an email (creates a pending invite; the link is displayed once, at creation only)
- Generate a **group invite link** with a required expiry date (link displayed once)
- List pending invites (email or "Group link", created + expiry dates); revoke an invite
**Pending Students** — submenu under Students (`manage_students`), only relevant in `self_approval` mode:
- "Awaiting approval" (email confirmed) — approve or reject
- "Awaiting email confirmation" (not yet confirmed) — reject only
## Frontend Shortcode
- `[us_student_register]` — the registration page. Shows the form for a valid pending invite; otherwise shows an "by invitation only" message (in `invite` mode).
- `[us_student_register]` — the registration page. In `invite` mode: shows the form for a valid pending invite, else an "by invitation only" message. In `self_approval` mode: shows the form to anyone (editable email), and renders confirmation-result notices from `?us_confirmed=1|expired`.
- The invitation-only message is customisable: block attribute `inviteOnlyMessage` (set under the block's **Invitation-only notice** panel) / shortcode attribute `invite_only_message`. Blank falls back to the default wording (`RegistrationPage::inviteOnlyMessage()`).
## Where Students Go Next
The block's **After registration** panel picks the page a student continues to once
registration finishes, and whether they get there by hand or automatically.
- **Sign-in page** (`loginPageId` / `login_page_id`) — the target of the "Sign in to your account" link shown after email confirmation (`?us_confirmed=1|ready`, falling back to the WordPress login screen) and of the "Continue to your account" link an invited student sees on the spot (`?us_registered=invite`; no link at all with no page chosen, since an already-signed-in student has no use for the login screen).
- **Redirect automatically** (`autoRedirect`, block only) — sends the student to that page instead of showing the link, via `BlockRegistrar::maybeAutoRedirect()` on `template_redirect`. It fires only on those two finished states (`RegistrationPage::isRegistrationComplete()`), so the "check your email" step, a validation error, and an `expired` confirmation link are always shown rather than redirected past. With no page chosen nothing happens — there is deliberately no login-screen fallback for the redirect. See `editor-blocks.md`.
## Token Redirect
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
@@ -56,16 +143,25 @@ covers invitation links generated/shared before a registration page was selected
No-op when no registration page is set.
## Capabilities
- `manage_students` — manage invites (studio admin; administrators inherit it via the `user_has_cap` filter). Added to `RoleManager::STUDIO_ADMIN_CAPS`.
- `manage_students` — manage invites and approve/reject pending students (studio admin; administrators inherit it via the `user_has_cap` filter). Added to `RoleManager::STUDIO_ADMIN_CAPS`.
## Implementation
- Models: `Unsupervised\Schedular\Auth\Invite`
- Repository: `Unsupervised\Schedular\Auth\InviteRepository`
- Admin controller: `Unsupervised\Schedular\Auth\RegistrationController`
- Admin controllers: `Unsupervised\Schedular\Auth\RegistrationController` (invites), `Unsupervised\Schedular\Auth\RegistrationApprovalController` (pending students)
- Frontend: `Unsupervised\Schedular\Auth\RegistrationPage`
- Self-approval flow: `Auth\RegistrationStatus` (lifecycle meta), `Auth\RegistrationLoginGate` (login + booking-cap gate), `Auth\EmailConfirmationHandler` (confirm link + native-form block), `Auth\RegistrationMailer` (emails)
- Settings toggle: `Payment\StudioSettings` (`us_registration_mode`, core-option mirror/restore)
- Reuses `Policy\PolicyRepository`, `Policy\PolicyVersionRepository`, `Policy\AcceptanceRepository`
- Schema: `us_invites`; `us_policies.acceptance_scope`
- Schema: `us_invites`; `us_policies.acceptance_scope`. Self-approval adds no tables — state is WordPress user meta.
## Tests
- `tests/Unit/Auth/InviteTest.php`
- `tests/Unit/Auth/InviteRepositoryTest.php`
- `tests/Unit/Auth/RegistrationStatusTest.php`
- `tests/Unit/Auth/RegistrationLoginGateTest.php`
- `tests/Unit/Auth/EmailConfirmationHandlerTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php`
- `tests/Unit/Auth/RegistrationApprovalControllerTest.php`
- `tests/Unit/Auth/RegistrationMailerTest.php`
- `tests/Unit/Payment/StudioSettingsTest.php`
+43 -13
View File
@@ -1,7 +1,7 @@
# Feature: Availability Management
## Overview
Instructors define date/time windows during which they are available for private lessons. Students book from these windows. Windows carry a lesson length and may be generated as a weekly-recurring series.
Instructors define same-day date/time windows during which they are available for private lessons. On save, a window is split into consecutive lesson-length slots (09:0016:00 with 60-minute lessons becomes seven rows), each independently bookable by students. Windows may be generated as a weekly-recurring series.
## Data Model — `{prefix}us_availability`
@@ -11,31 +11,46 @@ Instructors define date/time windows during which they are available for private
| `instructor_id` | BIGINT UNSIGNED | WordPress user ID |
| `offering_id` | BIGINT UNSIGNED | Nullable FK → `us_offerings.id` (private-lesson type) |
| `start_dt` | DATETIME | Slot start — stored as `Y-m-d H:i:s` |
| `end_dt` | DATETIME | Slot end — stored as `Y-m-d H:i:s` |
| `duration_minutes` | SMALLINT | Lesson length the window accommodates (e.g. 30, 60) |
| `end_dt` | DATETIME | Slot end — always `start_dt + duration_minutes` |
| `duration_minutes` | SMALLINT | Lesson length (e.g. 30, 60) |
| `is_booked` | TINYINT(1) | 0 = available, 1 = booked |
| `recurrence_group` | BIGINT UNSIGNED | Nullable — weekly-recurring windows share one group id |
| `created_at` | DATETIME | Insertion time |
A window's `duration_minutes` is matched against the offering a student picks: a
30-minute private offering can only be booked into a window whose
A slot's `duration_minutes` is matched against the offering a student picks: a
30-minute private offering can only be booked into a slot whose
`duration_minutes` accommodates it.
## Window Splitting
`AvailabilitySlot::splitByDuration()` chunks a submitted window into consecutive
`duration_minutes` slots; `AvailabilityRepository::createFromWindow()` persists
one row per chunk. A trailing remainder shorter than the lesson length is
dropped. Windows must start and end on the same day and fit at least one lesson
(REST responds `400 invalid_window` otherwise; the admin form is a no-op).
`AvailabilityRepository::splitOversizedWindows()` is a data migration (run by
`Installer` on activation or version change) that rewrites pre-split rows.
## Weekly-Recurring Windows
Instructors may generate a window weekly across a date range. Each occurrence is a
separate row sharing one `recurrence_group` id, so a recurring set can be added or
removed together while individual occurrences are still booked independently.
Instructors may generate a window weekly across a date range. Each lesson-length
chunk becomes its own weekly series: occurrences of the same time-of-day share
one `recurrence_group` id, so a recurring set can be added or removed together
while individual occurrences are still booked independently.
## Admin Interface
Instructors access **My Availability** in wp-admin (`?page=us-availability`).
- Add a slot: provide start/end datetime, duration, and (optionally) a linked private-lesson offering
- Add a weekly series: provide the weekday/time plus a date range
- Add availability: provide a same-day start/end window, lesson length, and (optionally) a linked private-lesson offering
- Add a weekly series: tick weekly repeat and choose the number of weeks
- Delete a slot: only allowed if `is_booked = 0`
- Bulk delete: the list view has a checkbox per unbooked slot (with a select-all header checkbox) and a **Delete selected** button (`usc_action=bulk_delete`, `slot_ids[]`); each id is ownership-checked, and booked slots are refused at the repository level
- Current slots can be shown as a **weekly calendar** (the default, navigated with `usc_week=Y-m-d`) or a **list** (`usc_view=list`); the grid honours the site's `start_of_week` option via `Availability\WeekCalendar`
## Public Calendar
The front-end booking shortcode renders a month/week calendar of open windows,
populated from `GET /availability`. Students can filter by instructor and by
offering/duration before selecting a slot to register for.
The front-end booking shortcode renders open slots from `GET /availability`
either as an agenda-style list grouped by day or as a **weekly calendar** with
previous/next-week navigation (toggle rendered by `assets/js/booking.js`; the
site's `start_of_week` option is passed through the `usScheduler` JS config).
Both views can be narrowed to the slots bookable as chosen private-lesson types
with the **Show Only** lesson-type filter — see `lesson-booking.md`.
## REST API
| Method | Endpoint | Permission |
@@ -45,13 +60,28 @@ offering/duration before selecting a slot to register for.
| `DELETE` | `/wp-json/us-scheduler/v1/availability/{id}` | `manage_availability` + slot owner |
`GET` supports query params: `instructor_id`, `offering_id`, `duration_minutes`, `from` (datetime), `to` (datetime).
Slots whose start has already passed are never returned.
`POST` validates `start_dt`/`end_dt` (admin form and REST alike) via
`AvailabilitySlot::normalizeDateTime()`: the canonical `Y-m-d H:i[:s]` and HTML
`datetime-local` (`Y-m-d\TH:i[:s]`) forms are normalised to `Y-m-d H:i:s`;
anything else — or an end not after the start — is rejected (REST responds
`400 invalid_datetime`; the admin form is a no-op). A valid window is stored as
lesson-length slots and `201` returns `{ "ids": [...] }` for every row created.
Times are displayed in 12-hour AM/PM form in the booking calendar and wp-admin
lists.
## Implementation
- Repository: `Unsupervised\Schedular\Availability\AvailabilityRepository`
- Model: `Unsupervised\Schedular\Availability\AvailabilitySlot`
- Week bucketing: `Unsupervised\Schedular\Availability\WeekCalendar`
- Admin controller: `Unsupervised\Schedular\Availability\AvailabilityController`
- REST endpoint: `Unsupervised\Schedular\Availability\AvailabilityEndpoint`
## Tests
- `tests/Unit/Availability/AvailabilityControllerTest.php`
- `tests/Unit/Availability/AvailabilityRepositoryTest.php`
- `tests/Unit/Availability/AvailabilitySlotTest.php`
- `tests/Unit/Availability/AvailabilityEndpointTest.php`
- `tests/Unit/Availability/WeekCalendarTest.php`
+69
View File
@@ -0,0 +1,69 @@
# Feature: Cancellation Cutoff
## Overview
Students may cancel their own lessons online — but not indefinitely close to the
start time. A **cancellation cutoff** closes student-initiated cancellation once
a lesson begins within a configured window. Instructors and studio admins are
never subject to the cutoff: they can cancel a lesson at any time through the
lesson-status and student-management flows.
The window is resolved per lesson:
1. If the lesson's **offering** sets its own cutoff, that value is used.
2. Otherwise the **studio default** applies.
Both values are expressed and computed in **hours**. The studio default is
entered and displayed to the admin in **days** for convenience; a per-offering
override is entered directly in hours (a finer-grained "time").
## Data Model
### Option `us_cancellation_cutoff_hours`
Studio-wide default cutoff, stored as an integer number of **hours**. Defaults to
`24` (one day) when unset. `0` means students may cancel at any time.
### Column `{prefix}us_offerings.cancellation_cutoff_hours`
Nullable `SMALLINT UNSIGNED`. `NULL` means "inherit the studio default"; any set
value (including `0` — cancel any time) overrides it for that offering.
## Resolution & Enforcement
`Booking\CancellationPolicy` owns the logic:
- `cutoffHours(?int $offeringCutoffHours): int` — the offering's override when it
is a non-negative value, otherwise the studio default.
- `studentMayCancel(string $slotStartDt, ?int $offeringCutoffHours, ?string $now): bool`
— false once `now` is within the effective cutoff of the slot start. A zero
cutoff always allows cancellation; unparseable datetimes fail open so a student
is never trapped by bad data. Comparisons use WordPress-local time
(`current_time('mysql')`), matching how upcoming lessons are computed.
- `describeCutoff(int $hours): string` — humanises a cutoff for messages
(whole days as days, otherwise hours).
`Booking\BookingEndpoint::cancel()` (the student endpoint,
`POST /bookings/{id}/cancel`) consults the policy before cancelling and returns a
`cancellation_closed` (HTTP 403) error explaining the window when it is too late.
The instructor status endpoint (`PATCH /bookings/{id}/status`) and
`Auth\StudentActions::cancelLesson()` (studio-admin student view) bypass the
policy entirely.
## Admin Interface
- **Studio Settings → Cancellations**: "Cancellation cutoff (days)" — the studio
default, entered/displayed in days, stored in hours.
- **Offerings** add/edit form: "Cancellation cutoff (hours)" — an optional
per-offering override; blank inherits the studio default, `0` allows anytime
cancellation.
## Implementation
- Service: `Unsupervised\Schedular\Booking\CancellationPolicy`
- Studio default: `Unsupervised\Schedular\Payment\StudioSettings::cancellationCutoffHours()`
(option `us_cancellation_cutoff_hours`)
- Per-offering value: `Unsupervised\Schedular\Offering\Offering::$cancellationCutoffHours`
- Enforcement: `Unsupervised\Schedular\Booking\BookingEndpoint::cancel()`
- Wiring: `RestRegistrar` constructs `new CancellationPolicy( new StudioSettings() )`
## Tests
- `tests/Unit/Booking/CancellationPolicyTest.php`
- `tests/Unit/Booking/BookingEndpointTest.php` (cutoff cases in `cancel()`)
- `tests/Unit/Payment/StudioSettingsTest.php` (default getter)
- `tests/Unit/Offering/OfferingTest.php`, `tests/Unit/Offering/OfferingRepositoryTest.php`
(new column round-trips)
+123
View File
@@ -0,0 +1,123 @@
# Feature: Student Credits (cancelled paid lessons)
## Overview
When a lesson that has **already been paid for** is cancelled, the student is
credited the amount they paid for *that lesson*. The credit sits on their account
and is automatically applied against their future scheduled-billing charges
(weekly / monthly) before they are asked to pay — so a cancelled-and-paid lesson
becomes money toward the next one rather than a manual refund.
This complements — it does not replace — the existing cancellation behaviour: a
still-**pending** payment is voided (`PaymentService::voidPending`), and only a
**paid** payment produces a credit.
## Credit amount — one lesson's share
The credit is one lesson's share of the covering payment's **total (including
tax)**:
| Covering payment | Lessons it covers | Credit on cancelling one |
|------------------|-------------------|--------------------------|
| Single booking (one-time / full-term single) | 1 | the whole total |
| Weekly **scheduled** lesson | 1 (one payment per lesson) | the whole total |
| Monthly **scheduled** charge | N lessons that month | `total ÷ N` |
| Weekly reservation **series** paid upfront (full-term) | the whole series | `total ÷ series size` |
The divisor is resolved in `PaymentService::coveredLessonCount`: a weekly series
paid upfront (an *unscheduled* payment on a lesson that has a `series_id`) divides
by the series size (`BookingRepository::countBySeries`); every other case divides
by how many lessons point at the payment (`BookingRepository::countByPaymentId`),
which is 1 for a single or weekly-scheduled lesson and N for a monthly charge.
The original payment is **left untouched** — the studio keeps the money it
collected; the credit is a forward-looking liability offset against future
billing, never a refund of past revenue.
### Guards
- Only a **paid** payment credits; an unpaid/pending one is voided instead.
- A lesson is credited **once**`CreditRepository::existsForLesson` blocks a
second credit if the same lesson is cancelled again after being reinstated.
- A non-anchor lesson in a series (no `payment_id` of its own) is credited through
the series anchor's payment.
## Applying credit at billing time
The daily scan (`Payment\ScheduledBillingRunner`) generates each student's due
payments, then — before sending the notice — applies their available credit
across those charges oldest-first (`PaymentService::applyCredits`):
- Each payment's `us_payments.credit_applied` is raised by the amount covered,
reducing what the student owes (`Payment::netDue()`).
- A payment **fully** covered by credit is marked **paid-by-credit** (status
`paid`, registration confirmed) so it drops out of the admin confirmation queue.
- A payment **partially** covered stays `pending` at its reduced net due, shown in
the admin Payments queue and on the notice.
- The credit ledger is drawn down by the total applied
(`CreditRepository::consume`, FIFO), marking each spent credit `consumed`.
The consolidated notice email (`Payment\PaymentDueMailer`) lists each charge at
its full amount, then an **"Account credit applied: -X"** line and the reduced
**Total due**. When the balance is zero the notice still goes out (so the student
knows their credit covered it) but carries no e-transfer destination or reference.
## Admin visibility
The studio admin sees a student's credit on their **student detail** page (gated by
`manage_billing`, like the payment history). An **Account credit** section shows the
available balance and a table of every credit — date, reason, original amount,
remaining, and status (`available` / `consumed`). Built by
`Auth\StudentHistory::creditBalance` / `::credits`.
## Data model — `{prefix}us_credits`
| Column | Type | Notes |
|---------------------|-----------------|---------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `student_id` | BIGINT UNSIGNED | WordPress user ID |
| `amount` | DECIMAL(10,2) | Original credit amount |
| `remaining` | DECIMAL(10,2) | Unused balance |
| `currency` | VARCHAR(3) | ISO 4217 |
| `source_payment_id` | BIGINT UNSIGNED | Payment that paid for the cancelled lesson |
| `source_lesson_id` | BIGINT UNSIGNED | The cancelled lesson (dedup key) |
| `reason` | VARCHAR(191) | Human-readable note |
| `status` | VARCHAR(20) | `available` / `consumed` |
| `created_at` | DATETIME | Insertion time |
| `updated_at` | DATETIME | Last draw-down; NULL until first consumed |
A new column on `{prefix}us_payments`:
| Column | Type | Notes |
|------------------|---------------|-----------------------------------------------------------|
| `credit_applied` | DECIMAL(10,2) | Account credit applied to this payment; `netDue = total credit_applied` |
> **Schema change:** `us_credits` and `us_payments.credit_applied` ship as part of
> the (as-yet-unreleased) **1.2.0** — the same release as scheduled billing — so
> `Installer`/`dbDelta` create them when a pre-1.2.0 site upgrades. If you are on a
> 1.2.0 *dev* build that predates this feature, the stored `us_schedular_version`
> already matches `USC_VERSION`, so `Plugin::boot()` will not re-run the installer;
> reactivate the plugin (or bump the version) to pick the new table/column up.
## Reporting caveat
Credits never touch past revenue and a credit-covered future charge is still
marked `paid`, so `PaymentReport` (which sums `status = paid`) counts the original
paid lesson and the later credit-covered lesson as gross revenue. This mirrors the
design choice to leave the original payment intact rather than represent a partial
refund of a shared payment.
## Implementation
- Model: `Unsupervised\Schedular\Payment\Credit`
- Repository: `Unsupervised\Schedular\Payment\CreditRepository`
- Issue on cancel: `PaymentService::creditForCancelledLesson`
(called from `Booking\BookingEndpoint::cancel` and `::updateStatus`)
- Apply at billing: `PaymentService::applyCredits`, driven by
`Payment\ScheduledBillingRunner::sendNotices`
- Net due: `Payment::netDue()`, `PaymentRepository::addCreditApplied`
- Lesson counts: `Booking\BookingRepository::countByPaymentId` / `countBySeries`
- Admin view: `Auth\StudentHistory::creditBalance` / `::credits`, rendered in
`templates/admin/student-detail.php`
## Tests
- `tests/Unit/Payment/CreditRepositoryTest.php`
- `tests/Unit/Payment/PaymentServiceTest.php` (`creditForCancelledLesson`, `applyCredits`)
- `tests/Unit/Payment/ScheduledBillingRunnerTest.php` (credit applied to a run)
- `tests/Unit/Payment/PaymentDueMailerTest.php` (credit line + reduced total)
- `tests/Unit/Payment/PaymentTest.php` (`netDue`)
- `tests/Unit/Booking/BookingEndpointTest.php` (credit issued on cancel)
- `tests/Unit/Auth/StudentHistoryTest.php` (`creditBalance`, `credits`)
+122
View File
@@ -0,0 +1,122 @@
# Editor Blocks
Gutenberg dynamic-block wrappers for the plugin's four front-end shortcodes,
so the pages can be previewed and styled inside the block editor instead of
appearing as grey shortcode text.
## Blocks
| Block | Wraps shortcode | Front-end renderer |
|---|---|---|
| `us-scheduler/booking` | `[us_booking]` | `Booking\BookingPage::render()` |
| `us-scheduler/student-login` | `[us_student_login]` | `Auth\LoginPage::render()` |
| `us-scheduler/student-register` | `[us_student_register]` | `Auth\RegistrationPage::render()` |
| `us-scheduler/group-classes` | `[us_group_classes]` | `GroupClass\GroupClassPage::render()` |
The shortcodes remain registered for back-compat; blocks and shortcodes share
the same page objects (constructed once in `Plugin::boot()`), so front-end
output is identical either way. Pasting a shortcode into the block editor
auto-converts it to the matching block via a `transforms.from` shortcode
transform.
## Block options
Four blocks have sidebar (inspector) options:
| Block | Attribute | Default | Effect |
|---|---|---|---|
| `us-scheduler/booking` | `loginPageId` (number) | `0` | Page the "log in to book a lesson" link points to for logged-out visitors. `0` = the WordPress login screen (with a redirect back to the current page). |
| `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. |
| `us-scheduler/booking` | `lessonTypeId` (number) | `0` | Pin the calendar to a single private-lesson type: only the times bookable as that type are listed, and it is the only type students can book here (auto-selected on the registration form). `0` = every type. Shortcode equivalent: `[us_booking lesson_type="…"]`. |
| `us-scheduler/booking` | `showTypeFilter` (boolean) | `true` | Whether students get the **Show Only** button that narrows the calendar to chosen lesson types. Unused when a single type is pinned (there is nothing to choose). Shortcode equivalent: `[us_booking show_filter="no"]`. |
| `us-scheduler/booking` | `displayMode` (string) | `both` | Which halves of the page to embed: `both`, `booking` (calendar only, no upcoming-lessons panel) or `upcoming` (the student's lessons only, nothing bookable) — so the two halves can live on different pages. Anything unrecognised falls back to `both`. Shortcode equivalent: `[us_booking show="booking"]`. |
| `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. |
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
| `us-scheduler/student-register` | `autoRedirect` (boolean) | `false` | Send students straight to that page instead of showing the link. Does nothing until a page is chosen — there is no login-screen fallback here. |
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. The class description is then omitted — only the schedule, instructor, price and enrolment controls are shown, so the surrounding page's own copy is not repeated. `0` = browse all classes, descriptions included. Shortcode equivalent: `[us_group_classes offering="…"]`. |
The page selects list all published pages; if a chosen page is later deleted,
the blocks fall back to their defaults. The group-classes block's class
select is a dropdown of active group classes fetched from
`GET /us-scheduler/v1/offerings?kind=group_class`; a stored class that is no
longer offered shows as "Unavailable class #N" rather than silently falling
back to all classes. The booking block's lesson-type select works the same way
against `?kind=private_lesson` ("Unavailable lesson type #N"), and the live
page says so plainly when the pinned type has been withdrawn.
The booking block's options reach the front end as data attributes on
`#us-booking-app` (`data-lesson-type`, `data-type-filter`) or as omitted
containers (`displayMode`), which `assets/js/booking.js` reads on load — see
`lesson-booking.md`. Its editor preview follows `displayMode`, showing the
calendar, the upcoming-lessons panel, or both. The link targets are also available
to the shortcodes as `[us_booking login_page_id="…"]` and
`[us_student_login booking_page_id="…"]`; auto-redirect is block-only.
Auto-redirect cannot happen during block rendering (output has already
started, so a `Location` header cannot be sent). Instead
`BlockRegistrar::maybeAutoRedirect()` runs on `template_redirect`, parses the
queried singular post's content for the block (including inside nested
blocks), and redirects when the block opts in. A block whose target is its
own page is ignored to avoid a redirect loop.
The registration block's auto-redirect additionally only fires on a
**finished** registration — `RegistrationPage::isRegistrationComplete()`: an
invited student who is now logged in (`?us_registered=invite`), or a
self-signup back from the emailed confirmation link (`?us_confirmed=ready|1`).
The intermediate "check your email" step and every failure (a validation
error, `?us_confirmed=expired`) stay on the page so the student reads the
message. That check runs before the content is parsed, so an ordinary page
view does not pay for the extra block scan.
## How it works
- **`BlockRegistrar`** (`src/BlockRegistrar.php`) hooks `init` and registers
each block with `register_block_type()`: a `render_callback` per block, the
shared editor script (`assets/js/blocks.js`, handle
`us-scheduler-blocks`), and the front-end stylesheet
(`assets/css/frontend.css`, handle `us-scheduler`) as the block `style` so
it also loads inside the editor and previews pick up theme styling.
- **`assets/js/blocks.js`** (vanilla JS, no build step) registers the client
side of each block — title, icon, keywords, shortcode transform — and
renders the editor preview with `wp.serverSideRender`, which fetches the
server-rendered markup via the `/wp/v2/block-renderer` REST route.
- **`BlockPreview`** (`src/BlockPreview.php`) supplies static, script-free
markup for editor previews. `BlockRegistrar::isEditorPreview()` detects the
block-renderer context via the `REST_REQUEST` constant (front-end template
rendering never happens inside a REST request) and renders the preview
instead of the live page.
## Editor preview behaviour
Live pages cannot run in the editor: booking and group classes are populated
by JavaScript making authenticated REST calls (and may load Stripe.js),
registration requires a valid invite token, and login short-circuits for
logged-in users (the editing admin always is). Each preview therefore
reproduces the live wrapper elements and CSS classes with representative
placeholder content:
- **Booking** — `#us-booking-app` with sample `.us-day` / `.us-slot` rows and
disabled Book buttons.
- **Group classes** — `#us-group-app` with a sample `.us-class` card and a
disabled Enrol button. When `offeringId` pins a single class the preview
drops the sample description, matching what the live page renders in that
mode.
- **Login** — the real `templates/frontend/login-page.php` template (it has
no request-state dependencies).
- **Registration** — a disabled sample of the `.us-register-form` fields.
Each preview starts with a `.us-editor-note` paragraph explaining what the
published page shows instead. The note class only appears in editor previews.
## Tests
- `tests/Unit/BlockRegistrarTest.php` — hook registration, block/asset
registration, attribute schemas, front-end delegation to the page objects,
preview-mode routing, auto-redirect behaviour.
- `tests/Unit/Booking/BookingPageTest.php` — logged-out login-link targets
and fallbacks.
- `tests/Unit/Auth/LoginPageTest.php` — logged-in booking-link targets and
fallbacks.
- `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS
classes/ids and includes the editor note.
+145 -11
View File
@@ -3,6 +3,8 @@
## Overview
Students enrol in a group class — an offering of kind `group_class` — as a commitment for the year. Enrolment is capacity-enforced and billed full-term upfront. Registration reuses the same flow as private lessons (intake questions + policy acceptance + payment).
A group class can be marked **invite-only** (`us_offerings.access_mode = invite_only`, see `offerings.md`). Invite-only classes are hidden from the public catalog — they never appear in the student booking/group-class list — and can only be enrolled in by students the instructor has let in. See **Invite-only access** below.
## Data Model — `{prefix}us_group_enrollments`
| Column | Type | Notes |
@@ -15,7 +17,27 @@ Students enrol in a group class — an offering of kind `group_class` — as a c
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
| `enrolled_at` | DATETIME | Insertion time |
## Class Dates, Time, and Instructor
A group class offering carries `term_start`/`term_end` plus a `class_time` and an
owning `instructor_id` (see `offerings.md`): one-off classes end the day they
start; weekly classes run a set number of sessions, all at `class_time`. The class
card on the enrolment page shows **when** the class meets (the date or date range
plus the start time) and **who** teaches it (the assigned instructor's name,
surfaced as `instructor_name` on the `GET /offerings` response). Instructor names
in the group-class views (front and back end) use the instructor's real name
(first + last) or nickname, never their login — see `Auth\UserName::format()`.
Assigning an instructor to a scheduled class removes that instructor's open
booking slots at the class time and flags any already-booked lesson that clashes;
see **Instructor assignment** in `offerings.md`.
## 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).
1. Student opens a group class from the offering catalog.
2. Student answers the offering's questions (`GET /offerings/{id}/questions`).
3. Student accepts the current published policy versions (`GET /policies`) — required to continue.
@@ -26,36 +48,148 @@ Students enrol in a group class — an offering of kind `group_class` — as a c
Capacity is enforced at enrolment time by counting `active` rows for the offering;
a class at capacity rejects further enrolments.
Enrolment also closes after the class's **enrolment deadline** (the instructor's
`enrollment_deadline`, defaulting to `term_start` — the first class day; see
`offerings.md`). Past the deadline `POST /enrollments` rejects the enrolment with
`403 enrollment_closed`, and the class list shows "Enrolment has closed." in place
of the Enrol button. While enrolment is still open the class card shows an
"Enrol by" line with the effective deadline date.
The deadline only bounds student **self**-enrolment. An instructor (or studio admin)
can still enrol someone by hand from the class **details page** — the **Add students
directly** control, available for every group class, deliberately bypasses the
deadline (and capacity) so a **late enrolment** can be added after the class has
closed. Past the deadline the details page labels these as late enrolments. See
**Admin Interface** below.
## 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.
`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
for cancelled lessons; see `credits.md`).
Self-withdrawal is bounded by the class's **withdrawal deadline** (the instructor's
`withdrawal_deadline`; see `offerings.md`). Unlike the enrolment deadline it has no
implicit default — a class with no deadline set stays open to withdrawal for its
whole life. Past the deadline `POST /enrollments/{id}/withdraw` rejects the request
with `403 withdrawal_closed`, and the class card shows "Withdrawal has closed —
contact the studio to withdraw." in place of the Withdraw button. The endpoint also
returns `404 not_found` for an unknown enrolment and `403 forbidden` when the
enrolment is not the caller's own; a withdrawal of an already-cancelled enrolment is
idempotent.
The deadline only bounds student **self**-withdrawal. A studio admin can withdraw a
student at any time from the **student detail page** (`Auth\StudentActions::withdrawEnrollment`),
which is never subject to the deadline.
## REST API
| Method | Endpoint | Permission |
|----------|----------------------------------------------|----------------------------------|
|----------|-------------------------------------------------|----------------------------------|
| `GET` | `/wp-json/us-scheduler/v1/enrollments` | Any logged-in user |
| `POST` | `/wp-json/us-scheduler/v1/enrollments` | `book_lesson` |
| `POST` | `/wp-json/us-scheduler/v1/enrollments/{id}/withdraw` | Owner (the enrolled student) |
`POST /enrollments` body: `offering_id`, `answers[]` (`question_id` → value),
`accepted_policy_version_ids[]`, and payment data (see `payments.md`).
`accepted_policy_version_ids[]`, and payment data (see `payments.md`). The
response includes `id`, `status`, and `payment` — a `{id, method, status}`
summary, or `null` when the class is free (the front end then skips the
payment step).
`GET /enrollments` returns the caller's own enrolments, or all enrolments for the
instructor's group classes if the caller has `view_own_lessons` on those offerings.
`GET /offerings` (the catalog that feeds the group-class list) returns public
offerings **plus** any invite-only offerings the caller has an access grant for, so a
granted student sees the private class alongside public ones. Ungranted students never
receive it. Enrolling in an invite-only class requires a grant: `POST /enrollments`
rejects an ungranted student with `403 invite_required`, and a successful enrolment
flips their grant from `invited` to `enrolled`.
## Invite-only access
Access to an invite-only class is recorded in `{prefix}us_group_access` — a grant per
person, separate from the enrolment itself. The instructor manages access from
**My Lessons → My Group Classes**. **Add students directly** is available on every
class's details page (see **Admin Interface**); invite-only classes add two more
controls beneath it:
1. **Add students directly** — the selected registered students are enrolled immediately
(`status = active`) with a **pending payment** at the class price (comp students are
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.
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.
3. **Invite by email** — for an address with no account yet: a tokenised personal invite
(`us_invites`, carrying `offering_id`) is created and the registration link emailed,
alongside an `invited` grant keyed by `email` + `invite_id`. If the address already has
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.
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.
### Data Model — `{prefix}us_group_access`
| Column | Type | Notes |
|---------------|-----------------|-----------------------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` (an invite-only group class) |
| `student_id` | BIGINT UNSIGNED | WordPress user ID; NULL until an email invitee registers |
| `email` | VARCHAR(191) | Email-invite grants only; used to link the account once it registers |
| `invite_id` | BIGINT UNSIGNED | FK → `us_invites.id` for email-invite grants; NULL otherwise |
| `status` | VARCHAR(20) | `invited` / `enrolled` / `revoked` |
| `invited_by` | BIGINT UNSIGNED | Instructor who granted access |
| `created_at` | DATETIME | Insertion time |
## Admin Interface
- **Group Classes** (`manage_options` / studio admin): all enrolments across instructors
- Instructors see enrolments for their own group classes under **My Lessons**
- **Group Classes** (`view_all_lessons` / studio admin): a per-class summary across
instructors — each class with its instructor, when it meets, and its active-enrolment
count against capacity (not a flat list of individual student enrolments). Selecting a
class (`?class_id=<id>`) opens the same per-class **details page** described below, so a
studio admin — including an owner-operator who also teaches, for whom the instructor
**My Group Classes** menu is hidden — can view any class's roster and manage invite-only
membership from here. Invite actions are permitted for the class's own instructor or any
`view_all_lessons` studio admin.
- **My Lessons → My Group Classes** (`view_own_lessons` / instructor): a summary of the
instructor's own group classes — each with when it meets and its active-enrolment count
against capacity, plus a **View details** link (**View & invite** for invite-only
classes). Selecting a class (`?class_id=<id>`, scoped to the owning instructor) opens its
**details page**: a class-details panel (when, instructor, enrolled/capacity, duration,
price, schedule note, enrolment deadline, status), the roster of enrolled students with
enrolment and payment status, and an **Add students** section. Every class — public or
invite-only — carries the **Add students directly** control there, which enrols the
selected students immediately (a late enrolment past the deadline; the section says so
when the deadline has passed). Invite-only classes additionally get the
**make-available** and **invite-by-email** controls plus the list of who has been invited
but not yet enrolled. These are nonce-checked `usc_action` POSTs, scoped to the owning
instructor. The summary (`templates/admin/my-group-classes.php`) and the details page
(`templates/admin/my-group-class-detail.php`) are separate templates.
## 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`
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_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`)
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode)
- 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`)
> **Payment seam:** payment is deferred to #7. An enrolment is created with
> `status = active` and `payment_id = null`; the pay→confirm + receipt step plugs
> in later. Instructor-specific enrolment views (the spec's "under My Lessons")
> are a follow-up — this iteration ships the studio-admin **Group Classes** page
> (`view_all_lessons`) plus per-student/per-instructor REST queries.
> **Payment:** a priced enrolment creates a payment via `Payment\PaymentService`
> (`registration_type = enrollment`) and links it as `payment_id`; unpriced
> enrolments return `payment: null` and skip the payment step. See `payments.md`
> for the card/e-transfer/comp flows.
## Tests
- `tests/Unit/GroupClass/GroupClassControllerTest.php` (roster + add/make-available/invite actions)
- `tests/Unit/GroupClass/EnrollmentTest.php`
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
- `tests/Unit/GroupClass/EnrollmentEndpointTest.php` (invite-only gating)
- `tests/Unit/GroupClass/GroupAccessTest.php`
- `tests/Unit/GroupClass/GroupAccessRepositoryTest.php`
- `tests/Unit/GroupClass/GroupClassPageTest.php`
- `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes)
+104 -14
View File
@@ -20,44 +20,129 @@ Students register for a private lesson by choosing an offering, picking a time (
| `created_at` | DATETIME | Insertion time |
## Registration Flow
1. Student opens the page with the `[us_booking]` shortcode and browses the calendar.
2. Student picks an **offering** (a 30 or 60-minute private-lesson type) and a slot.
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form). A **Show Only** button beside the view toggle opens a lesson-type filter that narrows the open times to those bookable as the chosen types (see **Lesson-Type Filter**).
2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot, narrowed to the filtered types. When exactly one type remains it is pre-selected (its intake questions load immediately). Every booking requires an offering — a generic slot with no fitting offering cannot be booked online.
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
6. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment.
7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
## Lesson-Type Filter
Not every open slot can be booked as every private-lesson type — a slot tied to
an offering takes that offering only, and a generic slot only takes types whose
length fits. The booking calendar therefore carries a lesson-type filter,
collapsed behind a **Show Only** button that sits in the calendar's control row
beside the List/Week toggle. Opening it reveals the type list between that row
and the calendar: a checkbox per active private-lesson type (from
`GET /offerings?kind=private_lesson`, fetched once per page load), showing the
instructor's name alongside the title when the catalog spans more than one
instructor. The button carries the number of ticked types and stays highlighted
while the filter is on, so a collapsed filter is never invisible. Both button and
list are hidden when there is only one bookable type.
Ticking one or more types narrows the calendar to the slots bookable as one of
them; no ticks means no filter, and collapsing the list leaves the filter
applied. Picking a filtered slot narrows the registration form's **Lesson type**
picker the same way, and when exactly one type remains it is pre-selected and its
intake questions load immediately. Changing the filter re-anchors the week view
on the earliest matching slot, so the student never lands on an empty week.
**Show all types** clears the filter.
Bookability is decided client-side by `offeringFitsSlot()` in
`assets/js/booking.js` — the mirror of the rule `POST /bookings` enforces (same
instructor, the tied offering when there is one, otherwise a matching
`duration_minutes`). The filter is a browsing aid only: the server re-checks
every booking regardless.
Two block/shortcode options change what the filter has to work with (see
`editor-blocks.md`), passed to the script as data attributes on
`#us-booking-app`:
- **A pinned lesson type** (`data-lesson-type`) narrows the catalog to that one
offering, so the page lists only the times bookable as it and books nothing
else — the filter control hides itself, there being one type left. A pinned
type that is no longer offered shows "This lesson type is not available for
booking right now" rather than an empty calendar.
- **Filter off** (`data-type-filter="0"`) drops the **Show Only** button
entirely; every open time is listed, as before the filter existed.
## Embedding Halves of the Page
The page has two halves — the booking calendar and the student's upcoming
lessons — and the block/shortcode can embed either on its own (`displayMode` /
`show`: `both` (default), `booking`, `upcoming`). The template simply omits the
containers of the half that is not wanted, and the script skips the work that
belongs to a missing container: an upcoming-only embed never requests
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.
## Cancellation
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
Cancelling marks the lesson `cancelled`, frees the availability slot for
rebooking, and voids a still-pending payment (marked `failed` so it leaves the
admin confirmation queue). A `paid` payment is never touched — refunds are a
manual, admin-side decision. Instructors cancelling via
`PATCH /bookings/{id}/status` get the same slot release and payment voiding;
reinstating a cancelled lesson re-claims its slot and fails with `409
slot_taken` if the freed time was booked by someone else in the meantime.
## Weekly Reservations
A weekly reservation creates one `series_id` shared across N lesson rows (one per
week in the term) and reserves the matching availability windows. It is billed
**full-term upfront** as a single payment (`billing_mode = full_term` on the
offering).
**upfront as a single payment** linked to the series' first (anchor) lesson:
- `billing_mode = full_term` — the offering's price already covers the term and is charged once.
- `billing_mode = one_time` — the per-lesson price is charged **once per occurrence actually claimed** (price × N).
Settling that payment (Stripe webhook, e-transfer confirmation, comp) confirms
**every non-cancelled lesson in the series**
(`BookingRepository::updateStatusForSeries()`), not just the anchor row.
## REST API
| Method | Endpoint | Permission |
|-----------|-------------------------------------------------|--------------------------------|
| `GET` | `/wp-json/us-scheduler/v1/bookings` | Any logged-in user |
| `POST` | `/wp-json/us-scheduler/v1/bookings` | `book_lesson` |
| `POST` | `/wp-json/us-scheduler/v1/bookings/{id}/cancel` | Logged-in owner of the lesson |
| `PATCH` | `/wp-json/us-scheduler/v1/bookings/{id}/status` | `manage_availability` or admin |
`POST /bookings` body: `offering_id`, `slot_id`, `recurrence`, `answers[]`
(`question_id` → value), `accepted_policy_version_ids[]`, and payment data
(see `payments.md`).
(see `payments.md`). The response includes `ids`, the resulting lesson
`status`, and `payment` — a `{id, method, status}` summary, or `null` when
nothing is owed (the front end then skips the payment step).
`GET /bookings` returns the caller's own lessons (student view) or upcoming lessons for the instructor if the caller has `manage_availability`.
An offering is always required (`400 offering_required` otherwise): a slot tied
to an offering uses that offering regardless of the request, while a generic
slot uses the student's `offering_id`, which must be one of the instructor's
active `private_lesson` offerings whose `duration_minutes` matches the slot.
`GET /bookings` returns the caller's upcoming, non-cancelled lessons (their own
for students; the instructor's for callers with `manage_availability`), each
with the slot's `start_dt`/`end_dt`.
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 for the logged-in instructor
- **My Lessons** (`view_own_lessons`): upcoming lessons 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.
Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week`
query params, same pattern as the availability page, bucketed via
`Availability\WeekCalendar`), with the original table available as the **List**
view — the list is where the per-lesson HST and e-transfer edit forms live. Both
views show the booked offering's name, and each lesson links through (`?lesson_id=`)
to a **detail view** (`LessonController::maybeRenderDetail()`) that shows the
offering, time, status, notes, the policy versions the student accepted (with
acceptance time and IP), and their intake-question answers. On **My Lessons** an
instructor may only open their own lessons; the studio **Scheduler** may open any.
## Frontend Shortcodes
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability. Attributes: `login_page_id`, `lesson_type` (pin one private-lesson offering), `show_filter` (`no` hides the **Show Only** filter), `show` (`both` / `booking` / `upcoming`)
- `[us_student_login]` — front-end login form for students
## Implementation
@@ -65,15 +150,20 @@ kind `group_class`; see `group-classes.md`.
- 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`
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
> **Payment seam:** payment is deferred to the Payments feature (#7). For now a
> booking is created with `status = pending` and `payment_id = null`; the
> instructor confirms via `PATCH /bookings/{id}/status`. When payments land, the
> pay→confirm + receipt step plugs into this seam. `GET /policies?scope=booking`
> returns just the booking-gate policies the form must collect.
> **Payment seam:** a priced booking is created with `status = pending` and its
> payment linked via `payment_id`; the lesson is confirmed when the payment is
> settled (see `payments.md`) or manually via `PATCH /bookings/{id}/status`.
> Unpriced bookings skip the seam entirely and are confirmed at creation.
> `GET /policies?scope=booking` returns just the booking-gate policies the form
> must collect.
## Tests
- `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`
+78 -4
View File
@@ -15,29 +15,99 @@ An offering is anything a student can register for: a private-lesson type (30 or
| `duration_minutes` | SMALLINT | Private lessons only (e.g. 30, 60); NULL for group classes |
| `price` | DECIMAL(10,2) | Price in dollars |
| `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` |
| `billing_mode` | VARCHAR(20) | `one_time` (single booking) or `full_term` (weekly / group) |
| `billing_mode` | VARCHAR(20) | `one_time`, `full_term`, `weekly`, or `monthly` (see Billing Mode below) |
| `allow_weekly` | TINYINT(1) | Private only — may be reserved weekly for the term |
| `capacity` | SMALLINT | Group only — max enrolments; NULL for private |
| `term_start` | DATE | Group / term offerings — first day; NULL otherwise |
| `term_end` | DATE | Group / term offerings — last day; NULL otherwise |
| `class_time` | TIME | Group only — time of day each session starts; NULL otherwise |
| `enrollment_deadline` | DATE | Group only — last day students may enrol; NULL defaults to `term_start` (the first class day) |
| `withdrawal_deadline` | DATE | Group only — last day a student may withdraw themselves; NULL keeps self-withdrawal open indefinitely |
| `schedule_note` | VARCHAR(191) | Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"|
| `cancellation_cutoff_hours` | SMALLINT UNSIGNED | Optional per-offering cancellation cutoff in hours; NULL inherits the studio default (see `cancellation-cutoff.md`) |
| `access_mode` | VARCHAR(20) | `public` (listed in the catalog) or `invite_only` (group classes hidden from the catalog — see `group-classes.md`) |
| `is_active` | TINYINT(1) | 0 = hidden from registration, 1 = bookable |
| `created_at` | DATETIME | Insertion time |
## Billing Mode
- `one_time` — charged once at booking (a single private lesson).
- `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`.
- `weekly`**not** charged at registration; a pending payment for one lesson's fee is generated **24 hours before each lesson** by the daily billing scan.
- `monthly`**not** charged at registration; on the **1st of each month** a single pending payment is generated for every lesson that falls in that month (4 lessons ⇒ 4 × fee).
`weekly` and `monthly` are *scheduled* billing (`Offering::isScheduledBilling()`): the
booking/enrolment succeeds with no payment step, and payments are created later by the
daily `us_generate_due_payments` cron scan. See `scheduled-billing.md` and `payments.md`.
## Term Dates
Group classes carry a term: `term_start` is the date of the first class and
`term_end` the last. The add-offering form takes a start date plus a sessions
control — **one-off** (the term ends the day it starts) or **weekly for N
sessions** (`term_end = term_start + (N1) weeks`, computed by
`Offering::weeklyTermEnd()`). Dates are validated by `Offering::normalizeDate()`
(strict `Y-m-d`); an invalid start date leaves both term columns NULL. The
student-facing class card shows the date (one-off) or the date range with the
weekly session count.
## Class Time and Sessions
A group class also carries `class_time` — the time of day each session starts —
validated by `Offering::normalizeTime()` (strict `H:i`/`H:i:s`; garbage leaves it
NULL). `class_time` + `term_start`/`term_end` + `duration_minutes` together define
the concrete session windows: `Offering::sessionWindows()` returns one
`{start, end}` per session (weekly across the term, or a single window for a
one-off), and returns an empty list unless date, time, and a positive duration are
all set. These windows drive availability reconciliation (see **Instructor
assignment** below and `group-classes.md`).
## Enrolment deadline
A group class carries an optional `enrollment_deadline` the instructor sets on the
offering form (blank leaves it NULL). `Offering::effectiveEnrollmentDeadline()`
resolves it to the stored date, or to `term_start` (the first class day) when unset,
so a class with no explicit deadline still closes to new enrolments once the first
class arrives. `Offering::isEnrollmentOpen($today)` compares a `Y-m-d` "today"
against that effective deadline (inclusive — the deadline day is still open). The
enrolment endpoint enforces it (`403 enrollment_closed`) and the front-end
group-class list mirrors the same rule; see `group-classes.md`.
## Withdrawal deadline
A group class also carries an optional `withdrawal_deadline` — the last day a
student may withdraw *themselves* from the class. Unlike the enrolment deadline it
has **no implicit default**: `Offering::isWithdrawalOpen($today)` treats an unset
(NULL) deadline as always open, so a class only closes to self-withdrawal once the
instructor sets a date and it passes (comparison is inclusive — the deadline day is
still open). A withdrawal made while open frees the seat and voids any still-pending
payment but **never issues an account credit** (credits are reserved for cancelled
lessons; see `credits.md`). Once the deadline passes the student must contact the
studio, and an admin withdraws them by hand from the student detail page — the admin
path is never subject to the deadline. The student endpoint enforces it
(`403 withdrawal_closed`) and the front-end group-class list mirrors the rule; see
`group-classes.md`.
## Instructor assignment
Every offering has an owning `instructor_id`. A studio admin
(`manage_instructors`) sees an **Instructor** picker on the offering form and may
assign a group class to any instructor; a plain instructor never sees the picker
and always owns the classes they create (the posted value is ignored for them, and
updates never reassign the owner otherwise). When a group class is saved with an
assigned instructor and a full schedule, `Offering\ClassSlotReconciler` clears that
instructor's **open** availability slots overlapping each session so students can't
book them, and reports any **already-booked** lesson that clashes as a conflict for
the studio to resolve by hand (a booked lesson is never deleted). The result is
surfaced as an admin notice after saving.
## Admin Interface
Studio admin and instructors manage offerings under **Offerings** in wp-admin.
- Studio admin (`manage_offerings`) manages offerings for any instructor.
- Instructor (`manage_offerings`) manages only their own.
- Each offering's intake questions are edited from the offering screen (see `registration-questions.md`).
- The offerings list shows each offering's ID (needed for `[us_group_classes offering="…"]`) and its term dates.
- **Edit** on a row reloads the page (`?usc_edit=<id>`) with the form prefilled; saving posts `usc_action=update`. Owner and currency are always preserved on update, so a form submission can never reassign an offering. Non-admin instructors can only load and update their own offerings.
- The form includes a **description** textarea and an **Active — open for registration** checkbox (unchecking hides the offering from students without deleting it — the admin-UI counterpart of the REST `is_active` flag).
## REST API
| Method | Endpoint | Permission |
|----------|---------------------------------------------|----------------------------------|
| `GET` | `/wp-json/us-scheduler/v1/offerings` | Public (active offerings only) |
| `GET` | `/wp-json/us-scheduler/v1/offerings` | `book_lesson` or `manage_offerings` (active offerings only) |
| `POST` | `/wp-json/us-scheduler/v1/offerings` | `manage_offerings` |
| `PATCH` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner |
| `DELETE` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner |
@@ -46,10 +116,14 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
## Implementation
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository`
- Model: `Unsupervised\Schedular\Offering\Offering`
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`, `effectiveEnrollmentDeadline`, `isEnrollmentOpen`)
- Admin controller: `Unsupervised\Schedular\Offering\OfferingController`
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint`
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` (public listing includes `instructor_name`)
- Availability reconciliation: `Unsupervised\Schedular\Offering\ClassSlotReconciler` (uses `Availability\AvailabilityRepository::findOverlapping`)
## Tests
- `tests/Unit/Offering/OfferingControllerTest.php`
- `tests/Unit/Offering/OfferingRepositoryTest.php`
- `tests/Unit/Offering/OfferingTest.php`
- `tests/Unit/Offering/OfferingEndpointTest.php`
- `tests/Unit/Offering/ClassSlotReconcilerTest.php`
+5
View File
@@ -54,6 +54,11 @@ and `instructor_id` query params as the page and returns `text/csv` with a
`Content-Disposition: attachment` header. Instructor requests are scoped to
their own rows regardless of `instructor_id`.
Fields that a spreadsheet would interpret as a formula (leading `=`, `+`, `-`,
`@`, tab, or CR — e.g. a hostile student display name) are prefixed with an
apostrophe so the export can never carry CSV formula injection into Excel or
Google Sheets.
## Implementation
- Report aggregator (pure totals + CSV): `Unsupervised\Schedular\Payment\PaymentReport`
+25 -2
View File
@@ -6,7 +6,9 @@ falls back to **e-transfer** — a pending payment a studio admin marks received
so everything works without any credentials. When Stripe **is** configured the
default rail becomes the **credit card**. The studio admin can override any
student's method (card / e-transfer / comp). Single bookings are charged once;
weekly reservations and group classes are charged the full term upfront. A
weekly reservations and group classes are charged the full term upfront (a
`full_term` price once, or a per-lesson `one_time` price × the occurrences
reserved — see `lesson-booking.md`). A
numbered receipt is emailed automatically when a payment is marked paid.
> **Implemented:** the payment ledger, studio settings, method resolution
@@ -86,6 +88,9 @@ After booking, the destination on a payment can be corrected per booking:
| `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` |
| `tax_rate` | DECIMAL(5,2) | HST rate % frozen at booking; editable until paid |
| `tax_amount` | DECIMAL(10,2) | Computed tax in dollars (`amount × tax_rate / 100`) |
| `due_date` | DATE | When a *scheduled* payment is due; NULL = due at registration (`Payment::isScheduled()`) |
| `period_key` | VARCHAR(20) | Scheduled-billing dedup key: session date (weekly) or `YYYY-MM` (monthly); NULL otherwise |
| `notice_batch` | VARCHAR(32) | Shared reference for the payments one due-notice email covers, so a lump-sum e-transfer reconciles to them; NULL otherwise |
| `etransfer_email` | VARCHAR(191) | Frozen e-transfer destination; editable until confirmed |
| `stripe_payment_intent_id` | VARCHAR(255) | Stripe PaymentIntent id; NULL for e-transfer / comp |
| `receipt_number` | VARCHAR(50) | Sequential receipt id; set when `paid` |
@@ -94,12 +99,30 @@ After booking, the destination on a payment can be corrected per booking:
| `paid_at` | DATETIME | When marked `paid`; NULL otherwise |
## Payment Flow
1. During registration the front-end calls `POST /payments/intent`, which creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.)
1. During registration the front-end calls `POST /payments/intent` — but only when the registration response carried a `payment` summary (unpriced registrations return `payment: null` and skip the payment step). The intent call creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.)
2. The browser confirms the card payment with Stripe.
3. Stripe calls `POST /payments/webhook`; on `payment_intent.succeeded` the payment is marked `paid`, `paid_at` is stamped, and the linked lesson/enrolment is `confirmed`.
4. On transition to `paid`, `ReceiptMailer` assigns a `receipt_number`, emails the student a receipt, and stamps `receipt_sent_at`.
5. For an e-transfer, the studio admin later calls `PATCH /payments/{id}` to mark it `paid`, which triggers the same confirmation + receipt.
## Scheduled Billing (weekly / monthly)
`weekly` and `monthly` offerings are **not** charged at registration. The booking /
enrolment succeeds with `payment: null`; the lesson is confirmed (or the enrolment stays
active) immediately, and payments are generated later by the daily
`us_generate_due_payments` cron scan (`Payment\ScheduledBillingRunner`). Each generated
payment carries a `due_date` and `period_key`, flows through the same
`PaymentService::createForRegistration` (so HST, method resolution, e-transfer freezing
and comp auto-pay are identical), and the student is emailed one consolidated itemised
notice per scan (`Payment\PaymentDueMailer`). Because these payments are scheduled,
`PaymentService::voidPending` never voids them — cancelling one lesson leaves a shared
monthly charge (and every other lesson it covers) untouched, and never rebills. Full
model, dedup, and the four generation cases are documented in `scheduled-billing.md`.
Cancelling a lesson that was **already paid** issues the student an account credit for
that lesson's share of what they paid; the next daily scan applies any available credit
against their due charges (reducing `us_payments.credit_applied``Payment::netDue()`)
before emailing the notice. See `credits.md`.
## REST API
| Method | Endpoint | Permission |
|---------|---------------------------------------------|-----------------------------|
+80
View File
@@ -0,0 +1,80 @@
# Feature: Plugin Self-Update from Gitea Releases
## Overview
WordPress sites running this plugin receive updates directly from the Gitea
repository's releases — no wordpress.org listing and no manual zip uploads.
Publishing a release is the whole deploy: bump the version, merge to `main`,
tag `vX.Y.Z` in Gitea. Every site sees the update on its next check and can
install it with one click, or unattended if the site admin enables
auto-updates for the plugin.
## How It Works
### Release side (`.gitea/workflows/release.yml`)
Pushing a `v*` tag (including tags created through Gitea's "New Release" UI)
triggers the release workflow, which:
1. Fails if the tag does not match the `Version:` plugin header — a mismatch
would make sites see a phantom update forever, or never see a real one.
2. Runs the test suite.
3. Builds the distributable zip via `composer build` (`bin/build-zip.sh`):
a single top-level `unsupervised-schedular/` folder with a production
(no-dev) Composer autoloader.
4. Creates the release for the tag (or reuses one created via the UI) and
attaches the zip as a release asset. Versions containing a hyphen
(e.g. `1.2.3-rc.1`) are flagged as pre-releases.
The attached asset — not Gitea's auto-generated source archive — is the
update package. Source archives have the wrong top-level folder name and no
`vendor/` directory, so WordPress could not install them.
### Site side (`src/Update/UpdateChecker.php`)
The plugin header declares:
```
Update URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
```
Since WP 5.8 that header both blocks wordpress.org from ever serving an
update for a same-slug plugin and makes core fire the
`update_plugins_git.unsupervised.ca` filter during update checks.
`UpdateChecker` (registered in `Plugin::boot()`) answers that filter:
1. Fetches `GET /api/v1/repos/Unsupervised/unsupervised-scheduler/releases/latest`
(anonymous — the repo is public). The `/latest` endpoint excludes drafts
and pre-releases, so `-rc` builds are never offered to sites.
2. Caches the result (including failures) in the
`us_schedular_latest_release` transient for 6 hours.
3. Strips the leading `v` from the tag and compares against `USC_VERSION`
with `version_compare`; PHP orders `1.0.0-rc.2 < 1.0.0` correctly.
4. When newer, returns the release's first `.zip` asset as the update
package. Core takes over from there: Plugins-screen notice, one-click
update, and WP-Cron auto-updates if enabled.
5. When not newer — the site is current, or the lookup failed — returns a
`no_update` payload (installed version, empty package). This keeps the
plugin in core's `update_plugins` transient so core's `update-supported`
flag stays set and the **Enable auto-updates** toggle shows on the
Plugins screen. Without it, an off-directory plugin is absent from the
transient between releases and the toggle never appears.
Any API failure, malformed response, or asset-less release degrades to
"no update available" (the `no_update` payload) — never an error surfaced
to the site, and never a lost auto-update toggle during a Gitea blip.
## Cutting a Release
1. Bump the version in `unsupervised-schedular.php` (both the `Version:`
header and the `USC_VERSION` constant) and merge to `main`.
2. Tag the merge commit `vX.Y.Z` — via Gitea's New Release UI or
`git tag vX.Y.Z && git push origin vX.Y.Z`.
3. The release workflow attaches the zip; sites pick the update up on their
next check (twice daily via cron, or immediately from
Dashboard → Updates → Check again).
## Classes
| Class | Responsibility |
|---|---|
| `Update\UpdateChecker` | Answers core's `update_plugins_{hostname}` filter from the Gitea releases API |
## Tests
- `tests/Unit/Update/UpdateCheckerTest.php`
+43 -15
View File
@@ -1,19 +1,30 @@
# Feature: Registration Questions
## Overview
Each offering can carry a set of intake questions the registrant must answer when booking. Questions are authored per offering by the studio admin or the owning instructor, and answers are stored against the resulting lesson or group enrolment.
Questions come in two **scopes**:
- **Offering scope** (`scope = 'offering'`) — intake questions a registrant answers when
booking a specific offering; authored per offering by the studio admin or the owning
instructor, and stored against the resulting lesson or group enrolment.
- **Account scope** (`scope = 'account'`) — studio-wide questions every new student answers
**once at account signup**, as a required second step after choosing their name and
password. Authored by the studio admin only, and stored against the new user account.
Both scopes share the `us_questions` / `us_question_answers` tables, the same field types,
and the same authoring page (**Offerings → Questions**).
## Data Model — `{prefix}us_questions`
| Column | Type | Notes |
|---------------|------------------|-------------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` — questions are scoped per offering |
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` for offering-scoped questions; NULL for account-scoped |
| `scope` | VARCHAR(20) | `offering` (default) or `account` |
| `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 |
| `sort_order` | INT | Display order within the offering |
| `sort_order` | INT | Display order within the scope |
| `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form |
| `created_at` | DATETIME | Insertion time |
@@ -23,27 +34,37 @@ Each offering can carry a set of intake questions the registrant must answer whe
|---------------------|------------------|--------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `question_id` | BIGINT UNSIGNED | FK → `us_questions.id` |
| `registration_type` | VARCHAR(20) | `lesson` or `enrollment` |
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.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) |
| `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 |
The `registration_type` + `registration_id` pair is a polymorphic reference shared
with `us_policy_acceptances` (see `policies.md`), letting answers attach to either a
private lesson or a group enrolment.
with `us_policy_acceptances` (see `policies.md`), letting answers attach to a private
lesson, a group enrolment, or an account signup (`account` + the user ID).
## Flow
1. On the registration form, the front-end calls `GET /offerings/{id}/questions`.
## Offering-scope Flow
1. On the booking form, the front-end calls `GET /offerings/{id}/questions`.
2. Required questions block submission until answered.
3. Answers are sent in the `answers[]` array on `POST /bookings` or `POST /enrollments` and written to `us_question_answers` alongside the new registration row.
## Account-scope Flow (signup step two)
1. The `[us_student_register]` page (`Auth\RegistrationPage`) loads active account-scope questions via `QuestionRepository::findByScope('account')`.
2. The form renders as two steps: step one is email/name/password/policies, step two is the questions. `assets/js/register.js` reveals step two behind a "Next" button (progressive enhancement — without JS both steps show 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>`.
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
Questions are edited from each offering's screen (**Offerings → Questions**).
- Studio admin (`manage_questions`) edits questions on any offering.
- Instructor (`manage_questions`) edits questions only on their own offerings.
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.
- 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`.
| Method | Endpoint | Permission |
|----------|---------------------------------------------------|----------------------|
| `GET` | `/wp-json/us-scheduler/v1/offerings/{id}/questions`| Public |
@@ -52,12 +73,19 @@ Questions are edited from each offering's screen (**Offerings → Questions**).
| `DELETE` | `/wp-json/us-scheduler/v1/questions/{id}` | `manage_questions` + owner |
## Implementation
- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository`, `Unsupervised\Schedular\Registration\AnswerRepository`
- Models: `Unsupervised\Schedular\Registration\Question`, `Unsupervised\Schedular\Registration\Answer`
- 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`)
- Admin controller: `Unsupervised\Schedular\Registration\QuestionController`
- REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint`
- REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint` (offering scope only)
- Signup step two: `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)
- 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
- `tests/Unit/Registration/QuestionRepositoryTest.php`
- `tests/Unit/Registration/AnswerRepositoryTest.php`
- `tests/Unit/Registration/QuestionTest.php`
- `tests/Unit/Registration/AnswerTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php`
- `tests/Unit/Auth/StudentHistoryTest.php`
+94
View File
@@ -0,0 +1,94 @@
# Feature: Scheduled Billing (weekly / monthly)
## Overview
Two offering billing modes defer payment past registration and generate pending
payments on a recurring schedule:
- **`weekly`** — one payment per lesson, due **24 hours before** that lesson.
- **`monthly`** — one payment per calendar month, due on the **1st**, covering every
lesson that falls in the month (4 lessons ⇒ 4 × fee).
Both apply to **private lessons** and **group classes**. At registration the
booking/enrolment succeeds with `payment: null` (no payment step); the lesson is
confirmed / the enrolment stays active immediately. Payments are created later by a daily
WP-Cron scan, and the student is emailed one consolidated notice per scan. Collection
uses the existing rails (e-transfer confirmed by the studio admin, or card) — there is no
automatic card charging.
## The daily scan — `Payment\ScheduledBillingRunner`
Hooked to the WP-Cron action **`us_generate_due_payments`** (scheduled `daily` by
`Installer`, cleared on plugin deactivation). `run()` is self-healing: it re-derives
everything due from current ledger state each run, so a missed day is simply picked up
next time. Every payment is created through `PaymentService::createForRegistration` (HST,
method resolution, e-transfer freezing, comp auto-pay reused) with a `due_date` and
`period_key` set.
### The four generation cases
| Source | When it bills | Amount | Dedup |
|--------|---------------|--------|-------|
| **Private weekly** | lesson `start_dt` ≤ now + 24h | 1 × fee | `us_lessons.payment_id` set on the lesson |
| **Private monthly** | the lesson's month's 1st ≤ today | (#lessons in month) × fee | `payment_id` set on every lesson in the month |
| **Group weekly** | session (from `Offering::sessionWindows()`) 1 day ≤ now | 1 × fee | `us_payments.period_key` = session date |
| **Group monthly** | the month's 1st ≤ today | (#sessions in month) × fee | `period_key` = `YYYY-MM` |
- Private lessons dedup on `us_lessons.payment_id IS NULL` — a lesson with no payment is
unbilled. A monthly group links its earliest lesson via `createForRegistration` and the
runner points the remaining lessons at the same payment.
- Group enrolments (one row per whole term) dedup on `period_key` via
`PaymentRepository::existsForPeriod()`, since one enrolment maps to many periodic
charges.
- Only offerings with a positive price are billed; cancelled lessons are excluded, so a
lesson cancelled before its payment is generated is simply never billed.
### Late bookings charge at booking time
A single scheduled lesson booked **after** its due date has already passed is charged at
booking instead of deferred (`BookingEndpoint::scheduledDueHasPassed`): an extra monthly
lesson added to a month that was already billed (its 1st has arrived), or a weekly lesson
booked within 24 hours of the session. These create a normal at-registration payment (no
`due_date`), so the fee is collected once, at booking, and never billed late by the scan.
This applies only to single bookings — a weekly reservation series always defers, each
lesson billed by the scan on its own schedule.
## Notification — `Payment\PaymentDueMailer`
As the runner creates each **pending** payment it appends an itemised line to that
student's notice bucket; after all cases run it sends **one** email per student with a
line per item (label · due date · amount) and a grand total, plus the e-transfer
destination(s). A student billed for several lessons on one day is emailed once, never
per lesson. Comp payments (auto-paid) are not bucketed.
### Notice batch (lump-sum reconciliation)
All the payments in one student's notice are tagged with a shared **notice batch**
reference (`us_payments.notice_batch`, `PaymentRepository::assignNoticeBatch`), which is
printed on the email so the student can quote it. In the **Payments** admin queue those
payments are shown grouped under that reference with a combined lump-sum total
(`PaymentController::groupPending`), so when one e-transfer arrives for the whole notice
the admin can see exactly which pending payments — and therefore which bookings — it
covers. Each is still confirmed individually with **Mark received**. Legacy
at-registration payments have no batch and appear on their own.
## Cancellation
Scheduled payments are never auto-voided. `PaymentService::voidPending` acts only on
legacy at-registration payments (`! Payment::isScheduled()`), so cancelling one lesson
never voids a shared monthly charge, never refunds, and never rebills.
Cancelling a lesson that was **already paid** credits the student one lesson's share
of what they paid (`PaymentService::creditForCancelledLesson`), and the next scan
applies that credit against their due charges before emailing the notice
(`PaymentService::applyCredits`). See `credits.md` for the full model.
## Implementation
- Runner: `Unsupervised\Schedular\Payment\ScheduledBillingRunner`
- Notice email: `Unsupervised\Schedular\Payment\PaymentDueMailer`
- Finders: `Booking\BookingRepository::findUnbilledScheduledLessons`,
`GroupClass\EnrollmentRepository::findActiveByBillingModes`
- Dedup: `Payment\PaymentRepository::existsForPeriod`
- Session windows: `Offering\Offering::sessionWindows`
- Cron scheduling: `Installer::scheduleBilling`; cleared in `unsupervised-schedular.php`
deactivation hook.
## Tests
- `tests/Unit/Payment/ScheduledBillingRunnerTest.php`
- `tests/Unit/Payment/PaymentDueMailerTest.php`
- `tests/Unit/Payment/PaymentRepositoryTest.php` (`existsForPeriod`, `due_date`/`period_key`)
- `tests/Unit/Payment/PaymentServiceTest.php` (`voidPending` skips scheduled)
- `tests/Unit/Booking/BookingEndpointTest.php` / `tests/Unit/GroupClass/EnrollmentEndpointTest.php` (deferred payment)
+47 -6
View File
@@ -1,15 +1,21 @@
# Feature: Student Administration
## Overview
A read-only studio-admin area to browse students and drill into one student's
history and upcoming activity — lessons and group-class enrolments — without
digging through individual records.
A studio-admin area to browse students, drill into one student's history and
upcoming activity — lessons and group-class enrolments — and act on their
behalf: cancel a lesson, withdraw them from a group class, or fix their account
details.
## Data Model
No new tables. The views are composed from existing data:
- Students are WordPress users with the `us_student` role (`get_users`, `get_userdata`).
- Lessons come from `{prefix}us_lessons` (with `{prefix}us_availability` for slot times).
- Group-class enrolments come from `{prefix}us_group_enrollments`.
- Policy acceptances come from `{prefix}us_policy_acceptances` (with the policy
and version tables for titles/numbers).
- Intake answers come from `{prefix}us_question_answers` (with `{prefix}us_questions`
for labels).
- Payments come from `{prefix}us_payments`.
## Admin Interface
**Students** in wp-admin (`manage_students`, studio admin only):
@@ -22,10 +28,30 @@ No new tables. The views are composed from existing data:
- **Upcoming lessons** and **Past lessons** — split by the linked availability
slot's `start_dt`; each shows date/time, offering, instructor, and status.
- **Group-class enrolments** — active/past, with offering title and status.
- *(Later)* policy-acceptance history, intake answers, and payment history once
Payments lands.
- **Policy acceptances** — every acceptance the student has recorded, newest
first: policy title, version, context (account signup / lesson / enrolment),
and when it was accepted.
- **Intake answers** — every registration-question answer, newest first:
question label, answer, and the registration it was given for.
- **Account credit** (`manage_billing` only) — the student's available credit
balance plus every credit (date, reason, amount, remaining, status). Credit
comes from cancelled paid lessons and is applied automatically to upcoming
scheduled billing. See `credits.md`.
- **Payment history** (`manage_billing` only) — every payment, newest first:
date, context, method, status, subtotal, HST, total, and receipt number.
Read-only in this iteration; cancel/edit actions are a possible follow-up.
### Admin actions (detail view)
All actions are nonce-protected POSTs handled on the detail page:
- **Edit account** — display name and email. The email must be valid and not in
use by another account.
- **Cancel lesson** — on any non-cancelled upcoming lesson. Uses the same path
as student-initiated cancellation: the lesson is marked `cancelled`, the
availability slot is freed for rebooking, and a still-pending payment is
voided. A paid lesson is credited back to the student's account (see
`credits.md`) rather than refunded.
- **Withdraw** — on an active group-class enrolment: marked `cancelled` (freeing
its capacity seat), with the same pending-payment voiding.
## Capabilities
- `manage_students` — studio admin (administrators inherit it via the
@@ -38,6 +64,15 @@ Read-only in this iteration; cancel/edit actions are a possible follow-up.
`Availability\AvailabilityRepository::findById`,
`Offering\OfferingRepository::findById`,
`GroupClass\EnrollmentRepository::findByStudent` + `countActiveForStudent`
- History sections: `Auth\StudentHistory` builds the display rows from
`Policy\AcceptanceRepository::findByStudent`,
`Registration\AnswerRepository::findByStudent`, and
`Payment\PaymentRepository::findByStudent`, resolving policy/version titles and
question labels (unit-tested with mocked repositories).
- Actions: `Auth\StudentActions` — cancel lesson / withdraw enrolment (both
refuse records that don't belong to the student, and reuse
`Payment\PaymentService::voidPending`) and account updates via
`wp_update_user` (unit-tested with mocked repositories).
- Upcoming/past split: `Auth\StudentSchedule::partition()` (pure, unit-tested)
- The upcoming/past split is extracted into a small pure helper so it is
unit-testable (the controller itself follows the repo convention of not being
@@ -45,3 +80,9 @@ Read-only in this iteration; cancel/edit actions are a possible follow-up.
## Tests
- `tests/Unit/Auth/StudentScheduleTest.php` (the pure upcoming/past split helper)
- `tests/Unit/Auth/StudentHistoryTest.php` (history display rows + fallbacks)
- `tests/Unit/Auth/StudentActionsTest.php` (cancel/withdraw guards + side
effects, account validation)
- `findByStudent` coverage in `tests/Unit/Policy/AcceptanceRepositoryTest.php`,
`tests/Unit/Registration/AnswerRepositoryTest.php`, and
`tests/Unit/Payment/PaymentRepositoryTest.php`
+26 -1
View File
@@ -44,7 +44,32 @@
</properties>
</rule>
<!--
Val::* type-narrowing helpers (src/Val.php) wrap superglobal reads so
PHPStan level 10 sees a typed value, e.g.
`absint( Val::int( $_GET['id'] ?? 0 ) )`. The sniff walks wrapping
calls innermost-out and aborts at the first unrecognised function
name, so the Val method names must be registered for it to look past
them. Because they are static calls (`::`), the sniff never credits
them as sanitizers themselves — it skips them and still requires a
real sanitizing function around the read.
-->
<rule ref="WordPress.Security.ValidatedSanitizedInput">
<properties>
<property name="customUnslashingSanitizingFunctions" type="array">
<element value="int"/>
<element value="intOrNull"/>
<element value="float"/>
<element value="bool"/>
</property>
<property name="customSanitizingFunctions" type="array">
<element value="string"/>
<element value="stringOrNull"/>
</property>
</properties>
</rule>
<!-- PHP 8.1+ minimum — allow modern syntax. -->
<config name="minimum_supported_wp_version" value="6.0"/>
<config name="minimum_supported_wp_version" value="6.2"/>
<config name="testVersion" value="8.1-"/>
</ruleset>
+1 -1
View File
@@ -2,7 +2,7 @@ includes:
- vendor/szepeviktor/phpstan-wordpress/extension.neon
parameters:
level: 6
level: 10
paths:
- src
bootstrapFiles:
+43 -6
View File
@@ -8,25 +8,35 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Auth\InstructorController;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationApprovalController;
use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\StudentActions;
use Unsupervised\Schedular\Auth\StudentController;
use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Booking\BookingRepository;
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;
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\PaymentController;
use Unsupervised\Schedular\Payment\PaymentReportController;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyController;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyService;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\QuestionController;
use Unsupervised\Schedular\Registration\QuestionRepository;
@@ -38,6 +48,7 @@ class AdminMenu {
private QuestionController $questionController;
private PolicyController $policyController;
private RegistrationController $registrationController;
private RegistrationApprovalController $registrationApprovalController;
private GroupClassController $groupClassController;
private StudentController $studentController;
private InstructorController $instructorController;
@@ -46,15 +57,16 @@ class AdminMenu {
private PaymentController $paymentController;
private PaymentReportController $paymentReportController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) {
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 ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments );
$this->offeringController = new OfferingController( $offerings );
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
$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->groupClassController = new GroupClassController( $enrollments, $offerings );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver );
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) );
$this->instructorController = new InstructorController();
$this->settings = $settings;
$this->accessSettings = new AccessSettings();
@@ -168,6 +180,16 @@ class AdminMenu {
35
);
// Studio admin: approve or reject self-signup students (open registration).
add_submenu_page(
'us-students',
__( 'Pending Students', 'unsupervised-schedular' ),
__( 'Pending Students', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_STUDENTS,
RegistrationApprovalController::PAGE_SLUG,
[ $this->registrationApprovalController, 'renderPage' ]
);
// Studio admin: confirm pending (e-transfer) payments.
add_menu_page(
__( 'Payments', 'unsupervised-schedular' ),
@@ -215,7 +237,11 @@ class AdminMenu {
30.5
);
// Instructor: view their upcoming lessons.
// Instructor: view their upcoming lessons. Hidden for anyone who can
// already see the Scheduler — it shows every instructor's lessons
// (including their own, with the same payment edit forms), so the two
// menu items would just duplicate each other for an owner-operator.
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
add_menu_page(
__( 'My Lessons', 'unsupervised-schedular' ),
__( 'My Lessons', 'unsupervised-schedular' ),
@@ -225,6 +251,17 @@ class AdminMenu {
'dashicons-welcome-learn-more',
42
);
// Instructor: their own group classes with per-class rosters.
add_submenu_page(
'us-my-lessons',
__( 'My Group Classes', 'unsupervised-schedular' ),
__( 'My Group Classes', 'unsupervised-schedular' ),
RoleManager::CAP_VIEW_LESSONS,
'us-my-group-classes',
[ $this->groupClassController, 'renderInstructorPage' ]
);
}
}
/**
+3 -1
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Site-owner toggles for whether WordPress administrators automatically receive
* the studio-admin and/or instructor capabilities.
@@ -39,7 +41,7 @@ class AccessSettings {
* single-account behaviour.
*/
private function flag( string $option ): bool {
return '0' !== (string) get_option( $option, '1' );
return '0' !== Val::string( get_option( $option, '1' ) );
}
public function renderPage(): void {
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Val;
/**
* Handles the self-signup email-confirmation link, and keeps WordPress's own
* registration form from being used to bypass the studio's policy-accepting
* registration page while open registration is enabled.
*/
class EmailConfirmationHandler {
public function __construct(
private StudioSettings $settings,
private RegistrationMailer $mailer,
) {}
public function register(): void {
add_action( 'template_redirect', [ $this, 'maybeConfirm' ] );
add_filter( 'register_url', [ $this, 'registerUrl' ] );
// login_init fires at the top of wp-login.php for every request (GET form
// display AND a direct POST) before any registration processing, so it is
// the reliable choke point; registration_errors is a fail-safe in case a
// POST ever reaches register_new_user().
add_action( 'login_init', [ $this, 'blockNativeRegistration' ] );
add_filter( 'registration_errors', [ $this, 'blockRegistrationErrors' ], 10, 1 );
}
/**
* Confirm a self-signup's email when the emailed `?us_confirm=<token>` link
* is opened, then redirect back to the registration page with a result flag.
*/
public function maybeConfirm(): void {
if ( is_admin() ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the token is itself the capability-bearing secret (like a password-reset key); nonces do not apply to an emailed link.
$rawToken = sanitize_text_field( Val::string( wp_unslash( $_GET['us_confirm'] ?? '' ) ) );
if ( '' === $rawToken ) {
return;
}
$base = $this->registrationPageUrl();
$userId = RegistrationStatus::userIdForToken( $rawToken );
if ( null === $userId || RegistrationStatus::isTokenExpired( $userId, gmdate( 'Y-m-d H:i:s' ) ) ) {
wp_safe_redirect( add_query_arg( 'us_confirmed', 'expired', $base ) );
exit;
}
RegistrationStatus::confirmEmail( $userId );
$user = get_user_by( 'id', $userId );
// Group invite link signups skip the admin review queue: confirming the
// email approves the account on the spot, so the student can sign in
// immediately instead of waiting for a studio admin.
if ( RegistrationStatus::isAutoApprove( $userId ) ) {
RegistrationStatus::approve( $userId );
if ( $user instanceof \WP_User ) {
$this->mailer->sendApproved( $user );
}
wp_safe_redirect( add_query_arg( 'us_confirmed', 'ready', $base ) );
exit;
}
if ( $user instanceof \WP_User ) {
$this->mailer->notifyAdminsPending( $user );
}
wp_safe_redirect( add_query_arg( 'us_confirmed', '1', $base ) );
exit;
}
/**
* Point WordPress's own "Register" links at the studio registration page
* while open registration is on and a page is configured.
*/
public function registerUrl( string $url ): string {
if ( ! $this->settings->openRegistrationEnabled() ) {
return $url;
}
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
return $pageId > 0 ? (string) get_permalink( $pageId ) : $url;
}
/**
* Redirect any `wp-login.php?action=register` request (GET or POST) to the
* studio registration page, so the bare native form which cannot collect
* required policy acceptances is never used.
*/
public function blockNativeRegistration(): void {
if ( ! $this->settings->openRegistrationEnabled() ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing decision; no state is changed here.
$action = sanitize_key( Val::string( wp_unslash( $_REQUEST['action'] ?? '' ) ) );
if ( 'register' !== $action ) {
return;
}
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
if ( $pageId <= 0 ) {
return;
}
wp_safe_redirect( (string) get_permalink( $pageId ) );
exit;
}
/**
* Fail-safe: reject any native registration attempt while open registration
* is on, so `register_new_user()` can never create a policy-less account.
*
* @param \WP_Error $errors Accumulated registration errors.
* @return \WP_Error
*/
public function blockRegistrationErrors( \WP_Error $errors ): \WP_Error {
if ( $this->settings->openRegistrationEnabled() ) {
$errors->add(
'us_registration_redirect',
esc_html__( 'Please register on the studio registration page.', 'unsupervised-schedular' )
);
}
return $errors;
}
private function registrationPageUrl(): string {
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
return $pageId > 0 ? (string) get_permalink( $pageId ) : home_url( '/' );
}
}
+12 -6
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Studio-admin **Instructors** page: create instructor accounts and toggle each
* instructor's managed capabilities. Gated on `manage_instructors`. A studio
@@ -24,7 +26,7 @@ class InstructorController {
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only instructor selector.
$instructorId = absint( $_GET['instructor_id'] ?? 0 );
$instructorId = absint( Val::int( $_GET['instructor_id'] ?? 0 ) );
$instructor = $instructorId > 0 ? get_userdata( $instructorId ) : false;
if ( $instructor && in_array( RoleManager::INSTRUCTOR, (array) $instructor->roles, true ) ) {
@@ -50,12 +52,15 @@ class InstructorController {
'email' => $user->user_email,
'registered' => $user->user_registered,
],
array_filter(
get_users(
[
'role' => RoleManager::INSTRUCTOR,
'orderby' => 'display_name',
'order' => 'ASC',
]
),
static fn( mixed $user ): bool => $user instanceof \WP_User
)
);
@@ -66,7 +71,7 @@ class InstructorController {
private function handleFormAction(): string {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( 'create' === $action ) {
@@ -82,8 +87,8 @@ class InstructorController {
private function createInstructor(): string {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
$name = sanitize_text_field( wp_unslash( $_POST['display_name'] ?? '' ) );
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
$name = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( ! is_email( $email ) ) {
@@ -125,8 +130,9 @@ class InstructorController {
private function updateCaps(): string {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$instructorId = absint( $_POST['instructor_id'] ?? 0 );
$submitted = array_map( 'sanitize_key', (array) wp_unslash( $_POST['capabilities'] ?? [] ) );
$instructorId = absint( Val::int( $_POST['instructor_id'] ?? 0 ) );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each capability key is sanitized with sanitize_key() in the array_map callback.
$submitted = array_values( array_map( static fn( mixed $cap ): string => sanitize_key( Val::string( $cap ) ), (array) wp_unslash( $_POST['capabilities'] ?? [] ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
$instructor = $instructorId > 0 ? get_userdata( $instructorId ) : false;
+57 -15
View File
@@ -3,12 +3,20 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
class Invite {
public const STATUS_PENDING = 'pending';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_REVOKED = 'revoked';
/** Single-use invite addressed to one email. */
public const KIND_PERSONAL = 'personal';
/** Multi-use shareable link (e.g. for a newsletter) with an explicit expiry. */
public const KIND_GROUP = 'group';
/**
* All valid invite statuses.
*
@@ -22,6 +30,16 @@ class Invite {
*/
public const EXPIRY_DAYS = 14;
/**
* Hash a raw invitation token for storage and lookup. Only the hash is
* persisted, so a database leak (backup, SQL injection elsewhere) cannot be
* used to redeem pending invites; the raw token exists only in the emailed
* link and is shown to the admin once, at creation.
*/
public static function hashToken( string $rawToken ): string {
return hash( 'sha256', $rawToken );
}
public function __construct(
public readonly string $email,
public readonly string $token,
@@ -31,40 +49,61 @@ class Invite {
public readonly ?int $acceptedUserId = null,
public readonly ?string $acceptedAt = null,
public readonly ?string $createdAt = null,
public readonly string $kind = self::KIND_PERSONAL,
public readonly ?string $expiresAt = null,
public readonly ?int $offeringId = null,
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
email: $row->email,
token: $row->token,
role: $row->role,
status: $row->status,
invitedBy: null !== $row->invited_by ? (int) $row->invited_by : null,
acceptedUserId: null !== $row->accepted_user_id ? (int) $row->accepted_user_id : null,
acceptedAt: $row->accepted_at,
createdAt: $row->created_at ?? null,
id: (int) $row->id,
email: Val::string( $row->email ),
token: Val::string( $row->token ),
role: Val::string( $row->role ),
status: Val::string( $row->status ),
invitedBy: Val::intOrNull( $row->invited_by ),
acceptedUserId: Val::intOrNull( $row->accepted_user_id ),
acceptedAt: Val::stringOrNull( $row->accepted_at ),
createdAt: Val::stringOrNull( $row->created_at ?? null ),
kind: '' !== Val::string( $row->kind ?? '' ) ? Val::string( $row->kind ) : self::KIND_PERSONAL,
expiresAt: Val::stringOrNull( $row->expires_at ?? null ),
offeringId: Val::intOrNull( $row->offering_id ?? null ),
id: Val::int( $row->id ),
);
}
public function isGroup(): bool {
return self::KIND_GROUP === $this->kind;
}
public function isPending(): bool {
return self::STATUS_PENDING === $this->status;
}
/**
* Whether the invite was created more than {@see EXPIRY_DAYS} ago, measured
* against the supplied current `Y-m-d H:i:s` timestamp. An invite with no
* known creation time is treated as not expired.
* Whether the invite has expired, measured against the supplied current
* `Y-m-d H:i:s` timestamp. An explicit `expires_at` (set on every group
* link) wins; otherwise a personal invite expires {@see EXPIRY_DAYS} after
* creation. An invite with neither timestamp is treated as not expired.
*/
public function isExpired( string $now ): bool {
$current = strtotime( $now );
if ( false === $current ) {
return false;
}
if ( null !== $this->expiresAt ) {
$expires = strtotime( $this->expiresAt );
return false !== $expires && $current > $expires;
}
if ( null === $this->createdAt ) {
return false;
}
$created = strtotime( $this->createdAt );
$current = strtotime( $now );
if ( false === $created || false === $current ) {
if ( false === $created ) {
return false;
}
@@ -89,10 +128,13 @@ class Invite {
'email' => $this->email,
'token' => $this->token,
'role' => $this->role,
'kind' => $this->kind,
'status' => $this->status,
'invited_by' => $this->invitedBy,
'accepted_user_id' => $this->acceptedUserId,
'accepted_at' => $this->acceptedAt,
'expires_at' => $this->expiresAt,
'offering_id' => $this->offeringId,
];
}
}
+16 -7
View File
@@ -11,28 +11,35 @@ class InviteRepository {
$this->table = $db->prefix . 'us_invites';
}
/**
* Persist an invite. Returns the new row id, or 0 when the insert failed
* callers must not hand out a registration link for an unstored token.
*/
public function insert( Invite $invite ): int {
$this->db->insert(
$result = $this->db->insert(
$this->table,
[
'email' => $invite->email,
'token' => $invite->token,
'role' => $invite->role,
'kind' => $invite->kind,
'offering_id' => $invite->offeringId,
'status' => $invite->status,
'invited_by' => $invite->invitedBy,
'accepted_user_id' => $invite->acceptedUserId,
'created_at' => current_time( 'mysql' ),
'accepted_at' => $invite->acceptedAt,
'expires_at' => $invite->expiresAt,
],
[ '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s' ]
[ '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s' ]
);
return $this->db->insert_id;
return false === $result ? 0 : $this->db->insert_id;
}
public function findByToken( string $token ): ?Invite {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE token = %s", $token )
$this->db->prepare( 'SELECT * FROM %i WHERE token = %s', $this->table, $token )
);
return $row ? Invite::fromRow( $row ) : null;
@@ -40,7 +47,7 @@ class InviteRepository {
public function findById( int $id ): ?Invite {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Invite::fromRow( $row ) : null;
@@ -52,7 +59,8 @@ class InviteRepository {
public function findPendingByEmail( string $email ): ?Invite {
$row = $this->db->get_row(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE email = %s AND status = %s ORDER BY id DESC LIMIT 1",
'SELECT * FROM %i WHERE email = %s AND status = %s ORDER BY id DESC LIMIT 1',
$this->table,
$email,
Invite::STATUS_PENDING
)
@@ -69,7 +77,8 @@ class InviteRepository {
public function findPending(): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE status = %s ORDER BY created_at DESC",
'SELECT * FROM %i WHERE status = %s ORDER BY created_at DESC',
$this->table,
Invite::STATUS_PENDING
)
);
+27 -8
View File
@@ -3,32 +3,36 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
class LoginPage {
/**
* Renders the student login shortcode output.
* Renders the student login shortcode/block output.
*
* @param array<string, string> $atts Shortcode attributes (unused reserved for future options).
* @param array<int|string, mixed> $atts Block attributes (`bookingPageId`) or
* shortcode attributes (`booking_page_id`).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
public function render( array $atts ): string {
$bookingPageId = Val::int( $atts['bookingPageId'] ?? $atts['booking_page_id'] ?? 0 );
if ( is_user_logged_in() ) {
$redirect = esc_url( (string) get_permalink() );
return sprintf(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'You are already logged in.', 'unsupervised-schedular' ),
$redirect,
esc_url( $this->bookingUrl( $bookingPageId ) ?? (string) get_permalink() ),
esc_html__( 'View available lessons', 'unsupervised-schedular' )
);
}
$error = '';
$redirect = sanitize_url( (string) get_permalink() );
$redirect = sanitize_url( $this->bookingUrl( $bookingPageId ) ?? (string) get_permalink() );
if ( isset( $_POST['us_login'] ) && check_admin_referer( 'us_student_login' ) ) {
$credentials = [
'user_login' => sanitize_user( wp_unslash( $_POST['log'] ?? '' ) ),
'user_login' => sanitize_user( Val::string( wp_unslash( $_POST['log'] ?? '' ) ) ),
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized.
'user_password' => wp_unslash( $_POST['pwd'] ?? '' ),
'user_password' => Val::string( wp_unslash( $_POST['pwd'] ?? '' ) ),
'remember' => isset( $_POST['rememberme'] ),
];
@@ -46,4 +50,19 @@ class LoginPage {
include USC_PLUGIN_DIR . 'templates/frontend/login-page.php';
return (string) ob_get_clean();
}
/**
* Permalink of the configured booking page, or null when no page is
* chosen (or the chosen page no longer exists). Logged-in visitors are
* linked (and redirected after login) there instead of the current page.
*/
public function bookingUrl( int $bookingPageId ): ?string {
if ( $bookingPageId <= 0 ) {
return null;
}
$url = get_permalink( $bookingPageId );
return is_string( $url ) ? $url : null;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Admin page (Students Pending Students) for reviewing self-signup accounts:
* approve a confirmed applicant into a full student, or reject (delete) them.
* Only relevant while open registration is enabled.
*/
class RegistrationApprovalController {
public const PAGE_SLUG = 'us-pending-students';
public const NONCE_ACTION = 'usc_registration_approval';
public function __construct( private RegistrationMailer $mailer ) {}
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_STUDENTS ) ) {
wp_die( esc_html__( 'You do not have permission to manage student registrations.', 'unsupervised-schedular' ) );
}
if ( isset( $_POST['usc_action'] ) && check_admin_referer( self::NONCE_ACTION ) ) {
$this->handleAction();
}
$awaitingApproval = [];
$awaitingConfirmation = [];
foreach ( $this->pendingUsers() as $user ) {
if ( RegistrationStatus::emailConfirmed( (int) $user->ID ) ) {
$awaitingApproval[] = $user;
} else {
$awaitingConfirmation[] = $user;
}
}
include USC_PLUGIN_DIR . 'templates/admin/registrations.php';
}
/**
* Approve or reject the posted user. Approval clears the pending flags and
* emails the student; rejection emails them, then hard-deletes the account so
* the email is freed to re-apply.
*/
private function handleAction(): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
$userId = absint( Val::int( $_POST['user_id'] ?? 0 ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( $userId <= 0 || ! RegistrationStatus::isAwaitingApproval( $userId ) ) {
return;
}
if ( 'approve' === $action ) {
RegistrationStatus::approve( $userId );
$user = get_user_by( 'id', $userId );
if ( $user instanceof \WP_User ) {
$this->mailer->sendApproved( $user );
}
return;
}
if ( 'reject' === $action ) {
$user = get_user_by( 'id', $userId );
$email = $user instanceof \WP_User ? (string) $user->user_email : '';
if ( '' !== $email ) {
$this->mailer->sendRejected( $email );
}
if ( ! function_exists( 'wp_delete_user' ) ) {
require_once ABSPATH . 'wp-admin/includes/user.php';
}
wp_delete_user( $userId );
}
}
/**
* Every account still awaiting approval (confirmed or not).
*
* @return list<\WP_User>
*/
private function pendingUsers(): array {
return array_values(
array_filter(
get_users(
[
'meta_key' => RegistrationStatus::META_AWAITING_APPROVAL, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'number' => 500,
'orderby' => 'user_registered',
'order' => 'ASC',
]
),
static fn( mixed $user ): bool => $user instanceof \WP_User
)
);
}
}
+96 -12
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
class RegistrationController {
/**
@@ -17,50 +19,132 @@ class RegistrationController {
wp_die( esc_html__( 'You do not have permission to manage invites.', 'unsupervised-schedular' ) );
}
$newInviteUrl = '';
$inviteError = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_invite_action' ) ) {
$this->handleFormAction();
[ $newInviteUrl, $inviteError ] = $this->handleFormAction();
}
$pendingInvites = $this->invites->findPending();
$registrationPageId = (int) get_option( self::OPTION_PAGE, 0 );
$registrationPageId = Val::int( get_option( self::OPTION_PAGE, 0 ) );
$registrationPageUrl = $registrationPageId > 0 ? (string) get_permalink( $registrationPageId ) : '';
include USC_PLUGIN_DIR . 'templates/admin/invites.php';
}
private function handleFormAction(): void {
/**
* Handle a posted admin action. Returns `[link, error]`: the registration
* link for a freshly created invite the only time it can be shown, since
* just the token's hash is stored or an error message when creation
* failed; both empty for every other action.
*
* @return array{string, string}
*/
private function handleFormAction(): array {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'set_page' === $action ) {
update_option( self::OPTION_PAGE, absint( $_POST['registration_page_id'] ?? 0 ) );
update_option( self::OPTION_PAGE, absint( Val::int( $_POST['registration_page_id'] ?? 0 ) ) );
}
if ( 'invite' === $action ) {
$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
if (
is_email( $email )
&& false === email_exists( $email )
&& null === $this->invites->findPendingByEmail( $email )
! is_email( $email )
|| false !== email_exists( $email )
|| null !== $this->invites->findPendingByEmail( $email )
) {
$this->invites->insert(
return [ '', esc_html__( 'Could not create the invite: enter a valid email address that has no account and no pending invite.', 'unsupervised-schedular' ) ];
}
$rawToken = wp_generate_password( 32, false );
$id = $this->invites->insert(
new Invite(
email: $email,
token: wp_generate_password( 32, false ),
token: Invite::hashToken( $rawToken ),
invitedBy: get_current_user_id(),
)
);
return $this->linkOrError( $id, $rawToken );
}
if ( 'group_invite' === $action ) {
$expiresAt = $this->normalizeExpiry( sanitize_text_field( Val::string( wp_unslash( $_POST['expires_at'] ?? '' ) ) ) );
if ( null === $expiresAt ) {
return [ '', esc_html__( 'Could not create the group link: choose an expiry date of today or later.', 'unsupervised-schedular' ) ];
}
$rawToken = wp_generate_password( 32, false );
$id = $this->invites->insert(
new Invite(
email: '',
token: Invite::hashToken( $rawToken ),
invitedBy: get_current_user_id(),
kind: Invite::KIND_GROUP,
expiresAt: $expiresAt,
)
);
return $this->linkOrError( $id, $rawToken );
}
if ( 'revoke' === $action ) {
$inviteId = absint( $_POST['invite_id'] ?? 0 );
$inviteId = absint( Val::int( $_POST['invite_id'] ?? 0 ) );
if ( $inviteId > 0 ) {
$this->invites->revoke( $inviteId );
}
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
return [ '', '' ];
}
/**
* The registration link for a stored invite, or an error when the insert
* failed a link must never be shown for a token that was not persisted,
* since it could only ever dead-end as "invalid or expired".
*
* @return array{string, string}
*/
private function linkOrError( int $insertedId, string $rawToken ): array {
if ( $insertedId <= 0 ) {
return [ '', esc_html__( 'Could not save the invite. Deactivate and reactivate the plugin to update the database, then try again.', 'unsupervised-schedular' ) ];
}
return [ $this->registrationLink( $rawToken ), '' ];
}
/**
* Validate a submitted group-link expiry date (strict `Y-m-d`, today or
* later) and expand it to the end of that day; null when invalid or past.
*/
private function normalizeExpiry( string $date ): ?string {
$day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date );
if ( false === $day || $day->format( 'Y-m-d' ) !== $date ) {
return null;
}
if ( $date < Val::string( current_time( 'Y-m-d' ) ) ) {
return null;
}
return $date . ' 23:59:59';
}
/**
* Build the registration URL for a raw invite token.
*/
private function registrationLink( string $rawToken ): string {
$pageId = Val::int( get_option( self::OPTION_PAGE, 0 ) );
$linkBase = $pageId > 0 ? (string) get_permalink( $pageId ) : '';
return add_query_arg( 'us_invite', rawurlencode( $rawToken ), '' !== $linkBase ? $linkBase : home_url( '/' ) );
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* Enforces the pending state of self-signup accounts:
* - an account whose email is not yet confirmed cannot log in at all;
* - a confirmed-but-unapproved account may log in, but its booking capability
* is withheld so it only reaches the "awaiting approval" screen.
*
* Both checks key solely off the pending user meta, so invite- and
* admin-created students (which carry none of it) are unaffected.
*/
class RegistrationLoginGate {
public function register(): void {
add_filter( 'wp_authenticate_user', [ $this, 'blockUnconfirmed' ], 10, 1 );
add_filter( 'user_has_cap', [ $this, 'withholdBookingWhilePending' ], 10, 4 );
}
/**
* Block authentication for a self-signup that has not yet confirmed its
* email. Runs after password verification.
*
* @param \WP_User|\WP_Error $user Authenticating user, or an earlier error.
* @return \WP_User|\WP_Error
*/
public function blockUnconfirmed( $user ) {
if (
$user instanceof \WP_User
&& RegistrationStatus::isAwaitingApproval( (int) $user->ID )
&& ! RegistrationStatus::emailConfirmed( (int) $user->ID )
) {
return new \WP_Error(
'us_email_unconfirmed',
esc_html__( 'Please confirm your email address before logging in — check your inbox for the confirmation link.', 'unsupervised-schedular' )
);
}
return $user;
}
/**
* Strip the booking capability from any account still awaiting approval, so a
* confirmed-but-unapproved student cannot book until a studio admin approves.
*
* @param array<string, bool> $allcaps All capabilities currently held.
* @param array<int, string> $caps Required capabilities (unused).
* @param array<int, mixed> $args Callback args (unused).
* @param mixed $user The user being checked (a WP_User in practice).
* @return array<string, bool>
*/
public function withholdBookingWhilePending( array $allcaps, array $caps, array $args, mixed $user ): array {
if ( $user instanceof \WP_User && RegistrationStatus::isAwaitingApproval( (int) $user->ID ) ) {
unset( $allcaps[ RoleManager::CAP_BOOK_LESSON ] );
}
return $allcaps;
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Transactional emails for the self-approval registration flow: the email
* confirmation link, the studio-admin heads-up that someone is ready to
* approve, and the approval / rejection notices to the student.
*/
class RegistrationMailer {
/**
* Email the new student a link to confirm their address. Returns false when
* there is no recipient.
*/
public function sendConfirmation( \WP_User $user, string $confirmUrl ): bool {
if ( '' === (string) $user->user_email ) {
return false;
}
$subject = sprintf(
/* translators: %s: site name */
__( 'Confirm your email for %s', 'unsupervised-schedular' ),
$this->siteName()
);
$body = sprintf(
/* translators: 1: site name, 2: confirmation URL */
__( "Thanks for signing up at %1\$s.\n\nPlease confirm your email address by opening this link:\n%2\$s\n\nOnce confirmed, a studio admin will review and approve your account. You'll get another email when it's ready.", 'unsupervised-schedular' ),
$this->siteName(),
$confirmUrl
);
return (bool) wp_mail( $user->user_email, $subject, $body );
}
/**
* Tell the studio admins a self-signup has confirmed their email and is
* waiting for approval. Sent to the site admin email.
*/
public function notifyAdminsPending( \WP_User $user ): bool {
$adminEmail = Val::string( get_option( 'admin_email', '' ) );
if ( '' === $adminEmail ) {
return false;
}
$subject = __( 'A new student is awaiting approval', 'unsupervised-schedular' );
$body = sprintf(
/* translators: 1: student name, 2: student email */
__( "%1\$s (%2\$s) has confirmed their email and is awaiting approval.\n\nReview them under Students → Pending Students in wp-admin.", 'unsupervised-schedular' ),
(string) $user->display_name,
(string) $user->user_email
);
return (bool) wp_mail( $adminEmail, $subject, $body );
}
/**
* Tell the student their account has been approved. Returns false when there
* is no recipient.
*/
public function sendApproved( \WP_User $user ): bool {
if ( '' === (string) $user->user_email ) {
return false;
}
$subject = sprintf(
/* translators: %s: site name */
__( 'Your %s account is approved', 'unsupervised-schedular' ),
$this->siteName()
);
$body = sprintf(
/* translators: 1: site name, 2: login URL */
__( "Good news — your account at %1\$s has been approved. You can now log in and book:\n%2\$s", 'unsupervised-schedular' ),
$this->siteName(),
wp_login_url()
);
return (bool) wp_mail( $user->user_email, $subject, $body );
}
/**
* Tell an applicant their registration was declined. Takes the email address
* directly, since the account is deleted as part of rejection.
*/
public function sendRejected( string $email ): bool {
if ( '' === $email ) {
return false;
}
$subject = sprintf(
/* translators: %s: site name */
__( 'Your %s registration', 'unsupervised-schedular' ),
$this->siteName()
);
$body = sprintf(
/* translators: %s: site name */
__( 'Thank you for your interest in %s. We are unable to approve your registration at this time. Please contact the studio if you have any questions.', 'unsupervised-schedular' ),
$this->siteName()
);
return (bool) wp_mail( $email, $subject, $body );
}
/**
* Tell a registered student they have been given access to an invite-only
* group class and can now enrol. Returns false when there is no recipient.
*/
public function sendClassAccessGranted( \WP_User $user, string $className ): bool {
if ( '' === (string) $user->user_email ) {
return false;
}
$subject = sprintf(
/* translators: %s: class title */
__( 'You have been invited to %s', 'unsupervised-schedular' ),
$className
);
$body = sprintf(
/* translators: 1: class title, 2: site name, 3: login URL */
__( "You have been given access to the group class \"%1\$s\" at %2\$s.\n\nLog in and open the group classes page to enrol:\n%3\$s", 'unsupervised-schedular' ),
$className,
$this->siteName(),
wp_login_url()
);
return (bool) wp_mail( $user->user_email, $subject, $body );
}
/**
* Email a tokenised registration link to someone invited to a group class who
* does not yet have an account. Returns false when there is no recipient.
*/
public function sendClassInvite( string $email, string $link, string $className ): bool {
if ( '' === $email ) {
return false;
}
$subject = sprintf(
/* translators: %s: class title */
__( 'You are invited to join %s', 'unsupervised-schedular' ),
$className
);
$body = sprintf(
/* translators: 1: class title, 2: site name, 3: registration URL */
__( "You have been invited to the group class \"%1\$s\" at %2\$s.\n\nCreate your account using this link, then choose to enrol in the class:\n%3\$s", 'unsupervised-schedular' ),
$className,
$this->siteName(),
$link
);
return (bool) wp_mail( $email, $subject, $body );
}
private function siteName(): string {
$name = (string) get_bloginfo( 'name' );
return '' !== $name ? $name : __( 'the studio', 'unsupervised-schedular' );
}
}
+350 -32
View File
@@ -3,55 +3,200 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy;
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\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Val;
class RegistrationPage {
/** Success signal: an invited student was created and logged in. */
private const RESULT_INVITE = 'invite';
/** Success signal: a self-signup was created and must confirm their email. */
private const RESULT_CONFIRM = 'confirm';
/**
* Success signal: a group-link signup was created and must confirm their
* email confirming approves the account immediately (no admin review).
*/
private const RESULT_CONFIRM_GROUP = 'confirm_group';
/**
* Validation error from the most recent submission processed on
* `template_redirect`, carried over to {@see render()} so it can be shown
* inline with the form. Empty when the last submit succeeded or none ran.
*/
private string $submitError = '';
public function __construct(
private InviteRepository $invites,
private PolicyRepository $policies,
private PolicyVersionRepository $versions,
private AcceptanceRepository $acceptances,
private StudioSettings $settings,
private RegistrationMailer $mailer,
private QuestionRepository $questions,
private AnswerRepository $answers,
private GroupAccessRepository $access,
) {}
/**
* Renders the student registration shortcode output.
*
* @param array<string, string> $atts Shortcode attributes (unused reserved for future options).
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`,
* `inviteOnlyMessage`) or shortcode
* attributes (`login_page_id`,
* `invite_only_message`).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
public function render( array $atts ): string {
// A just-completed invite signup is redirected back here already logged
// in (see maybeHandleSubmit); its success flag distinguishes that from a
// visitor who simply happens to be signed in already.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
if ( is_user_logged_in() ) {
if ( self::RESULT_INVITE === $registered ) {
// An invited student is done the moment they land here logged in,
// so this is where their "continue" link belongs. The sign-in-page
// fallback is deliberately not used: pointing someone who is
// already signed in at the login screen helps nobody.
$continue = $this->continueUrl( $this->successPageId( $atts ) );
$link = null === $continue
? ''
: '<p><a href="' . esc_url( $continue ) . '">'
. esc_html__( 'Continue to your account', 'unsupervised-schedular' )
. '</a></p>';
return '<div class="us-register-form"><p class="us-success">'
. esc_html__( 'Your account has been created and you are now logged in.', 'unsupervised-schedular' )
. '</p>' . $link . '</div>';
}
return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked below.
$token = sanitize_text_field( wp_unslash( $_REQUEST['us_invite'] ?? '' ) );
$invite = '' !== $token ? $this->invites->findByToken( $token ) : null;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked in maybeHandleSubmit.
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) );
// Only the token's hash is stored, so hash the submitted token for lookup.
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
$open = $this->settings->openRegistrationEnabled();
$error = '';
$success = false;
// Only a redeemable invite fixes the form's email to the invited address.
// A stale token (expired / accepted / revoked) with open registration on
// must fall back to the normal editable email field, not show — and then
// fail to submit — the stale invite's address.
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) {
$result = $this->handleSubmit( $invite );
if ( true === $result ) {
$success = true;
} else {
$error = $result;
}
}
// The submission itself is processed in maybeHandleSubmit on
// template_redirect (before any output), so the invite auto-login cookie
// is actually sent. Its success signal returns here as ?us_registered;
// only a validation error is carried on the instance to show inline.
$successType = in_array( $registered, [ self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ? $registered : '';
$error = $this->submitError;
// Result of an email-confirmation link (set by EmailConfirmationHandler's redirect).
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, not a state change.
$confirmResult = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
// Where the post-confirmation prompt sends students to sign in.
$loginUrl = $this->loginUrl( $this->successPageId( $atts ) );
$policyForms = $this->signupPolicies();
$canRegister = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$canRegister = $open || $inviteValid;
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
// The two-step script only matters when there is a second step to reveal.
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
wp_enqueue_script( 'us-scheduler-register' );
}
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
return (string) ob_get_clean();
}
/**
* Process a submitted registration on `template_redirect`, before any page
* output. Running here (rather than inside {@see render()}, which fires
* during `the_content` after headers are sent) is what lets the invite
* branch's `wp_set_auth_cookie()` actually persist otherwise the student
* appears logged in for a single render and is logged out on the next view.
*
* On success the request is redirected (post/redirect/get) with a
* `?us_registered` flag so a refresh cannot resubmit; a validation error is
* stashed for {@see render()} to show inline with the form.
*/
public function maybeHandleSubmit(): void {
if ( ! isset( $_POST['us_register'] ) || is_user_logged_in() ) {
return;
}
if ( ! check_admin_referer( 'us_student_register' ) ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer above.
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) );
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
$open = $this->settings->openRegistrationEnabled();
$result = $this->handleSubmit( $invite, $open );
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) {
$this->redirect( add_query_arg( 'us_registered', $result, $this->currentUrl() ) );
return;
}
$this->submitError = $result;
}
/**
* The current page's clean permalink, used as the post/redirect/get target
* so the invite token and any stale flags are dropped from the URL.
*/
private function currentUrl(): string {
$url = get_permalink();
return is_string( $url ) ? $url : home_url( '/' );
}
/**
* Issues the post-submit redirect and stops the request. Split out so tests
* can observe the target without the process exiting.
*/
protected function redirect( string $url ): void {
wp_safe_redirect( $url );
exit;
}
/**
* The message shown when registration is closed and no valid invite is
* present. Studios can override the default via the block
* (`inviteOnlyMessage`) or shortcode (`invite_only_message`) attribute.
*
* @param array<int|string, mixed> $atts
*/
private function inviteOnlyMessage( array $atts ): string {
$custom = trim( Val::string( $atts['inviteOnlyMessage'] ?? $atts['invite_only_message'] ?? '' ) );
if ( '' !== $custom ) {
return $custom;
}
return esc_html__( 'Registration is by invitation only. Please use the link from your invitation email, or contact the studio.', 'unsupervised-schedular' );
}
/**
* Redirect to the configured registration page when an invite token lands
* elsewhere (e.g. a link generated before the page was selected). Hooked on
@@ -63,12 +208,12 @@ class RegistrationPage {
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only token used only to build the redirect target.
$token = sanitize_text_field( wp_unslash( $_GET['us_invite'] ?? '' ) );
$token = sanitize_text_field( Val::string( wp_unslash( $_GET['us_invite'] ?? '' ) ) );
if ( '' === $token ) {
return;
}
$pageId = (int) get_option( RegistrationController::OPTION_PAGE, 0 );
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
if ( $pageId <= 0 || is_page( $pageId ) ) {
return;
}
@@ -78,26 +223,44 @@ class RegistrationPage {
}
/**
* Process the submitted registration. Returns true on success or an error
* message string on failure.
* Process the submitted registration. Returns a success signal
* ({@see RESULT_INVITE} or {@see RESULT_CONFIRM}) or an error message string
* on failure.
*
* The invite branch is tried first, so an invited student always completes
* signup regardless of whether open registration is enabled.
*/
private function handleSubmit( ?Invite $invite ): string|bool {
if ( null === $invite || ! $invite->isAcceptable( current_time( 'mysql' ) ) ) {
private function handleSubmit( ?Invite $invite, bool $open ): string {
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
if ( ! $inviteValid && ! $open ) {
return esc_html__( 'This invitation is invalid, expired, or has already been used.', 'unsupervised-schedular' );
}
// The submit nonce is verified by the caller (render) before this runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized.
$password = (string) wp_unslash( $_POST['password'] ?? '' );
$displayName = sanitize_text_field( wp_unslash( $_POST['display_name'] ?? '' ) );
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) );
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
if ( strlen( $password ) < 8 ) {
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
}
// The email is fixed by a personal invite; group-link signups and
// self-signups supply their own.
if ( $inviteValid && ! $invite->isGroup() ) {
$email = $invite->email;
} else {
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
if ( ! is_email( $email ) ) {
return esc_html__( 'Please enter a valid email address.', 'unsupervised-schedular' );
}
}
$policyForms = $this->signupPolicies();
$accepted = array_map( 'absint', (array) ( $_POST['accept'] ?? [] ) );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion.
$accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
foreach ( $policyForms as $form ) {
@@ -106,17 +269,28 @@ class RegistrationPage {
}
}
if ( email_exists( $invite->email ) ) {
// Account-signup questions (step two) — validate before creating the user so
// a missing required answer never leaves a half-registered account behind.
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$answers = $this->submittedAnswers();
foreach ( $accountQuestions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
}
}
if ( email_exists( $email ) ) {
return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
}
$userId = wp_insert_user(
[
'user_login' => $invite->email,
'user_email' => $invite->email,
'user_login' => $email,
'user_email' => $email,
'user_pass' => $password,
'display_name' => '' !== $displayName ? $displayName : $invite->email,
'role' => $invite->role,
'display_name' => '' !== $displayName ? $displayName : $email,
'role' => $inviteValid ? $invite->role : RoleManager::STUDENT,
]
);
@@ -125,12 +299,156 @@ class RegistrationPage {
}
$this->recordAcceptances( $policyForms, (int) $userId );
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
if ( $inviteValid && ! $invite->isGroup() ) {
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
// A personal invite may carry a group-class grant (invited by email);
// point any grants for this address at the new account so the class
// becomes enrollable for them.
$this->access->linkStudentByEmail( $email, (int) $userId );
wp_set_current_user( (int) $userId );
wp_set_auth_cookie( (int) $userId );
return true;
return self::RESULT_INVITE;
}
// Group-link signups and self-signups both stay pending until they
// confirm their email; the group link is multi-use so it is never marked
// accepted. A group signup auto-approves on confirmation — no admin
// review — while a self-signup then waits for studio approval.
$autoApprove = $inviteValid && $invite->isGroup();
$rawToken = RegistrationStatus::markPending( (int) $userId, $autoApprove );
$user = get_user_by( 'id', (int) $userId );
if ( $user instanceof \WP_User ) {
$this->mailer->sendConfirmation( $user, $this->confirmUrl( $rawToken ) );
}
return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM;
}
/**
* The page id chosen for the post-registration destination, from either the
* block (`loginPageId`) or shortcode (`login_page_id`) attribute.
*
* @param array<int|string, mixed> $atts
*/
private function successPageId( array $atts ): int {
return Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
}
/**
* URL the post-confirmation sign-in link points to: the chosen login page
* when one is configured (and still exists), otherwise the WordPress login
* screen.
*/
private function loginUrl( int $loginPageId ): string {
return $this->continueUrl( $loginPageId ) ?? wp_login_url();
}
/**
* The chosen post-registration page's URL, or null when none is configured
* (or it has since been deleted). Unlike {@see loginUrl()} this has no
* WordPress-login-screen fallback, so callers that need a page the student
* was actually sent to the invited-student link and the block's
* auto-redirect can tell "not configured" from "configured".
*/
public function continueUrl( int $pageId ): ?string {
if ( $pageId <= 0 ) {
return null;
}
$url = get_permalink( $pageId );
return is_string( $url ) ? $url : null;
}
/**
* Whether this request is a *finished* registration the states the
* block's auto-redirect may act on:
*
* - an invited student who just signed up and is now logged in, and
* - a self-signup returning from the emailed confirmation link, whether
* their account is ready (`ready`) or awaiting studio approval (`1`).
*
* Deliberately excluded: the intermediate "check your email" step (the
* student would never see the instruction) and every failure a validation
* error or an expired confirmation link (`expired`) so the message always
* gets shown. The `us_confirmed` values are set by
* {@see EmailConfirmationHandler::maybeConfirm()}.
*/
public function isRegistrationComplete(): bool {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
if ( self::RESULT_INVITE === $registered ) {
return is_user_logged_in();
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag set by EmailConfirmationHandler's redirect.
$confirmed = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
return in_array( $confirmed, [ '1', 'ready' ], true );
}
/**
* Build the email-confirmation URL for a raw token: the configured
* registration page (falling back to the home page) with `?us_confirm=`.
*/
private function confirmUrl( string $rawToken ): string {
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
$base = $pageId > 0 ? (string) get_permalink( $pageId ) : home_url( '/' );
return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base );
}
/**
* The account-question answers submitted with the form, keyed by question id.
*
* @return array<int, string>
*/
private function submittedAnswers(): array {
// The submit nonce is verified by the caller (render) before this runs.
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized in the loop below.
$raw = $_POST['us_answers'] ?? [];
if ( ! is_array( $raw ) ) {
return [];
}
$out = [];
foreach ( $raw as $questionId => $value ) {
$out[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
}
return $out;
}
/**
* Persist the submitted answers for each active account-signup question.
*
* @param list<Question> $questions
* @param array<int, string> $answers question_id => submitted value
*/
private function recordAnswers( array $questions, array $answers, int $userId ): void {
foreach ( $questions as $question ) {
$value = trim( (string) ( $answers[ (int) $question->id ] ?? '' ) );
if ( '' === $value ) {
continue;
}
$this->answers->insert(
new Answer(
questionId: (int) $question->id,
registrationType: Answer::REG_ACCOUNT,
registrationId: $userId,
studentId: $userId,
answerValue: $value,
)
);
}
}
/**
@@ -140,7 +458,7 @@ class RegistrationPage {
*/
private function recordAcceptances( array $policyForms, int $userId ): void {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP is stored verbatim for audit.
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
foreach ( $policyForms as $form ) {
$this->acceptances->insert(
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* The account lifecycle for a self-signup student, expressed entirely as user
* meta so it lives alongside the WordPress user and needs no extra table.
*
* States (see {@see docs/features/account-registration.md}):
* - Email unconfirmed `us_awaiting_approval='1'`, no `us_email_confirmed`, a
* hashed confirmation token + expiry set. Login is blocked.
* - Confirmed, awaiting approval `us_awaiting_approval='1'`,
* `us_email_confirmed='1'`, token/expiry cleared. Login allowed but the
* booking capability is withheld.
* - Approved / active `us_awaiting_approval` deleted; a normal student.
*
* Invite- and admin-created students carry none of these metas, so they behave
* exactly as before.
*/
class RegistrationStatus {
public const META_AWAITING_APPROVAL = 'us_awaiting_approval';
public const META_EMAIL_CONFIRMED = 'us_email_confirmed';
public const META_CONFIRM_TOKEN = 'us_email_confirm_token';
public const META_CONFIRM_EXPIRES = 'us_email_confirm_expires';
/**
* Set on accounts created via a group invite link: confirming the email
* approves the account immediately instead of queueing it for admin review.
*/
public const META_AUTO_APPROVE = 'us_auto_approve';
/**
* Hours a self-signup email-confirmation link stays valid after the account
* is created. Limits the window in which a leaked link can be redeemed.
*/
public const EMAIL_CONFIRM_EXPIRY_HOURS = 48;
/**
* Hash a raw confirmation token for storage and lookup. Only the hash is
* persisted (mirrors {@see Invite::hashToken()}), so a database leak cannot
* be used to confirm an account the raw token exists only in the email.
*/
public static function hashToken( string $rawToken ): string {
return hash( 'sha256', $rawToken );
}
/**
* Put a freshly created user into the pending state and issue an email
* confirmation token. Returns the raw token to embed in the emailed link.
* With `$autoApprove` (group invite links) confirming the email approves
* the account immediately no admin review step.
*/
public static function markPending( int $userId, bool $autoApprove = false ): string {
$rawToken = wp_generate_password( 32, false );
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
update_user_meta( $userId, self::META_CONFIRM_TOKEN, self::hashToken( $rawToken ) );
update_user_meta(
$userId,
self::META_CONFIRM_EXPIRES,
gmdate( 'Y-m-d H:i:s', time() + self::EMAIL_CONFIRM_EXPIRY_HOURS * 3600 )
);
if ( $autoApprove ) {
update_user_meta( $userId, self::META_AUTO_APPROVE, '1' );
}
return $rawToken;
}
/**
* Mark the account's email confirmed and discard the (now spent) token. The
* account stays awaiting approval.
*/
public static function confirmEmail( int $userId ): void {
update_user_meta( $userId, self::META_EMAIL_CONFIRMED, '1' );
delete_user_meta( $userId, self::META_CONFIRM_TOKEN );
delete_user_meta( $userId, self::META_CONFIRM_EXPIRES );
}
/**
* Approve the account: clear the pending flag and any leftover token so the
* student becomes a normal, active student.
*/
public static function approve( int $userId ): void {
delete_user_meta( $userId, self::META_AWAITING_APPROVAL );
delete_user_meta( $userId, self::META_CONFIRM_TOKEN );
delete_user_meta( $userId, self::META_CONFIRM_EXPIRES );
delete_user_meta( $userId, self::META_AUTO_APPROVE );
}
public static function isAwaitingApproval( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_AWAITING_APPROVAL, true ) );
}
/**
* Whether confirming this account's email should approve it immediately
* (group invite link signups).
*/
public static function isAutoApprove( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_AUTO_APPROVE, true ) );
}
public static function emailConfirmed( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_EMAIL_CONFIRMED, true ) );
}
/**
* Find the user awaiting confirmation whose stored hash matches the supplied
* raw token, or null when none matches.
*/
public static function userIdForToken( string $rawToken ): ?int {
if ( '' === $rawToken ) {
return null;
}
$users = get_users(
[
'meta_key' => self::META_CONFIRM_TOKEN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => self::hashToken( $rawToken ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'number' => 1,
'fields' => 'ID',
]
);
if ( [] === $users ) {
return null;
}
return Val::int( $users[0] );
}
/**
* Whether the confirmation token for a user has passed its expiry, measured
* against the supplied `Y-m-d H:i:s` (UTC) timestamp. A user with no stored
* expiry is treated as expired (there is nothing valid to confirm).
*/
public static function isTokenExpired( int $userId, string $now ): bool {
$expires = Val::string( get_user_meta( $userId, self::META_CONFIRM_EXPIRES, true ) );
if ( '' === $expires ) {
return true;
}
$expiresTs = strtotime( $expires );
$nowTs = strtotime( $now );
if ( false === $expiresTs || false === $nowTs ) {
return true;
}
return $nowTs > $expiresTs;
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
/**
* Studio-admin actions on a single student from the student detail view:
* cancelling a lesson, withdrawing a group-class enrolment, and editing basic
* account details. Mutations go through the same paths as the student-facing
* flows so slot release and pending-payment voiding stay consistent.
*/
class StudentActions {
public function __construct(
private BookingRepository $bookings,
private AvailabilityRepository $availability,
private EnrollmentRepository $enrollments,
private PaymentService $payments,
) {}
/**
* Cancel a lesson on the student's behalf: marks it cancelled, frees the
* slot for rebooking, and voids a still-pending payment. A paid lesson is
* credited back to the student's account (a per-lesson share of what they
* paid) to offset their future scheduled billing.
*/
public function cancelLesson( int $lessonId, int $studentId ): bool {
$lesson = $this->bookings->findById( $lessonId );
if ( null === $lesson || $lesson->studentId !== $studentId || Lesson::STATUS_CANCELLED === $lesson->status ) {
return false;
}
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CANCELLED );
$this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId );
$this->payments->creditForCancelledLesson( $lesson );
return true;
}
/**
* Withdraw the student from a group class: marks the active enrolment
* cancelled (freeing its capacity seat) and voids a still-pending payment.
*/
public function withdrawEnrollment( int $enrollmentId, int $studentId ): bool {
$enrollment = $this->enrollments->findById( $enrollmentId );
if ( null === $enrollment || $enrollment->studentId !== $studentId || Enrollment::STATUS_ACTIVE !== $enrollment->status ) {
return false;
}
$this->enrollments->updateStatus( $enrollmentId, Enrollment::STATUS_CANCELLED );
$this->payments->voidPending( $enrollment->paymentId );
return true;
}
/**
* Update the student's display name and email. The email must be valid and
* not belong to another user.
*/
public function updateAccount( int $studentId, string $displayName, string $email ): bool|\WP_Error {
if ( '' === $displayName ) {
return new \WP_Error( 'empty_name', __( 'Display name cannot be empty.', 'unsupervised-schedular' ) );
}
if ( ! is_email( $email ) ) {
return new \WP_Error( 'invalid_email', __( 'Please enter a valid email address.', 'unsupervised-schedular' ) );
}
$existing = email_exists( $email );
if ( false !== $existing && (int) $existing !== $studentId ) {
return new \WP_Error( 'email_taken', __( 'Another account already uses this email address.', 'unsupervised-schedular' ) );
}
$result = wp_update_user(
[
'ID' => $studentId,
'display_name' => $displayName,
'user_email' => $email,
]
);
return $result instanceof \WP_Error ? $result : true;
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* Keeps front-end-only users (students) out of wp-admin entirely.
*
* Students authenticate through the front-end login shortcode and do all of
* their work booking, viewing lessons, paying on the site's public pages.
* They have no reason to see the WordPress dashboard, profile screen, or admin
* bar, so this guard redirects them to the front end if they reach wp-admin and
* hides the admin bar for them everywhere.
*
* Access is decided by capability, not role: anyone holding a back-office
* capability (a WordPress administrator, studio admin, or instructor) keeps full
* wp-admin access, while a user with none of them is treated as front-end only.
*/
class StudentAdminGuard {
/**
* Capabilities that grant a genuine reason to be in wp-admin. A user holding
* none of these is front-end only and is kept out of the dashboard.
*
* @var list<string>
*/
private const BACK_OFFICE_CAPS = [
'manage_options',
RoleManager::CAP_MANAGE_INSTRUCTORS,
RoleManager::CAP_MANAGE_STUDENTS,
RoleManager::CAP_MANAGE_OFFERINGS,
RoleManager::CAP_MANAGE_QUESTIONS,
RoleManager::CAP_MANAGE_POLICIES,
RoleManager::CAP_MANAGE_BILLING,
RoleManager::CAP_MANAGE_AVAILABILITY,
RoleManager::CAP_VIEW_ALL_LESSONS,
RoleManager::CAP_VIEW_ALL_PAYMENTS,
RoleManager::CAP_VIEW_OWN_PAYMENTS,
RoleManager::CAP_EXPORT_PAYMENTS,
];
public function register(): void {
add_action( 'admin_init', [ $this, 'redirectFromDashboard' ] );
add_filter( 'show_admin_bar', [ $this, 'hideAdminBar' ] );
}
/**
* Redirect a front-end-only user away from any wp-admin page to the site
* home, so the dashboard and profile screens are never reachable.
*/
public function redirectFromDashboard(): void {
if ( ! $this->shouldBlockAdminAccess() ) {
return;
}
wp_safe_redirect( home_url( '/' ) );
exit;
}
/**
* Whether the current request into wp-admin should be bounced to the front
* end. AJAX requests are always allowed through so front-end features that
* call admin-ajax keep working.
*/
public function shouldBlockAdminAccess(): bool {
if ( wp_doing_ajax() ) {
return false;
}
if ( ! is_user_logged_in() ) {
return false;
}
return ! $this->hasBackOfficeAccess();
}
/**
* Hide the admin bar for front-end-only users; leave it untouched for anyone
* with back-office access.
*
* @param bool $show Whether WordPress would otherwise show the admin bar.
*/
public function hideAdminBar( bool $show ): bool {
if ( is_user_logged_in() && ! $this->hasBackOfficeAccess() ) {
return false;
}
return $show;
}
/**
* Whether the current user holds any capability that warrants wp-admin access.
*/
private function hasBackOfficeAccess(): bool {
foreach ( self::BACK_OFFICE_CAPS as $cap ) {
if ( current_user_can( $cap ) ) {
return true;
}
}
return false;
}
}
+72 -4
View File
@@ -11,6 +11,7 @@ use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Val;
class StudentController {
@@ -20,6 +21,8 @@ class StudentController {
private OfferingRepository $offerings,
private EnrollmentRepository $enrollments,
private BillingMethodResolver $resolver,
private StudentHistory $history,
private StudentActions $actions,
) {}
public function renderPage(): void {
@@ -28,7 +31,7 @@ class StudentController {
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only student selector.
$studentId = absint( $_GET['student_id'] ?? 0 );
$studentId = absint( Val::int( $_GET['student_id'] ?? 0 ) );
$student = $studentId > 0 ? get_userdata( $studentId ) : false;
if ( $student && in_array( RoleManager::STUDENT, (array) $student->roles, true ) ) {
@@ -45,12 +48,15 @@ class StudentController {
'upcoming' => $this->bookings->countUpcomingForStudent( (int) $user->ID ),
'enrolments' => $this->enrollments->countActiveForStudent( (int) $user->ID ),
],
array_filter(
get_users(
[
'role' => RoleManager::STUDENT,
'orderby' => 'display_name',
'order' => 'ASC',
]
),
static fn( mixed $user ): bool => $user instanceof \WP_User
)
);
@@ -61,9 +67,15 @@ class StudentController {
private function renderDetail( \WP_User $student ): void {
$canBilling = current_user_can( RoleManager::CAP_MANAGE_BILLING );
if ( $canBilling && isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_student_billing' ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- routing only; each action below verifies its own nonce.
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
$notice = '';
$error = '';
if ( $canBilling && 'set_billing' === $action && check_admin_referer( 'usc_student_billing' ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
$method = sanitize_key( wp_unslash( $_POST['payment_method'] ?? '' ) );
$method = sanitize_key( Val::string( wp_unslash( $_POST['payment_method'] ?? '' ) ) );
if ( in_array( $method, Payment::VALID_METHODS, true ) ) {
update_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, $method );
} else {
@@ -71,7 +83,43 @@ class StudentController {
}
}
$billingOverride = (string) get_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, true );
if ( 'update_account' === $action && check_admin_referer( 'usc_student_actions' ) ) {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
$email = sanitize_email( Val::string( wp_unslash( $_POST['user_email'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
$result = $this->actions->updateAccount( (int) $student->ID, $displayName, $email );
if ( $result instanceof \WP_Error ) {
$error = $result->get_error_message();
} else {
$notice = __( 'Account details updated.', 'unsupervised-schedular' );
$fresh = get_userdata( (int) $student->ID );
$student = $fresh instanceof \WP_User ? $fresh : $student;
}
}
if ( 'cancel_lesson' === $action && check_admin_referer( 'usc_student_actions' ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
$lessonId = absint( Val::int( $_POST['lesson_id'] ?? 0 ) );
if ( $this->actions->cancelLesson( $lessonId, (int) $student->ID ) ) {
$notice = __( 'Lesson cancelled.', 'unsupervised-schedular' );
} else {
$error = __( 'This lesson could not be cancelled.', 'unsupervised-schedular' );
}
}
if ( 'withdraw_enrollment' === $action && check_admin_referer( 'usc_student_actions' ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
$enrollmentId = absint( Val::int( $_POST['enrollment_id'] ?? 0 ) );
if ( $this->actions->withdrawEnrollment( $enrollmentId, (int) $student->ID ) ) {
$notice = __( 'Enrolment withdrawn.', 'unsupervised-schedular' );
} else {
$error = __( 'This enrolment could not be withdrawn.', 'unsupervised-schedular' );
}
}
$billingOverride = Val::string( get_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, true ) );
$billingDefault = $this->resolver->defaultMethod();
$now = current_time( 'mysql' );
@@ -89,6 +137,7 @@ class StudentController {
$offering = $this->offerings->findById( $enrollment->offeringId );
return [
'id' => (int) $enrollment->id,
'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
'status' => $enrollment->status,
];
@@ -96,10 +145,28 @@ class StudentController {
$this->enrollments->findByStudent( (int) $student->ID )
);
$acceptances = $this->history->policyAcceptances( (int) $student->ID );
$registrationInfo = $this->history->registrationInfo( (int) $student->ID );
$intake = $this->history->intakeAnswers( (int) $student->ID );
$payments = $canBilling ? $this->history->payments( (int) $student->ID ) : [];
$credits = $canBilling ? $this->history->credits( (int) $student->ID ) : [];
$creditBalance = $canBilling ? $this->history->creditBalance( (int) $student->ID ) : 0.0;
$creditCurrency = $this->creditCurrency( $credits );
$backUrl = admin_url( 'admin.php?page=us-students' );
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
}
/**
* Currency to label the credit balance with taken from the student's credits
* (they share a currency in practice), defaulting to CAD when they have none.
*
* @param list<array{created_at: string, amount: float, remaining: float, currency: string, reason: string, status: string}> $credits
*/
private function creditCurrency( array $credits ): string {
return [] !== $credits ? (string) $credits[0]['currency'] : 'CAD';
}
/**
* Build a display row for a lesson (slot time, offering, instructor, status).
*
@@ -111,6 +178,7 @@ class StudentController {
$instructor = get_userdata( $lesson->instructorId );
return [
'id' => (int) $lesson->id,
'start_dt' => $slot ? $slot->startDt : '',
'end_dt' => $slot ? $slot->endDt : '',
'offering' => $offering ? $offering->title : '—',
+180
View File
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Payment\Credit;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
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\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
/**
* Builds the display rows for the history sections of the admin student detail
* view: policy acceptances, intake answers, and payments.
*/
class StudentHistory {
public function __construct(
private AcceptanceRepository $acceptances,
private PolicyRepository $policies,
private PolicyVersionRepository $policyVersions,
private AnswerRepository $answers,
private QuestionRepository $questions,
private PaymentRepository $payments,
private CreditRepository $credits,
) {}
/**
* Every policy acceptance the student has recorded, newest first.
*
* @return list<array{policy: string, version: string, context: string, accepted_at: string}>
*/
public function policyAcceptances( int $studentId ): array {
return array_map(
function ( PolicyAcceptance $acceptance ): array {
$version = $this->policyVersions->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 ) : '—',
'context' => $this->contextLabel( $acceptance->registrationType, $acceptance->registrationId ),
'accepted_at' => $acceptance->acceptedAt ?? '',
];
},
$this->acceptances->findByStudent( $studentId )
);
}
/**
* Booking/enrolment intake answers the student has submitted, newest first.
* Account-signup answers are excluded those are shown on their own under
* {@see registrationInfo()}.
*
* @return list<array{question: string, answer: string, context: string}>
*/
public function intakeAnswers( int $studentId ): array {
$bookingAnswers = array_filter(
$this->answers->findByStudent( $studentId ),
static fn( Answer $answer ): bool => Answer::REG_ACCOUNT !== $answer->registrationType
);
return array_values(
array_map(
function ( Answer $answer ): array {
$question = $this->questions->findById( $answer->questionId );
return [
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
'answer' => $answer->answerValue ?? '—',
'context' => $this->contextLabel( $answer->registrationType, $answer->registrationId ),
];
},
$bookingAnswers
)
);
}
/**
* The student's answers to the studio-wide account-signup questions: every
* configured account question paired with the student's answer ("" when
* unanswered, e.g. a question added after they registered).
*
* @return list<array{question: string, answer: string, required: bool}>
*/
public function registrationInfo( int $studentId ): array {
$byQuestion = [];
foreach ( $this->answers->findByRegistration( Answer::REG_ACCOUNT, $studentId ) as $answer ) {
$byQuestion[ $answer->questionId ] = $answer->answerValue ?? '';
}
return array_map(
static function ( Question $question ) use ( $byQuestion ): array {
$value = $byQuestion[ (int) $question->id ] ?? '';
return [
'question' => $question->label,
'answer' => '' === $value ? '—' : $value,
'required' => $question->isRequired,
];
},
$this->questions->findByScope( Question::SCOPE_ACCOUNT )
);
}
/**
* Every payment for the student, newest first.
*
* @return list<array{created_at: string, context: string, method: string, status: string, amount: float, tax_amount: float, total: float, currency: string, receipt: string}>
*/
public function payments( int $studentId ): array {
return array_map(
fn( Payment $payment ): array => [
'created_at' => $payment->createdAt ?? '',
'context' => $this->contextLabel( $payment->registrationType, $payment->registrationId ),
'method' => $payment->method,
'status' => $payment->status,
'amount' => $payment->amount,
'tax_amount' => $payment->taxAmount,
'total' => $payment->total(),
'currency' => $payment->currency,
'receipt' => $payment->receiptNumber ?? '—',
],
$this->payments->findByStudent( $studentId )
);
}
/**
* The student's total unused credit balance (from cancelled paid lessons),
* applied automatically against future scheduled-billing charges.
*/
public function creditBalance( int $studentId ): float {
return $this->credits->availableBalance( $studentId );
}
/**
* Every credit the student has been issued, newest first, with the amount, what
* remains, and its state.
*
* @return list<array{created_at: string, amount: float, remaining: float, currency: string, reason: string, status: string}>
*/
public function credits( int $studentId ): array {
return array_map(
static fn( Credit $credit ): array => [
'created_at' => $credit->createdAt ?? '',
'amount' => $credit->amount,
'remaining' => $credit->remaining,
'currency' => $credit->currency,
'reason' => $credit->reason ?? '—',
'status' => $credit->status,
],
$this->credits->findByStudent( $studentId )
);
}
/**
* Human label for a polymorphic registration target.
*/
private function contextLabel( string $registrationType, int $registrationId ): string {
switch ( $registrationType ) {
case PolicyAcceptance::REG_ACCOUNT:
return __( 'Account signup', 'unsupervised-schedular' );
case PolicyAcceptance::REG_LESSON:
/* translators: %d: the lesson id */
return sprintf( __( 'Lesson #%d', 'unsupervised-schedular' ), $registrationId );
case PolicyAcceptance::REG_ENROLLMENT:
/* translators: %d: the group-class enrolment id */
return sprintf( __( 'Enrolment #%d', 'unsupervised-schedular' ), $registrationId );
default:
return sprintf( '%s #%d', $registrationType, $registrationId );
}
}
}
+7 -5
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Pure helper for splitting a student's dated rows into upcoming and past.
*/
@@ -21,7 +23,7 @@ class StudentSchedule {
$past = [];
foreach ( $rows as $row ) {
$start = (string) ( $row['start_dt'] ?? '' );
$start = Val::string( $row['start_dt'] ?? '' );
if ( '' !== $start && $start >= $now ) {
$upcoming[] = $row;
} else {
@@ -29,12 +31,12 @@ class StudentSchedule {
}
}
usort( $upcoming, static fn( array $a, array $b ): int => strcmp( (string) ( $a['start_dt'] ?? '' ), (string) ( $b['start_dt'] ?? '' ) ) );
usort( $past, static fn( array $a, array $b ): int => strcmp( (string) ( $b['start_dt'] ?? '' ), (string) ( $a['start_dt'] ?? '' ) ) );
usort( $upcoming, static fn( array $a, array $b ): int => strcmp( Val::string( $a['start_dt'] ?? '' ), Val::string( $b['start_dt'] ?? '' ) ) );
usort( $past, static fn( array $a, array $b ): int => strcmp( Val::string( $b['start_dt'] ?? '' ), Val::string( $a['start_dt'] ?? '' ) ) );
return [
'upcoming' => array_values( $upcoming ),
'past' => array_values( $past ),
'upcoming' => $upcoming,
'past' => $past,
];
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* Resolves a person's public-facing name for display. Prefers their real name
* (first + last), then their nickname deliberately avoiding the account's
* login/username, which `display_name` can otherwise expose.
*/
class UserName {
/**
* The display name for a user: "First Last" when a real name is set,
* otherwise the WordPress nickname. Falls back to the numeric id (or an empty
* string when none is given) when the user cannot be loaded or has no name.
*/
public static function format( ?\WP_User $user, int $fallbackId = 0 ): string {
if ( ! $user instanceof \WP_User ) {
return $fallbackId > 0 ? (string) $fallbackId : '';
}
$full = trim( $user->first_name . ' ' . $user->last_name );
if ( '' !== $full ) {
return $full;
}
$nickname = trim( $user->nickname );
if ( '' !== $nickname ) {
return $nickname;
}
return $fallbackId > 0 ? (string) $fallbackId : '';
}
}
+52 -20
View File
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
class AvailabilityController {
@@ -28,43 +29,76 @@ class AvailabilityController {
$slots = $this->repository->findByInstructor( $instructorId );
$offeringChoices = $this->offerings->findAll( $instructorId, Offering::KIND_PRIVATE_LESSON, true );
// View-state query params only (which view, which week) — nothing is
// mutated from them, so no nonce applies.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$view = 'list' === sanitize_key( Val::string( wp_unslash( $_GET['usc_view'] ?? '' ) ) ) ? 'list' : 'week';
$requestedWeek = sanitize_text_field( Val::string( wp_unslash( $_GET['usc_week'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$weekStart = WeekCalendar::weekStart( $requestedWeek, Val::int( get_option( 'start_of_week', 1 ) ), current_time( 'Y-m-d' ) );
$weekDays = WeekCalendar::days( $weekStart, $slots );
$prevWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '-7 days' )->format( 'Y-m-d' );
$nextWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '+7 days' )->format( 'Y-m-d' );
include USC_PLUGIN_DIR . 'templates/admin/availability.php';
}
private function handleFormAction( int $instructorId ): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) {
$this->addSlot( $instructorId );
}
if ( 'delete' === $action ) {
$slotId = absint( $_POST['slot_id'] ?? 0 );
if ( $slotId > 0 ) {
$slot = $this->repository->findById( $slotId );
if ( $slot && $slot->instructorId === $instructorId ) {
$this->repository->delete( $slotId );
$this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId );
}
if ( 'bulk_delete' === $action ) {
// The array itself carries no data; each element is coerced and
// absint-sanitized individually below.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
$rawIds = $_POST['slot_ids'] ?? [];
foreach ( is_array( $rawIds ) ? $rawIds : [] as $rawId ) {
$this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId );
}
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
private function addSlot( int $instructorId ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$startDt = sanitize_text_field( wp_unslash( $_POST['start_dt'] ?? '' ) );
$endDt = sanitize_text_field( wp_unslash( $_POST['end_dt'] ?? '' ) );
if ( '' === $startDt || '' === $endDt ) {
/**
* Delete a slot only when it exists and belongs to the given instructor.
* The repository additionally refuses to delete booked slots.
*/
private function deleteOwnSlot( int $slotId, int $instructorId ): void {
if ( $slotId <= 0 ) {
return;
}
$offeringId = absint( $_POST['offering_id'] ?? 0 );
$duration = absint( $_POST['duration_minutes'] ?? 0 );
$slot = $this->repository->findById( $slotId );
if ( $slot && $slot->instructorId === $instructorId ) {
$this->repository->delete( $slotId );
}
}
$slot = new AvailabilitySlot(
private function addSlot( int $instructorId ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ) );
$endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ) );
// A window must start and end on the same day (weekly repeat covers longer
// ranges) and fit at least one lesson; it is stored as lesson-length slots.
if ( null === $startDt || null === $endDt || $endDt <= $startDt || substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
return;
}
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
$window = new AvailabilitySlot(
instructorId: $instructorId,
startDt: $startDt,
endDt: $endDt,
@@ -72,12 +106,10 @@ class AvailabilityController {
offeringId: $offeringId > 0 ? $offeringId : null,
);
if ( 'weekly' === sanitize_key( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ) {
$this->repository->createWeeklySeries( $slot, absint( $_POST['weeks'] ?? 1 ) );
return;
}
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) );
$weeks = absint( Val::int( $_POST['weeks'] ?? 1 ) );
$this->repository->insert( $slot );
$this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks );
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
}
+37 -18
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
class AvailabilityEndpoint {
@@ -13,6 +14,11 @@ class AvailabilityEndpoint {
private OfferingRepository $offerings,
) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void {
register_rest_route(
$route_namespace,
@@ -96,11 +102,11 @@ class AvailabilityEndpoint {
public function index( \WP_REST_Request $request ): \WP_REST_Response {
$slots = $this->repository->findAvailable(
(int) $request->get_param( 'instructor_id' ),
(int) $request->get_param( 'offering_id' ),
(int) $request->get_param( 'duration_minutes' ),
(string) $request->get_param( 'from' ),
(string) $request->get_param( 'to' ),
Val::int( $request->get_param( 'instructor_id' ) ),
Val::int( $request->get_param( 'offering_id' ) ),
Val::int( $request->get_param( 'duration_minutes' ) ),
Val::string( $request->get_param( 'from' ) ),
Val::string( $request->get_param( 'to' ) ),
);
return new \WP_REST_Response( array_map( fn( AvailabilitySlot $s ) => $s->toArray(), $slots ), 200 );
@@ -108,8 +114,8 @@ class AvailabilityEndpoint {
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$instructorId = get_current_user_id();
$offeringId = absint( $request->get_param( 'offering_id' ) );
$duration = absint( $request->get_param( 'duration_minutes' ) );
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$duration = absint( Val::int( $request->get_param( 'duration_minutes' ) ) );
// A slot may only be tied to an offering the instructor owns, so it can
// never inherit another instructor's price or payment routing at booking.
@@ -120,27 +126,40 @@ class AvailabilityEndpoint {
}
}
$slot = new AvailabilitySlot(
$startDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'start_dt' ) ) );
$endDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'end_dt' ) ) );
if ( null === $startDt || null === $endDt || $endDt <= $startDt ) {
return new \WP_Error( 'invalid_datetime', __( 'Provide a valid start and end, with the end after the start.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
return new \WP_Error( 'invalid_window', __( 'Availability must start and end on the same day. Use the weekly repeat to cover multiple weeks.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$window = new AvailabilitySlot(
instructorId: $instructorId,
startDt: (string) $request->get_param( 'start_dt' ),
endDt: (string) $request->get_param( 'end_dt' ),
startDt: $startDt,
endDt: $endDt,
durationMinutes: $duration > 0 ? $duration : 60,
offeringId: $offeringId > 0 ? $offeringId : null,
);
if ( 'weekly' === $request->get_param( 'recurrence' ) ) {
$ids = $this->repository->createWeeklySeries( $slot, absint( $request->get_param( 'weeks' ) ) );
if ( [] === $window->splitByDuration() ) {
return new \WP_Error( 'invalid_window', __( 'The availability window is shorter than the lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$ids = $this->repository->createFromWindow(
$window,
'weekly' === $request->get_param( 'recurrence' ),
absint( Val::int( $request->get_param( 'weeks' ) ) )
);
return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
}
$id = $this->repository->insert( $slot );
return new \WP_REST_Response( [ 'id' => $id ], 201 );
}
public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$slot = $this->repository->findById( $id );
if ( null === $slot ) {
+109 -9
View File
@@ -30,6 +30,26 @@ class AvailabilityRepository {
return $this->db->insert_id;
}
/**
* Persist an availability window as individually bookable lesson-length slots.
* The window is split into consecutive `duration_minutes` chunks; each chunk
* becomes its own row (and, when weekly, its own weekly series) so students can
* book any open lesson-length block within the window.
*
* @return list<int> Inserted slot IDs.
*/
public function createFromWindow( AvailabilitySlot $window, bool $weekly = false, int $weeks = 1 ): array {
$ids = [];
foreach ( $window->splitByDuration() as $slot ) {
$ids = $weekly
? array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) )
: [ ...$ids, $this->insert( $slot ) ];
}
return $ids;
}
/**
* Create a weekly-recurring series from a template slot. Each occurrence is a
* separate row one week apart, all sharing a `recurrence_group` (the id of the
@@ -87,8 +107,10 @@ class AvailabilityRepository {
* @return list<AvailabilitySlot>
*/
public function findAvailable( int $instructorId = 0, int $offeringId = 0, int $durationMinutes = 0, string $from = '', string $to = '' ): array {
$where = [ 'is_booked = 0' ];
$params = [];
// A slot whose start has passed can no longer be booked, so it is never
// "available" regardless of the requested range.
$where = [ 'is_booked = 0', 'start_dt >= %s' ];
$params = [ current_time( 'mysql' ) ];
if ( $instructorId > 0 ) {
$where[] = 'instructor_id = %d';
@@ -116,11 +138,11 @@ class AvailabilityRepository {
}
$whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC";
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY start_dt ASC";
$rows = $params
? $this->db->get_results( $this->db->prepare( $sql, $params ) )
: $this->db->get_results( $sql );
$rows = $this->db->get_results(
$this->db->prepare( $sql, array_merge( [ $this->table ], $params ) )
);
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
@@ -133,7 +155,8 @@ class AvailabilityRepository {
public function findByInstructor( int $instructorId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY start_dt ASC",
'SELECT * FROM %i WHERE instructor_id = %d ORDER BY start_dt ASC',
$this->table,
$instructorId
)
);
@@ -149,7 +172,8 @@ class AvailabilityRepository {
public function findUnbookedInGroup( int $recurrenceGroup ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE recurrence_group = %d AND is_booked = 0 ORDER BY start_dt ASC",
'SELECT * FROM %i WHERE recurrence_group = %d AND is_booked = 0 ORDER BY start_dt ASC',
$this->table,
$recurrenceGroup
)
);
@@ -157,9 +181,31 @@ class AvailabilityRepository {
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
/**
* An instructor's slots (booked and unbooked) that overlap a time window
* they share any time with the half-open interval [$start, $end). Used when a
* group class is scheduled to find the private-booking slots that collide with
* it, so open ones can be cleared and booked ones flagged as conflicts.
*
* @return list<AvailabilitySlot>
*/
public function findOverlapping( int $instructorId, string $start, string $end ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE instructor_id = %d AND start_dt < %s AND end_dt > %s ORDER BY start_dt ASC',
$this->table,
$instructorId,
$end,
$start
)
);
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
public function findById( int $id ): ?AvailabilitySlot {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? AvailabilitySlot::fromRow( $row ) : null;
@@ -187,6 +233,60 @@ class AvailabilityRepository {
return 1 === $updated;
}
/**
* Free a slot whose lesson was cancelled so the time can be booked again.
*/
public function release( int $id ): bool {
return false !== $this->db->update(
$this->table,
[ 'is_booked' => 0 ],
[ 'id' => $id ],
[ '%d' ],
[ '%d' ]
);
}
/**
* One-time upgrade for rows created before windows were split on save: a
* window stored as a single row (e.g. 09:0016:00 with 60-minute lessons)
* showed to students as one giant slot. Rewrites every unbooked same-day
* window longer than its lesson length as lesson-length rows: the original
* row is trimmed to the first chunk (keeping its id and any recurrence
* group), and the remaining chunks are inserted as one-off rows.
*/
public function splitOversizedWindows(): void {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i
WHERE is_booked = 0
AND DATE(start_dt) = DATE(end_dt)
AND TIMESTAMPDIFF(MINUTE, start_dt, end_dt) > duration_minutes',
$this->table
)
);
foreach ( $rows ?? [] as $row ) {
$window = AvailabilitySlot::fromRow( $row );
$chunks = $window->splitByDuration();
if ( [] === $chunks ) {
continue;
}
$this->db->update(
$this->table,
[ 'end_dt' => $chunks[0]->endDt ],
[ 'id' => $window->id ],
[ '%s' ],
[ '%d' ]
);
foreach ( array_slice( $chunks, 1 ) as $chunk ) {
$this->insert( $chunk );
}
}
}
/**
* Delete an unbooked slot. Returns false if the slot is already booked.
*/
+66 -9
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Val;
class AvailabilitySlot {
public function __construct(
@@ -16,16 +18,71 @@ class AvailabilitySlot {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
/**
* Normalise a submitted slot datetime to canonical `Y-m-d H:i:s`, or null when
* it is not a real datetime. Accepts the HTML `datetime-local` form
* (`Y-m-d\TH:i`, optionally with seconds) and the canonical form (optionally
* without seconds). Anything else including strings PHP would "helpfully"
* coerce is rejected so garbage never reaches the DATETIME column or throws
* inside the weekly-series date arithmetic.
*/
public static function normalizeDateTime( string $value ): ?string {
foreach ( [ 'Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i' ] as $format ) {
$dt = \DateTimeImmutable::createFromFormat( '!' . $format, $value );
if ( false !== $dt && $dt->format( $format ) === $value ) {
return $dt->format( 'Y-m-d H:i:s' );
}
}
return null;
}
/**
* Split this window into consecutive lesson-length slots: 09:0016:00 with
* 60-minute lessons yields seven bookable slots. A trailing remainder shorter
* than the lesson length is dropped, and an empty list is returned when the
* window cannot fit a single lesson.
*
* @return list<self>
*/
public function splitByDuration(): array {
if ( $this->durationMinutes <= 0 ) {
return [];
}
$end = new \DateTimeImmutable( $this->endDt );
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
$cursor = new \DateTimeImmutable( $this->startDt );
$chunkEnd = $cursor->add( $step );
$slots = [];
while ( $chunkEnd <= $end ) {
$slots[] = new self(
instructorId: $this->instructorId,
startDt: $cursor->format( 'Y-m-d H:i:s' ),
endDt: $chunkEnd->format( 'Y-m-d H:i:s' ),
durationMinutes: $this->durationMinutes,
offeringId: $this->offeringId,
);
$cursor = $chunkEnd;
$chunkEnd = $cursor->add( $step );
}
return $slots;
}
public static function fromRow( \stdClass $row ): self {
return new self(
instructorId: (int) $row->instructor_id,
startDt: $row->start_dt,
endDt: $row->end_dt,
durationMinutes: (int) $row->duration_minutes,
offeringId: null !== $row->offering_id ? (int) $row->offering_id : null,
isBooked: (bool) $row->is_booked,
recurrenceGroup: null !== $row->recurrence_group ? (int) $row->recurrence_group : null,
id: (int) $row->id,
instructorId: Val::int( $row->instructor_id ),
startDt: Val::string( $row->start_dt ),
endDt: Val::string( $row->end_dt ),
durationMinutes: Val::int( $row->duration_minutes ),
offeringId: Val::intOrNull( $row->offering_id ),
isBooked: Val::bool( $row->is_booked ),
recurrenceGroup: Val::intOrNull( $row->recurrence_group ),
id: Val::int( $row->id ),
);
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
/**
* Pure helpers for the weekly calendar views: resolving which week to show and
* bucketing slots into that week's seven days.
*/
class WeekCalendar {
/**
* Resolve a requested week anchor to the date of the first day of its week.
* `$requested` may be any date (`Y-m-d`) inside the wanted week; anything
* unparseable falls back to `$today`. `$startOfWeek` follows WordPress's
* `start_of_week` option (0 = Sunday 6 = Saturday).
*/
public static function weekStart( string $requested, int $startOfWeek, string $today ): string {
$anchor = self::parseDay( $requested ) ?? self::parseDay( $today ) ?? new \DateTimeImmutable( 'today' );
$shift = ( (int) $anchor->format( 'w' ) - $startOfWeek + 7 ) % 7;
return $anchor->modify( '-' . $shift . ' days' )->format( 'Y-m-d' );
}
/**
* Bucket slots into the seven days of the week starting at `$weekStart`
* (`Y-m-d`). Every day is present, empty or not, in calendar order.
*
* @param list<AvailabilitySlot> $slots
* @return list<array{date: string, slots: list<AvailabilitySlot>}>
*/
public static function days( string $weekStart, array $slots ): array {
$start = self::parseDay( $weekStart ) ?? new \DateTimeImmutable( 'today' );
$byDay = [];
foreach ( $slots as $slot ) {
$byDay[ substr( $slot->startDt, 0, 10 ) ][] = $slot;
}
$days = [];
for ( $i = 0; $i < 7; $i++ ) {
$date = $start->modify( '+' . $i . ' days' )->format( 'Y-m-d' );
$days[] = [
'date' => $date,
'slots' => $byDay[ $date ] ?? [],
];
}
return $days;
}
/**
* Bucket arbitrary items into the seven days of the week starting at
* `$weekStart` (`Y-m-d`), using `$dayOf` to extract each item's `Y-m-d` day.
* Every day is present, empty or not, in calendar order.
*
* @template T
* @param list<T> $items
* @param callable(T): string $dayOf
* @return list<array{date: string, items: list<T>}>
*/
public static function bucket( string $weekStart, array $items, callable $dayOf ): array {
$start = self::parseDay( $weekStart ) ?? new \DateTimeImmutable( 'today' );
$byDay = [];
foreach ( $items as $item ) {
$byDay[ $dayOf( $item ) ][] = $item;
}
$days = [];
for ( $i = 0; $i < 7; $i++ ) {
$date = $start->modify( '+' . $i . ' days' )->format( 'Y-m-d' );
$days[] = [
'date' => $date,
'items' => $byDay[ $date ] ?? [],
];
}
return $days;
}
private static function parseDay( string $value ): ?\DateTimeImmutable {
$day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
return false !== $day && $day->format( 'Y-m-d' ) === $value ? $day : null;
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular;
/**
* Static, script-free markup for the editor previews of the front-end blocks.
*
* The booking and group-class pages are populated by JavaScript on the live
* site, and the registration page requires a valid invite token none of
* which exist inside the block editor. These previews reproduce the same
* wrapper elements and CSS classes the live pages use, filled with
* representative placeholder content, so themes can be styled against
* realistic markup without firing REST calls, redirects, or Stripe.js.
*/
class BlockPreview {
/**
* Sample booking page.
*
* @param string $mode Which halves the block embeds one of
* {@see Booking\BookingPage::MODE_BOTH},
* `MODE_BOOKING` or `MODE_UPCOMING`. The preview shows
* the same sections the published page would.
*/
public static function booking( string $mode = Booking\BookingPage::MODE_BOTH ): string {
if ( Booking\BookingPage::MODE_UPCOMING === $mode ) {
return sprintf(
'<div id="us-booking-app">%s<div id="us-my-lessons">%s</div></div>',
self::note( __( 'Editor preview — students see their own lessons on the published page.', 'unsupervised-schedular' ) ),
self::upcomingLessons()
);
}
$days = [
[
'label' => __( 'Monday', 'unsupervised-schedular' ),
'slots' => [
[ '4:00 PM4:30 PM', 30 ],
[ '4:30 PM5:00 PM', 30 ],
],
],
[
'label' => __( 'Wednesday', 'unsupervised-schedular' ),
'slots' => [
[ '5:00 PM5:45 PM', 45 ],
],
],
];
$dayHtml = '';
foreach ( $days as $day ) {
$slotHtml = '';
foreach ( $day['slots'] as $slot ) {
$slotHtml .= sprintf(
'<div class="us-slot"><span>%s (%d min)</span><button type="button" class="us-book-btn" disabled>%s</button></div>',
esc_html( $slot[0] ),
(int) $slot[1],
esc_html__( 'Book', 'unsupervised-schedular' )
);
}
$dayHtml .= sprintf(
'<div class="us-day"><h3 class="us-day-heading">%s</h3>%s</div>',
esc_html( $day['label'] ),
$slotHtml
);
}
$lessons = Booking\BookingPage::MODE_BOOKING === $mode
? ''
: sprintf( '<div id="us-my-lessons">%s</div>', self::upcomingLessons() );
return sprintf(
'<div id="us-booking-app">%s%s<div id="us-slot-list">%s</div></div>',
self::note( __( 'Editor preview — students see live availability on the published page.', 'unsupervised-schedular' ) ),
$lessons,
$dayHtml
);
}
/**
* Sample "your upcoming lessons" panel, shared by the booking preview's
* full and upcoming-only modes.
*/
private static function upcomingLessons(): string {
return sprintf(
'<div class="us-my-lessons"><h3>%s</h3>'
. '<div class="us-my-lesson"><span class="us-my-lesson-info">'
. '<strong class="us-my-lesson-title">%s <span class="us-my-lesson-duration">(30 min)</span></strong>'
. '<span class="us-my-lesson-when">%s</span></span>'
. '<span class="us-my-lesson-actions">'
. '<span class="us-lesson-status us-lesson-status-confirmed">%s</span>'
. '<button type="button" class="us-cancel-lesson" disabled>%s</button>'
. '</span></div></div>',
esc_html__( 'Your upcoming lessons', 'unsupervised-schedular' ),
esc_html__( 'Piano Lesson', 'unsupervised-schedular' ),
esc_html__( 'Monday · 4:00 PM4:30 PM', 'unsupervised-schedular' ),
esc_html__( 'Confirmed', 'unsupervised-schedular' ),
esc_html__( 'Cancel', 'unsupervised-schedular' )
);
}
/**
* Sample group-class card.
*
* @param bool $singleClass Whether the block is pinned to one class, in
* which case the live page omits the class
* description and the preview does too.
*/
public static function groupClasses( bool $singleClass = false ): string {
$note = $singleClass
? __( 'Editor preview — the published page shows the chosen class with its live schedule and enrolment status.', 'unsupervised-schedular' )
: __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' );
$description = $singleClass
? ''
: '<p>' . esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ) . '</p>';
return sprintf(
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p class="us-class-when">%s</p>%s<p>25.00 CAD</p><p class="us-enrol-deadline">%s</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
self::note( $note ),
esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ),
esc_html__( 'Saturdays 10:00 AM11:00 AM', 'unsupervised-schedular' ),
$description,
esc_html__( 'Enrol by Sep 6, 2026', 'unsupervised-schedular' ),
esc_html__( 'Enrol', 'unsupervised-schedular' )
);
}
/**
* The live login form renders fine without any request state, so the
* preview includes the real template (the editing user is logged in, which
* would otherwise short-circuit to an "already logged in" message).
*/
public static function login(): string {
$error = '';
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/login-page.php';
return self::note( __( 'Editor preview — logged-in visitors are offered a link to the booking page instead.', 'unsupervised-schedular' ) ) . (string) ob_get_clean();
}
public static function registration(): string {
$fields = sprintf(
'<p><label for="us-reg-email">%s</label><input type="email" id="us-reg-email" value="[email protected]" readonly></p>',
esc_html__( 'Email', 'unsupervised-schedular' )
);
$fields .= sprintf(
'<p><label for="us-reg-name">%s</label><input type="text" id="us-reg-name"></p>',
esc_html__( 'Your name', 'unsupervised-schedular' )
);
$fields .= sprintf(
'<p><label for="us-reg-pass">%s</label><input type="password" id="us-reg-pass"></p>',
esc_html__( 'Password', 'unsupervised-schedular' )
);
$fields .= sprintf(
'<p><input type="submit" value="%s" disabled></p>',
esc_attr__( 'Create Account', 'unsupervised-schedular' )
);
return sprintf(
'<div class="us-register-form">%s<form>%s</form></div>',
self::note( __( 'Editor preview — the live form requires a valid invite link and lists signup policies.', 'unsupervised-schedular' ) ),
$fields
);
}
private static function note( string $text ): string {
return '<p class="us-editor-note">' . esc_html( $text ) . '</p>';
}
}
+336
View File
@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
/**
* Registers Gutenberg dynamic-block wrappers for the front-end shortcodes so
* the pages can be previewed and styled inside the block editor.
*
* On the front end each block delegates to the same page object its shortcode
* uses, so output is identical either way. Inside the editor (the
* block-renderer REST preview used by wp.serverSideRender) a static preview
* from BlockPreview is rendered instead same markup and CSS classes, no
* live REST calls, redirects, or Stripe.js.
*/
class BlockRegistrar {
public const SCRIPT_HANDLE = 'us-scheduler-blocks';
public const STYLE_HANDLE = 'us-scheduler';
public function __construct(
private BookingPage $bookingPage,
private LoginPage $loginPage,
private RegistrationPage $registrationPage,
private GroupClassPage $groupClassPage,
) {}
public function register(): void {
add_action( 'init', [ $this, 'registerBlocks' ] );
add_action( 'template_redirect', [ $this, 'maybeAutoRedirect' ] );
}
public function registerBlocks(): void {
// The editor script registers the client side of each block (title,
// icon, shortcode transform, inspector controls) and previews it via
// wp.serverSideRender.
wp_register_script(
self::SCRIPT_HANDLE,
USC_PLUGIN_URL . 'assets/js/blocks.js',
[ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-core-data', 'wp-server-side-render', 'wp-i18n', 'wp-api-fetch' ],
USC_VERSION,
true
);
// The front-end stylesheet doubles as the block style so editor
// previews look like the published page. ShortcodeRegistrar registers
// the same handle on the front end, hence the guard.
if ( ! wp_style_is( self::STYLE_HANDLE, 'registered' ) ) {
wp_register_style( self::STYLE_HANDLE, USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
}
foreach ( $this->blocks() as $name => $config ) {
register_block_type(
$name,
[
'api_version' => '3',
'editor_script' => self::SCRIPT_HANDLE,
'style' => self::STYLE_HANDLE,
'attributes' => $config['attributes'],
'render_callback' => $config['render'],
]
);
}
}
/**
* Block definitions: render callback plus the attribute schema. The
* schema must be declared server-side too, or the block-renderer preview
* endpoint rejects the attributes wp.serverSideRender sends.
*
* @return array<string, array{render: callable(array<string, mixed>=): string, attributes: array<string, array{type: string, default: mixed}>}>
*/
private function blocks(): array {
$redirectToggle = [
'type' => 'boolean',
'default' => false,
];
return [
'us-scheduler/booking' => [
'render' => [ $this, 'renderBooking' ],
'attributes' => [
'loginPageId' => [
'type' => 'number',
'default' => 0,
],
'autoRedirect' => $redirectToggle,
'lessonTypeId' => [
'type' => 'number',
'default' => 0,
],
'showTypeFilter' => [
'type' => 'boolean',
'default' => true,
],
'displayMode' => [
'type' => 'string',
'default' => BookingPage::MODE_BOTH,
],
],
],
'us-scheduler/student-login' => [
'render' => [ $this, 'renderLogin' ],
'attributes' => [
'bookingPageId' => [
'type' => 'number',
'default' => 0,
],
'autoRedirect' => $redirectToggle,
],
],
'us-scheduler/student-register' => [
'render' => [ $this, 'renderRegistration' ],
'attributes' => [
'loginPageId' => [
'type' => 'number',
'default' => 0,
],
'autoRedirect' => $redirectToggle,
'inviteOnlyMessage' => [
'type' => 'string',
'default' => '',
],
],
],
'us-scheduler/group-classes' => [
'render' => [ $this, 'renderGroupClasses' ],
'attributes' => [
'offeringId' => [
'type' => 'number',
'default' => 0,
],
],
],
];
}
/**
* Renders the booking block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderBooking( array $attributes = [] ): string {
if ( ! $this->isEditorPreview() ) {
return $this->bookingPage->render( $attributes );
}
return BlockPreview::booking( Val::string( $attributes['displayMode'] ?? BookingPage::MODE_BOTH ) );
}
/**
* Renders the student-login block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderLogin( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::login() : $this->loginPage->render( $attributes );
}
/**
* Renders the student-registration block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderRegistration( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::registration() : $this->registrationPage->render( $attributes );
}
/**
* Renders the group-classes block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderGroupClasses( array $attributes = [] ): string {
if ( ! $this->isEditorPreview() ) {
return $this->groupClassPage->render( $attributes );
}
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
}
/**
* Server-side auto-redirect for blocks that opt in via their autoRedirect
* attribute: logged-out visitors on a page containing the booking block
* are sent to its login page, logged-in visitors on a page containing the
* student-login block are sent to its booking page, and a student who has
* just finished registering is sent to the register block's chosen page.
* Hooked on `template_redirect` because block rendering happens after
* output has started, too late to send a Location header.
*/
public function maybeAutoRedirect(): void {
if ( is_admin() || ! is_singular() ) {
return;
}
$post = get_post();
if ( ! $post instanceof \WP_Post ) {
return;
}
if ( $this->maybeRedirectAfterRegistration( $post ) ) {
return;
}
if ( is_user_logged_in() ) {
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-login' );
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
return;
}
$bookingPageId = Val::int( $attrs['bookingPageId'] ?? 0 );
if ( $bookingPageId === $post->ID ) {
return; // Redirecting the page to itself would loop.
}
$url = $this->loginPage->bookingUrl( $bookingPageId );
if ( null !== $url ) {
$this->redirect( $url );
}
return;
}
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/booking' );
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
return;
}
$loginPageId = Val::int( $attrs['loginPageId'] ?? 0 );
if ( $loginPageId === $post->ID ) {
return; // Redirecting the page to itself would loop.
}
$this->redirect( $this->bookingPage->loginUrl( $loginPageId ) );
}
/**
* Sends a student whose registration has just completed to the register
* block's chosen page, when the block opts in. Only the finished states
* qualify (see {@see RegistrationPage::isRegistrationComplete()}): a
* failure or the "check your email" step stays put so its message is read.
* Unlike the other blocks there is no login-screen fallback with no page
* chosen there is nowhere to send them, so the link is shown instead.
*
* Returns whether the redirect was issued (it only ever returns in tests;
* {@see redirect()} exits in production).
*/
private function maybeRedirectAfterRegistration( \WP_Post $post ): bool {
// Checked before parsing the content because it is a couple of query
// args, whereas every front-end request would otherwise pay for a
// third block scan.
if ( ! $this->registrationPage->isRegistrationComplete() ) {
return false;
}
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-register' );
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
return false;
}
$pageId = Val::int( $attrs['loginPageId'] ?? 0 );
if ( $pageId === $post->ID ) {
return false; // Redirecting the page to itself would loop.
}
$url = $this->registrationPage->continueUrl( $pageId );
if ( null === $url ) {
return false;
}
$this->redirect( $url );
return true;
}
/**
* Attributes of the first occurrence of the named block in the content,
* searching inner blocks so blocks nested inside groups or columns are
* still found. Null when the block is absent. Attributes equal to their
* schema default are omitted from the serialized block, so callers must
* apply defaults themselves.
*
* @return array<mixed>|null
*/
private function firstBlockAttrs( string $content, string $blockName ): ?array {
if ( ! has_block( $blockName, $content ) ) {
return null;
}
$queue = parse_blocks( $content );
while ( [] !== $queue ) {
$block = array_shift( $queue );
if ( ! is_array( $block ) ) {
continue;
}
if ( ( $block['blockName'] ?? null ) === $blockName ) {
$attrs = $block['attrs'] ?? null;
return is_array( $attrs ) ? $attrs : [];
}
$inner = $block['innerBlocks'] ?? null;
if ( is_array( $inner ) && [] !== $inner ) {
$queue = array_merge( $queue, array_values( $inner ) );
}
}
return null;
}
/**
* Issues the redirect and stops the request. Split out so tests can
* observe redirects without the process exiting.
*/
protected function redirect( string $url ): void {
wp_safe_redirect( $url );
exit;
}
/**
* Whether this render is the editor's block-renderer REST preview rather
* than a real front-end page render. Front-end template rendering never
* happens inside a REST request, so REST_REQUEST is a reliable signal.
*/
protected function isEditorPreview(): bool {
return defined( 'REST_REQUEST' ) && (bool) constant( 'REST_REQUEST' );
}
}
+197 -18
View File
@@ -5,11 +5,13 @@ namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\RoleManager;
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;
use Unsupervised\Schedular\Val;
class BookingEndpoint {
@@ -25,8 +27,14 @@ class BookingEndpoint {
private OfferingRepository $offerings,
private RegistrationGate $gate,
private PaymentService $payments,
private CancellationPolicy $cancellationPolicy,
) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void {
register_rest_route(
$route_namespace,
@@ -73,6 +81,18 @@ class BookingEndpoint {
]
);
register_rest_route(
$route_namespace,
'/bookings/(?P<id>\d+)/cancel',
[
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'cancel' ],
'permission_callback' => [ $this, 'isLoggedIn' ],
],
]
);
register_rest_route(
$route_namespace,
'/bookings/(?P<id>\d+)/status',
@@ -97,13 +117,38 @@ class BookingEndpoint {
$userId = get_current_user_id();
$lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY )
? $this->bookings->findUpcomingForInstructor( $userId )
: $this->bookings->findByStudent( $userId );
: $this->bookings->findUpcomingForStudent( $userId );
return new \WP_REST_Response( array_map( fn( Lesson $l ) => $l->toArray(), $lessons ), 200 );
return new \WP_REST_Response( array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ), 200 );
}
/**
* A lesson's array form plus its slot's start/end times and the booked
* offering's name, so front-end lists can show what the session is and when
* it happens without a second request.
*
* @return array<string, mixed>
*/
private function lessonWithTimes( Lesson $lesson ): array {
$slot = $this->availability->findById( $lesson->slotId );
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
// Prefer the offering's own length; fall back to the slot's when the
// offering has none (a generic, duration-less type).
$duration = null !== $offering && null !== $offering->durationMinutes
? $offering->durationMinutes
: $slot?->durationMinutes;
return $lesson->toArray() + [
'start_dt' => $slot?->startDt,
'end_dt' => $slot?->endDt,
'offering_title' => $offering?->title,
'duration_minutes' => $duration,
];
}
public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$slotId = (int) $request->get_param( 'slot_id' );
$slotId = Val::int( $request->get_param( 'slot_id' ) );
$slot = $this->availability->findById( $slotId );
if ( null === $slot ) {
@@ -120,7 +165,7 @@ class BookingEndpoint {
// 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( $request->get_param( 'offering_id' ) );
$requestedOfferingId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$slotOfferingId = (int) ( $slot->offeringId ?? 0 );
if ( $slotOfferingId > 0 ) {
@@ -132,17 +177,37 @@ class BookingEndpoint {
$offeringId = $requestedOfferingId;
}
$offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
if ( $offeringId > 0 && null === $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 ( null !== $offering && $offering->instructorId !== $slot->instructorId ) {
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 ] );
}
}
$answers = $this->answers( $request );
$acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) );
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) );
$gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds );
if ( $gateError instanceof \WP_Error ) {
@@ -150,7 +215,7 @@ class BookingEndpoint {
}
$studentId = get_current_user_id();
$notes = (string) $request->get_param( 'notes' );
$notes = Val::string( $request->get_param( 'notes' ) );
$recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' )
? Lesson::RECURRENCE_WEEKLY
: Lesson::RECURRENCE_SINGLE;
@@ -159,7 +224,7 @@ class BookingEndpoint {
slotId: $slotId,
studentId: $studentId,
instructorId: $slot->instructorId,
offeringId: $offeringId > 0 ? $offeringId : null,
offeringId: $offeringId,
recurrence: $recurrence,
notes: '' !== $notes ? $notes : null,
);
@@ -191,14 +256,48 @@ class BookingEndpoint {
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
if ( null !== $offering && $offering->price > 0.0 ) {
$this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
$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 );
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;
}
// `payment: null` tells the front end to skip the payment step entirely.
return new \WP_REST_Response(
[
'ids' => $ids,
'status' => Lesson::STATUS_PENDING,
'status' => $status,
'payment' => $payment?->toSummaryArray(),
],
201
);
@@ -212,21 +311,89 @@ class BookingEndpoint {
private function answers( \WP_REST_Request $request ): array {
$out = [];
foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) {
$out[ (int) $questionId ] = sanitize_text_field( (string) $value );
$out[ (int) $questionId ] = sanitize_text_field( Val::string( $value ) );
}
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( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
return '' !== $ip ? $ip : null;
}
/**
* Student-initiated cancellation of their own lesson: marks it cancelled,
* frees the slot for rebooking, and voids any still-pending payment. A lesson
* already paid for is credited back to the student's account (a per-lesson
* share of the covering payment) to offset their future scheduled billing.
*/
public function cancel( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$lesson = $this->bookings->findById( $id );
if ( null === $lesson ) {
return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( get_current_user_id() !== $lesson->studentId ) {
return new \WP_Error( 'forbidden', __( 'You cannot cancel this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( Lesson::STATUS_CANCELLED !== $lesson->status ) {
$slot = $this->availability->findById( $lesson->slotId );
if ( null !== $slot ) {
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
$overrideHours = $offering?->cancellationCutoffHours;
if ( ! $this->cancellationPolicy->studentMayCancel( $slot->startDt, $overrideHours ) ) {
return new \WP_Error(
'cancellation_closed',
sprintf(
/* translators: %s: humanised cutoff window, e.g. "2 days" or "12 hours". */
__( 'This lesson can no longer be cancelled online — cancellations close %s before the lesson starts. Please contact the studio.', 'unsupervised-schedular' ),
$this->cancellationPolicy->describeCutoff( $this->cancellationPolicy->cutoffHours( $overrideHours ) )
),
[ 'status' => 403 ]
);
}
}
$this->bookings->updateStatus( $id, Lesson::STATUS_CANCELLED );
$this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId );
$this->payments->creditForCancelledLesson( $lesson );
}
return new \WP_REST_Response(
[
'id' => $id,
'status' => Lesson::STATUS_CANCELLED,
],
200
);
}
public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$lesson = $this->bookings->findById( $id );
if ( null === $lesson ) {
@@ -237,12 +404,24 @@ class BookingEndpoint {
return new \WP_Error( 'forbidden', __( 'You cannot update this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
$this->bookings->updateStatus( $id, (string) $request->get_param( 'status' ) );
$status = Val::string( $request->get_param( 'status' ) );
if ( Lesson::STATUS_CANCELLED === $status && Lesson::STATUS_CANCELLED !== $lesson->status ) {
$this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId );
$this->payments->creditForCancelledLesson( $lesson );
} elseif ( Lesson::STATUS_CANCELLED === $lesson->status && Lesson::STATUS_CANCELLED !== $status && ! $this->availability->claim( $lesson->slotId ) ) {
// Reinstating a cancelled lesson must re-reserve its slot, and
// someone else may have booked the freed time in the meantime.
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$this->bookings->updateStatus( $id, $status );
return new \WP_REST_Response(
[
'id' => $id,
'status' => $request->get_param( 'status' ),
'status' => $status,
],
200
);
+82 -4
View File
@@ -3,25 +3,53 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class BookingPage {
/** Booking calendar and the student's upcoming lessons (the default). */
public const MODE_BOTH = 'both';
/** Booking calendar only — no upcoming-lessons panel. */
public const MODE_BOOKING = 'booking';
/** The student's upcoming lessons only — nothing bookable. */
public const MODE_UPCOMING = 'upcoming';
/**
* Renders the booking shortcode output.
* Renders the booking shortcode/block output.
*
* @param array<string, string> $atts Shortcode attributes (unused reserved for future options).
* Supported attributes (block / shortcode form):
* - `loginPageId` / `login_page_id` where logged-out visitors are sent.
* - `lessonTypeId` / `lesson_type` a private-lesson offering id that pins
* the calendar to one lesson type: only the times bookable as that type
* are listed, and only it can be booked. 0 or absent shows every type.
* - `showTypeFilter` / `show_filter` whether the "Show Only" lesson-type
* filter is offered (default true; irrelevant when a type is pinned).
* - `displayMode` / `show` which halves of the page to embed:
* {@see self::MODE_BOTH} (default), {@see self::MODE_BOOKING} (calendar
* only) or {@see self::MODE_UPCOMING} (the student's lessons only).
*
* @param array<int|string, mixed> $atts Block or shortcode attributes.
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
public function render( array $atts ): string {
if ( ! is_user_logged_in() ) {
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
return sprintf(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url( wp_login_url( get_permalink() ) ),
esc_url( $this->loginUrl( $loginPageId ) ),
esc_html__( 'log in to book a lesson', 'unsupervised-schedular' )
);
}
if ( RegistrationStatus::isAwaitingApproval( get_current_user_id() ) ) {
return '<p>' . esc_html__( 'Your account is awaiting studio approval. You will be able to book once a studio admin approves it.', 'unsupervised-schedular' ) . '</p>';
}
if ( ! current_user_can( RoleManager::CAP_BOOK_LESSON ) ) {
return '<p>' . esc_html__( 'This page is for students only.', 'unsupervised-schedular' ) . '</p>';
}
@@ -29,8 +57,58 @@ class BookingPage {
wp_enqueue_style( 'us-scheduler' );
wp_enqueue_script( 'us-scheduler' );
$lessonTypeId = absint( Val::int( $atts['lessonTypeId'] ?? $atts['lesson_type'] ?? 0 ) );
$showTypeFilter = self::toBool( $atts['showTypeFilter'] ?? $atts['show_filter'] ?? true );
$mode = self::mode( $atts['displayMode'] ?? $atts['show'] ?? self::MODE_BOTH );
$showBooking = self::MODE_UPCOMING !== $mode;
$showUpcoming = self::MODE_BOOKING !== $mode;
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
return (string) ob_get_clean();
}
/**
* Normalises the display-mode attribute; anything unrecognised embeds the
* whole page, so a typo never silently hides half of it.
*/
private static function mode( mixed $value ): string {
$mode = strtolower( trim( Val::string( $value ) ) );
return in_array( $mode, [ self::MODE_BOOKING, self::MODE_UPCOMING ], true ) ? $mode : self::MODE_BOTH;
}
/**
* Reads a boolean attribute. Block attributes arrive as real booleans,
* shortcode attributes as strings where the words people actually write
* for "off" ("no", "false", "off") are all truthy to PHP, so they are
* matched explicitly rather than cast.
*/
private static function toBool( mixed $value ): bool {
if ( is_string( $value ) ) {
return ! in_array( strtolower( trim( $value ) ), [ '', '0', 'no', 'false', 'off' ], true );
}
return Val::bool( $value );
}
/**
* URL the logged-out prompt sends visitors to: the chosen login page when
* one is configured (and still exists), otherwise the WordPress login
* screen with a redirect back to the current page.
*/
public function loginUrl( int $loginPageId ): string {
if ( $loginPageId > 0 ) {
$url = get_permalink( $loginPageId );
if ( is_string( $url ) ) {
return $url;
}
}
$permalink = get_permalink();
return wp_login_url( false === $permalink ? '' : $permalink );
}
}
+135 -11
View File
@@ -80,7 +80,7 @@ class BookingRepository {
public function findById( int $id ): ?Lesson {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Lesson::fromRow( $row ) : null;
@@ -96,12 +96,14 @@ class BookingRepository {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT l.* FROM {$this->table} l
JOIN {$avTable} a ON a.id = l.slot_id
'SELECT l.* FROM %i l
JOIN %i a ON a.id = l.slot_id
WHERE l.instructor_id = %d
AND l.status != %s
AND a.start_dt >= %s
ORDER BY a.start_dt ASC",
ORDER BY a.start_dt ASC',
$this->table,
$avTable,
$instructorId,
Lesson::STATUS_CANCELLED,
current_time( 'mysql' )
@@ -111,6 +113,33 @@ class BookingRepository {
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
}
/**
* Upcoming lessons for a student (status != cancelled, slot in the future).
*
* @return list<Lesson>
*/
public function findUpcomingForStudent( int $studentId ): array {
$avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT l.* FROM %i l
JOIN %i a ON a.id = l.slot_id
WHERE l.student_id = %d
AND l.status != %s
AND a.start_dt >= %s
ORDER BY a.start_dt ASC',
$this->table,
$avTable,
$studentId,
Lesson::STATUS_CANCELLED,
current_time( 'mysql' )
)
);
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
}
/**
* Count a student's upcoming, non-cancelled lessons (slot in the future).
*/
@@ -119,11 +148,13 @@ class BookingRepository {
return (int) $this->db->get_var(
$this->db->prepare(
"SELECT COUNT(*) FROM {$this->table} l
JOIN {$avTable} a ON a.id = l.slot_id
'SELECT COUNT(*) FROM %i l
JOIN %i a ON a.id = l.slot_id
WHERE l.student_id = %d
AND l.status != %s
AND a.start_dt >= %s",
AND a.start_dt >= %s',
$this->table,
$avTable,
$studentId,
Lesson::STATUS_CANCELLED,
current_time( 'mysql' )
@@ -139,7 +170,8 @@ class BookingRepository {
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY created_at DESC",
'SELECT * FROM %i WHERE student_id = %d ORDER BY created_at DESC',
$this->table,
$studentId
)
);
@@ -157,11 +189,13 @@ class BookingRepository {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT l.* FROM {$this->table} l
JOIN {$avTable} a ON a.id = l.slot_id
'SELECT l.* FROM %i l
JOIN %i a ON a.id = l.slot_id
WHERE l.status != %s
AND a.start_dt >= %s
ORDER BY a.start_dt ASC",
ORDER BY a.start_dt ASC',
$this->table,
$avTable,
Lesson::STATUS_CANCELLED,
current_time( 'mysql' )
)
@@ -170,6 +204,76 @@ class BookingRepository {
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
}
/**
* Not-yet-billed lessons on a scheduled-billing (weekly / monthly) offering:
* status not cancelled and no payment attached yet. Each row carries the slot
* start time and the offering's billing fields so the daily billing scan can
* decide what is due without a second query per lesson. Ordered by student,
* offering and time so the scan can group a student's monthly lessons cheaply.
*
* @return list<\stdClass> Rows: id, student_id, instructor_id, offering_id,
* start_dt, billing_mode, title, price, currency,
* etransfer_email.
*/
public function findUnbilledScheduledLessons(): array {
$avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
$offTable = str_replace( 'us_lessons', 'us_offerings', $this->table );
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT l.id, l.student_id, l.instructor_id, l.offering_id,
a.start_dt,
o.billing_mode, o.title, o.price, o.currency, o.etransfer_email
FROM %i l
JOIN %i a ON a.id = l.slot_id
JOIN %i o ON o.id = l.offering_id
WHERE l.status != %s
AND l.payment_id IS NULL
AND o.billing_mode IN ( %s, %s )
ORDER BY l.student_id ASC, l.offering_id ASC, a.start_dt ASC',
$this->table,
$avTable,
$offTable,
Lesson::STATUS_CANCELLED,
\Unsupervised\Schedular\Offering\Offering::BILLING_WEEKLY,
\Unsupervised\Schedular\Offering\Offering::BILLING_MONTHLY
)
);
return $rows ?? [];
}
/**
* How many lessons a payment covers every lesson pointed at it, cancelled or
* not, since the payment was billed for all of them. Used to split a paid
* payment's total into a per-lesson share when one covered lesson is cancelled
* and credited. Never below zero.
*/
public function countByPaymentId( int $paymentId ): int {
return (int) $this->db->get_var(
$this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE payment_id = %d',
$this->table,
$paymentId
)
);
}
/**
* How many lessons belong to a weekly series the whole reservation an upfront
* (full-term) payment covers, so cancelling one lesson credits its per-lesson
* share. Counts every lesson in the series, cancelled or not.
*/
public function countBySeries( int $seriesId ): int {
return (int) $this->db->get_var(
$this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE series_id = %d',
$this->table,
$seriesId
)
);
}
public function setPaymentId( int $id, int $paymentId ): bool {
return false !== $this->db->update(
$this->table,
@@ -180,6 +284,26 @@ class BookingRepository {
);
}
/**
* Update every non-cancelled lesson in a weekly series at once e.g.
* confirming the whole reservation when its single upfront payment settles.
*/
public function updateStatusForSeries( int $seriesId, string $status ): bool {
if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) {
return false;
}
$sql = $this->db->prepare(
'UPDATE %i SET status = %s WHERE series_id = %d AND status != %s',
$this->table,
$status,
$seriesId,
Lesson::STATUS_CANCELLED
);
return null !== $sql && false !== $this->db->query( $sql );
}
public function updateStatus( int $id, string $status ): bool {
if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) {
return false;
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Payment\StudioSettings;
/**
* Decides whether a student may still cancel a lesson. Cancellation closes once
* the lesson starts within the effective cutoff window; instructors and studio
* admins bypass this entirely and cancel through other paths.
*
* The window is resolved per lesson: the offering's own cutoff when it sets one,
* otherwise the studio default. Both are expressed in hours.
*/
class CancellationPolicy {
public function __construct( private StudioSettings $settings ) {}
/**
* The effective cutoff in hours for a lesson: the offering's override when
* set (a non-negative value), otherwise the studio default.
*/
public function cutoffHours( ?int $offeringCutoffHours ): int {
if ( null !== $offeringCutoffHours && $offeringCutoffHours >= 0 ) {
return $offeringCutoffHours;
}
return $this->settings->cancellationCutoffHours();
}
/**
* Whether a student may still cancel a lesson starting at $slotStartDt
* (WordPress-local `Y-m-d H:i:s`), given the offering's optional cutoff
* override. A zero cutoff always allows cancellation; unparseable input
* fails open so a student is never trapped by bad data. Pass $now to make
* the comparison deterministic in tests.
*/
public function studentMayCancel( string $slotStartDt, ?int $offeringCutoffHours, ?string $now = null ): bool {
$hours = $this->cutoffHours( $offeringCutoffHours );
if ( $hours <= 0 ) {
return true;
}
$start = strtotime( $slotStartDt );
$current = strtotime( $now ?? current_time( 'mysql' ) );
if ( false === $start || false === $current ) {
return true;
}
return ( $start - $current ) >= $hours * 3600;
}
/**
* A human-readable description of a cutoff for student-facing messages:
* whole days as days, anything else as hours.
*/
public function describeCutoff( int $hours ): string {
if ( $hours > 0 && 0 === $hours % 24 ) {
$days = $hours / 24;
/* translators: %d: number of days. */
return sprintf( _n( '%d day', '%d days', $days, 'unsupervised-schedular' ), $days );
}
/* translators: %d: number of hours. */
return sprintf( _n( '%d hour', '%d hours', $hours, 'unsupervised-schedular' ), $hours );
}
}
+13 -11
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Val;
class Lesson {
public const STATUS_PENDING = 'pending';
@@ -39,18 +41,18 @@ class Lesson {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
slotId: (int) $row->slot_id,
studentId: (int) $row->student_id,
instructorId: (int) $row->instructor_id,
offeringId: null !== $row->offering_id ? (int) $row->offering_id : null,
recurrence: $row->recurrence,
seriesId: null !== $row->series_id ? (int) $row->series_id : null,
status: $row->status,
paymentId: null !== $row->payment_id ? (int) $row->payment_id : null,
notes: $row->notes,
id: (int) $row->id,
slotId: Val::int( $row->slot_id ),
studentId: Val::int( $row->student_id ),
instructorId: Val::int( $row->instructor_id ),
offeringId: Val::intOrNull( $row->offering_id ),
recurrence: Val::string( $row->recurrence ),
seriesId: Val::intOrNull( $row->series_id ),
status: Val::string( $row->status ),
paymentId: Val::intOrNull( $row->payment_id ),
notes: Val::stringOrNull( $row->notes ),
id: Val::int( $row->id ),
);
}
+96 -6
View File
@@ -4,14 +4,22 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Availability\WeekCalendar;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Val;
class LessonController {
public function __construct(
private BookingRepository $repository,
private PaymentRepository $payments,
private AvailabilityRepository $availability,
private OfferingRepository $offerings,
private LessonDetail $detail,
) {}
public function renderAdminDashboard(): void {
@@ -19,11 +27,15 @@ class LessonController {
wp_die( esc_html__( 'You do not have permission to view this page.', 'unsupervised-schedular' ) );
}
if ( $this->maybeRenderDetail( 'us-scheduler', false ) ) {
return;
}
$this->handleEtransferUpdate( false );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() );
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
$this->renderLessonsPage( $rows, 'us-scheduler' );
}
public function renderInstructorLessons(): void {
@@ -31,10 +43,67 @@ class LessonController {
wp_die( esc_html__( 'You do not have permission to view lessons.', 'unsupervised-schedular' ) );
}
if ( $this->maybeRenderDetail( 'us-my-lessons', true ) ) {
return;
}
$this->handleEtransferUpdate( true );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
$this->renderLessonsPage( $rows, 'us-my-lessons' );
}
/**
* When the request targets a single lesson (`?lesson_id=`), render its detail
* view and report that the page has been handled. Instructors may only open
* their own lessons; the studio dashboard ($onlyOwn = false) may open any.
*/
private function maybeRenderDetail( string $pageSlug, bool $onlyOwn ): bool {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only lesson selector.
$lessonId = absint( Val::int( $_GET['lesson_id'] ?? 0 ) );
if ( $lessonId <= 0 ) {
return false;
}
$lesson = $this->repository->findById( $lessonId );
$backUrl = admin_url( 'admin.php?page=' . $pageSlug );
if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) {
$row = null;
$answers = [];
$accepts = [];
} else {
$row = $this->row( $lesson );
$answers = $this->detail->answers( $lessonId );
$accepts = $this->detail->acceptances( $lessonId );
}
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
return true;
}
/**
* Render the lessons template with its calendar view state: week (default)
* or list, plus which week the week view shows.
*
* @param list<array<string, mixed>> $rows
*/
private function renderLessonsPage( array $rows, string $pageSlug ): 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
$view = 'list' === sanitize_key( Val::string( wp_unslash( $_GET['usc_view'] ?? '' ) ) ) ? 'list' : 'week';
$requestedWeek = sanitize_text_field( Val::string( wp_unslash( $_GET['usc_week'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$weekStart = WeekCalendar::weekStart( $requestedWeek, Val::int( get_option( 'start_of_week', 1 ) ), current_time( 'Y-m-d' ) );
$weekDays = WeekCalendar::bucket( $weekStart, $rows, static fn( array $row ): string => Val::string( $row['day'] ) );
$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 );
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
}
@@ -48,10 +117,11 @@ class LessonController {
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$paymentId = absint( $_POST['payment_id'] ?? 0 );
$email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) );
$taxRate = isset( $_POST['tax_rate'] ) ? max( 0.0, (float) $_POST['tax_rate'] ) : 0.0;
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
$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.
$taxRate = isset( $_POST['tax_rate'] ) ? max( 0.0, Val::float( $_POST['tax_rate'] ) ) : 0.0;
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( $paymentId <= 0 || ! in_array( $action, [ 'set_etransfer', 'set_tax' ], true ) ) {
@@ -81,11 +151,19 @@ class LessonController {
$student = get_userdata( $lesson->studentId );
$instructor = get_userdata( $lesson->instructorId );
$payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null;
$slot = $this->availability->findById( $lesson->slotId );
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
return [
'lesson_id' => (int) $lesson->id,
'student' => $student ? $student->display_name : (string) $lesson->studentId,
'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId,
'slot_id' => (int) $lesson->slotId,
'offering' => $offering ? $offering->title : '—',
'duration' => null !== $offering && null !== $offering->durationMinutes ? $offering->durationMinutes : 0,
'recurrence' => $lesson->recurrence,
'time' => $slot ? $this->formatSlotTime( $slot ) : '—',
'day' => $slot ? substr( $slot->startDt, 0, 10 ) : '',
'time_short' => $slot ? Val::string( mysql2date( 'g:i A', $slot->startDt ) ) : '—',
'status' => $lesson->status,
'notes' => $lesson->notes ?? '',
'payment_id' => $payment ? (int) $payment->id : 0,
@@ -99,4 +177,16 @@ class LessonController {
'tax_editable' => null !== $payment && ! $payment->isPaid(),
];
}
/**
* Format a slot's window as e.g. "Jul 6, 2026 9:00 AM10:00 AM", repeating the
* date on the end time only when the slot crosses midnight.
*/
private function formatSlotTime( AvailabilitySlot $slot ): string {
$sameDay = substr( $slot->startDt, 0, 10 ) === substr( $slot->endDt, 0, 10 );
return Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) )
. ''
. Val::string( mysql2date( $sameDay ? 'g:i A' : 'M j, Y g:i A', $slot->endDt ) );
}
}
+73
View File
@@ -0,0 +1,73 @@
<?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 )
);
}
}
+9 -7
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Val;
class Enrollment {
public const STATUS_ACTIVE = 'active';
@@ -25,14 +27,14 @@ class Enrollment {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
offeringId: (int) $row->offering_id,
studentId: (int) $row->student_id,
instructorId: (int) $row->instructor_id,
status: $row->status,
paymentId: null !== $row->payment_id ? (int) $row->payment_id : null,
id: (int) $row->id,
offeringId: Val::int( $row->offering_id ),
studentId: Val::int( $row->student_id ),
instructorId: Val::int( $row->instructor_id ),
status: Val::string( $row->status ),
paymentId: Val::intOrNull( $row->payment_id ),
id: Val::int( $row->id ),
);
}
+95 -8
View File
@@ -10,6 +10,7 @@ use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Val;
class EnrollmentEndpoint {
@@ -18,8 +19,14 @@ class EnrollmentEndpoint {
private OfferingRepository $offerings,
private RegistrationGate $gate,
private PaymentService $payments,
private GroupAccessRepository $access,
) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void {
register_rest_route(
$route_namespace,
@@ -52,6 +59,18 @@ class EnrollmentEndpoint {
],
]
);
register_rest_route(
$route_namespace,
'/enrollments/(?P<id>\d+)/withdraw',
[
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'withdraw' ],
'permission_callback' => [ $this, 'isLoggedIn' ],
],
]
);
}
public function index( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
@@ -69,7 +88,7 @@ class EnrollmentEndpoint {
}
public function enroll( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$offeringId = absint( $request->get_param( 'offering_id' ) );
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
@@ -82,12 +101,24 @@ class EnrollmentEndpoint {
return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
// Invite-only classes can only be enrolled in by students who were granted
// access (or added directly); everyone else never sees the class at all.
if ( $offering->isInviteOnly() && ! $this->access->hasGrant( $offeringId, $studentId ) ) {
return new \WP_Error( 'invite_required', __( 'This class is by invitation only.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
// Enrolment closes at the end of the deadline day — the instructor's set
// deadline, or the first class day by default.
if ( ! $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) {
return new \WP_Error( 'enrollment_closed', __( 'Enrolment for this class has closed.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$answers = $this->answers( $request );
$acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) );
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) );
$gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds );
if ( $gateError instanceof \WP_Error ) {
@@ -104,16 +135,72 @@ class EnrollmentEndpoint {
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
if ( $offering->price > 0.0 ) {
$this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
// Mark the access grant used so instructor rosters distinguish invited
// students from enrolled ones (a no-op for public classes).
if ( $offering->isInviteOnly() ) {
$this->access->markEnrolled( $offeringId, $studentId );
}
// Scheduled billing (weekly / monthly) is generated later by the daily
// billing scan, so nothing is charged at enrolment; the enrolment is active
// regardless of payment.
$payment = null;
if ( $offering->price > 0.0 && ! $offering->isScheduledBilling() ) {
$payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
}
// `payment: null` tells the front end to skip the payment step entirely.
return new \WP_REST_Response(
[
'id' => $id,
'status' => Enrollment::STATUS_ACTIVE,
'payment' => $payment?->toSummaryArray(),
],
201
);
}
/**
* Withdraw the current student from a group class they enrolled in. Allowed
* only while the offering's withdrawal deadline is open (a class with no
* deadline set stays open indefinitely); once it passes, the student must
* contact the studio and an admin withdraws them by hand. A timely withdrawal
* frees the seat and voids any still-pending payment but never issues an
* account credit that is reserved for cancelled lessons.
*/
public function withdraw( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$enrollment = $this->enrollments->findById( $id );
if ( null === $enrollment ) {
return new \WP_Error( 'not_found', __( 'Enrolment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( get_current_user_id() !== $enrollment->studentId ) {
return new \WP_Error( 'forbidden', __( 'You cannot withdraw from this class.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( Enrollment::STATUS_ACTIVE === $enrollment->status ) {
$offering = $this->offerings->findById( $enrollment->offeringId );
if ( null !== $offering && ! $offering->isWithdrawalOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) {
return new \WP_Error(
'withdrawal_closed',
__( 'Withdrawal for this class has closed. Please contact the studio.', 'unsupervised-schedular' ),
[ 'status' => 403 ]
);
}
$this->enrollments->updateStatus( $id, Enrollment::STATUS_CANCELLED );
$this->payments->voidPending( $enrollment->paymentId );
}
return new \WP_REST_Response(
[
'id' => $id,
'status' => Enrollment::STATUS_ACTIVE,
'status' => Enrollment::STATUS_CANCELLED,
],
201
200
);
}
@@ -133,7 +220,7 @@ class EnrollmentEndpoint {
private function answers( \WP_REST_Request $request ): array {
$out = [];
foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) {
$out[ (int) $questionId ] = sanitize_text_field( (string) $value );
$out[ (int) $questionId ] = sanitize_text_field( Val::string( $value ) );
}
return $out;
@@ -141,7 +228,7 @@ class EnrollmentEndpoint {
private function clientIp(): ?string {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
return '' !== $ip ? $ip : null;
}
+46 -7
View File
@@ -30,7 +30,7 @@ class EnrollmentRepository {
public function findById( int $id ): ?Enrollment {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Enrollment::fromRow( $row ) : null;
@@ -42,7 +42,8 @@ class EnrollmentRepository {
public function countActiveForOffering( int $offeringId ): int {
return (int) $this->db->get_var(
$this->db->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND status = %s",
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND status = %s',
$this->table,
$offeringId,
Enrollment::STATUS_ACTIVE
)
@@ -55,7 +56,8 @@ class EnrollmentRepository {
public function countActiveForStudent( int $studentId ): int {
return (int) $this->db->get_var(
$this->db->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE student_id = %d AND status = %s",
'SELECT COUNT(*) FROM %i WHERE student_id = %d AND status = %s',
$this->table,
$studentId,
Enrollment::STATUS_ACTIVE
)
@@ -68,7 +70,8 @@ class EnrollmentRepository {
public function hasActiveEnrollment( int $offeringId, int $studentId ): bool {
$count = (int) $this->db->get_var(
$this->db->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND student_id = %d AND status = %s",
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND student_id = %d AND status = %s',
$this->table,
$offeringId,
$studentId,
Enrollment::STATUS_ACTIVE
@@ -86,7 +89,8 @@ class EnrollmentRepository {
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY enrolled_at DESC",
'SELECT * FROM %i WHERE student_id = %d ORDER BY enrolled_at DESC',
$this->table,
$studentId
)
);
@@ -102,7 +106,8 @@ class EnrollmentRepository {
public function findByInstructor( int $instructorId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY enrolled_at DESC",
'SELECT * FROM %i WHERE instructor_id = %d ORDER BY enrolled_at DESC',
$this->table,
$instructorId
)
);
@@ -118,7 +123,8 @@ class EnrollmentRepository {
public function findAllActive(): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE status = %s ORDER BY enrolled_at DESC",
'SELECT * FROM %i WHERE status = %s ORDER BY enrolled_at DESC',
$this->table,
Enrollment::STATUS_ACTIVE
)
);
@@ -126,6 +132,39 @@ class EnrollmentRepository {
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
}
/**
* Active enrolments whose group class bills on a scheduled mode (weekly /
* monthly) the source rows for the daily billing scan. Filtered by joining
* the offering so only classes actually on a scheduled plan are returned.
*
* @param list<string> $modes Billing modes to include (e.g. weekly, monthly).
* @return list<Enrollment>
*/
public function findActiveByBillingModes( array $modes ): array {
if ( [] === $modes ) {
return [];
}
$offTable = str_replace( 'us_group_enrollments', 'us_offerings', $this->table );
$placeholders = implode( ', ', array_fill( 0, count( $modes ), '%s' ) );
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT e.* FROM %i e
JOIN %i o ON o.id = e.offering_id
WHERE e.status = %s
AND o.billing_mode IN ( {$placeholders} )
ORDER BY e.student_id ASC, e.offering_id ASC",
$this->table,
$offTable,
Enrollment::STATUS_ACTIVE,
...$modes
)
);
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
}
public function setPaymentId( int $id, int $paymentId ): bool {
return false !== $this->db->update(
$this->table,
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Val;
/**
* A grant of access to an invite-only group class. Registered students who have
* been "made available" a class hold an `invited` grant (`student_id` set);
* email-invited people who do not yet have an account hold a grant keyed by
* `email` and linked to a `us_invites` row, which is pointed at the new
* `student_id` once they register. A grant flips to `enrolled` when the student
* enrols through the normal flow.
*/
class GroupAccess {
public const STATUS_INVITED = 'invited';
public const STATUS_ENROLLED = 'enrolled';
public const STATUS_REVOKED = 'revoked';
/**
* All valid grant statuses.
*
* @var list<string>
*/
public const VALID_STATUSES = [ self::STATUS_INVITED, self::STATUS_ENROLLED, self::STATUS_REVOKED ];
public function __construct(
public readonly int $offeringId,
public readonly ?int $studentId = null,
public readonly string $email = '',
public readonly ?int $inviteId = null,
public readonly string $status = self::STATUS_INVITED,
public readonly ?int $invitedBy = null,
public readonly ?int $id = null,
) {}
public static function fromRow( \stdClass $row ): self {
return new self(
offeringId: Val::int( $row->offering_id ),
studentId: Val::intOrNull( $row->student_id ),
email: Val::string( $row->email ?? '' ),
inviteId: Val::intOrNull( $row->invite_id ?? null ),
status: Val::string( $row->status ),
invitedBy: Val::intOrNull( $row->invited_by ?? null ),
id: Val::int( $row->id ),
);
}
/**
* Returns a plain array representation of the grant.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'offering_id' => $this->offeringId,
'student_id' => $this->studentId,
'email' => $this->email,
'invite_id' => $this->inviteId,
'status' => $this->status,
'invited_by' => $this->invitedBy,
];
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
class GroupAccessRepository {
private string $table;
public function __construct( private \wpdb $db ) {
$this->table = $db->prefix . 'us_group_access';
}
public function insert( GroupAccess $access ): int {
$this->db->insert(
$this->table,
[
'offering_id' => $access->offeringId,
'student_id' => $access->studentId,
'email' => $access->email,
'invite_id' => $access->inviteId,
'status' => $access->status,
'invited_by' => $access->invitedBy,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%s', '%d', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
}
/**
* Whether a student holds a live (invited or enrolled) grant for an offering.
*/
public function hasGrant( int $offeringId, int $studentId ): bool {
$count = (int) $this->db->get_var(
$this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND student_id = %d AND status IN ( %s, %s )',
$this->table,
$offeringId,
$studentId,
GroupAccess::STATUS_INVITED,
GroupAccess::STATUS_ENROLLED
)
);
return $count > 0;
}
/**
* The offering ids a student holds a live grant for the invite-only classes
* to fold into their catalogue view.
*
* @return list<int>
*/
public function findGrantedOfferingIds( int $studentId ): array {
$rows = $this->db->get_col(
$this->db->prepare(
'SELECT DISTINCT offering_id FROM %i WHERE student_id = %d AND status IN ( %s, %s )',
$this->table,
$studentId,
GroupAccess::STATUS_INVITED,
GroupAccess::STATUS_ENROLLED
)
);
return array_values( array_map( \Unsupervised\Schedular\Val::int( ... ), $rows ) );
}
/**
* All grants for an offering, newest first.
*
* @return list<GroupAccess>
*/
public function findByOffering( int $offeringId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE offering_id = %d ORDER BY id DESC',
$this->table,
$offeringId
)
);
return array_map( GroupAccess::fromRow( ... ), $rows ?? [] );
}
/**
* Point email-invite grants for an address at the account created when the
* invitation was accepted, so the granted class unlocks for the new student.
* Only grants still awaiting an account (`student_id` NULL) are linked.
*/
public function linkStudentByEmail( string $email, int $studentId ): bool {
if ( '' === $email ) {
return false;
}
$sql = $this->db->prepare(
'UPDATE %i SET student_id = %d WHERE email = %s AND student_id IS NULL',
$this->table,
$studentId,
$email
);
return null !== $sql && false !== $this->db->query( $sql );
}
/**
* Flip a student's live grant for an offering to enrolled.
*/
public function markEnrolled( int $offeringId, int $studentId ): bool {
return false !== $this->db->update(
$this->table,
[ 'status' => GroupAccess::STATUS_ENROLLED ],
[
'offering_id' => $offeringId,
'student_id' => $studentId,
],
[ '%s' ],
[ '%d', '%d' ]
);
}
public function revoke( int $id ): bool {
return false !== $this->db->update(
$this->table,
[ 'status' => GroupAccess::STATUS_REVOKED ],
[ 'id' => $id ],
[ '%s' ],
[ '%d' ]
);
}
}
+486 -8
View File
@@ -3,35 +3,513 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Auth\Invite;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Val;
class GroupClassController {
public function __construct(
private EnrollmentRepository $enrollments,
private OfferingRepository $offerings,
private PaymentRepository $payments,
private GroupAccessRepository $access,
private PaymentService $paymentService,
private InviteRepository $invites,
private RegistrationMailer $mailer,
) {}
/**
* Studio-admin overview: every group class across instructors as a summary
* who teaches it, when it meets, and how full it is rather than a flat list
* of individual student enrolments. Selecting a class (`?class_id=<id>`) opens
* the same per-class details page instructors use, so a studio admin (including
* an owner-operator who also teaches) can view any class's roster and manage
* invite-only membership from here.
*/
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
}
$rows = array_map(
function ( Enrollment $enrollment ): array {
$offering = $this->offerings->findById( $enrollment->offeringId );
$student = get_userdata( $enrollment->studentId );
$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.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$classId = absint( Val::int( $_GET['class_id'] ?? 0 ) );
$current = null;
foreach ( $offerings as $offering ) {
if ( $offering->id === $classId ) {
$current = $offering;
break;
}
}
if ( null !== $current ) {
// Enrolments are looked up by the class's own instructor; classDetail
// filters them down to this offering.
$class = $this->classDetail( $current, $this->enrollments->findByInstructor( $current->instructorId ) );
$students = $this->studentOptions();
include USC_PLUGIN_DIR . 'templates/admin/my-group-class-detail.php';
return;
}
$rows = array_map(
function ( Offering $offering ): array {
return [
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
'status' => $enrollment->status,
'id' => $offering->id,
'title' => $offering->title,
'instructor' => $this->instructorName( $offering ),
'when' => $this->whenLabel( $offering ),
'capacity' => $offering->capacity,
'enrolled' => $this->enrollments->countActiveForOffering( (int) $offering->id ),
'invite_only' => $offering->isInviteOnly(),
];
},
$this->enrollments->findAllActive()
$offerings
);
include USC_PLUGIN_DIR . 'templates/admin/group-classes.php';
}
/**
* Instructor view. By default a summary of the instructor's own group classes
* each with when it meets and how many are enrolled rather than a dump of
* every roster. A `class_id` query param drills into one class to show its
* roster of enrolled students and, for invite-only classes, the controls to
* add, grant access to, or email-invite students.
*/
public function renderInstructorPage(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
}
$instructorId = get_current_user_id();
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
$notice = $this->handleFormAction( $instructorId );
}
$offerings = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS );
$enrollments = $this->enrollments->findByInstructor( $instructorId );
// View-state query param only (which class to drill into) — nothing is
// mutated from it, so no nonce applies.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$classId = absint( Val::int( $_GET['class_id'] ?? 0 ) );
$current = null;
foreach ( $offerings as $offering ) {
if ( $offering->id === $classId ) {
$current = $offering;
break;
}
}
if ( null !== $current ) {
$baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
$class = $this->classDetail( $current, $enrollments );
$students = $this->studentOptions();
include USC_PLUGIN_DIR . 'templates/admin/my-group-class-detail.php';
return;
}
$classes = array_map(
fn( Offering $offering ): array => $this->classSummary( $offering, $enrollments ),
$offerings
);
$baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php';
}
/**
* Summary row for one class in the instructor overview: its identity, when it
* meets, and how many active enrolments it holds against capacity.
*
* @param list<Enrollment> $enrollments
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}
*/
private function classSummary( Offering $offering, array $enrollments ): array {
$enrolled = 0;
foreach ( $enrollments as $enrollment ) {
if ( $enrollment->offeringId === $offering->id && Enrollment::STATUS_ACTIVE === $enrollment->status ) {
++$enrolled;
}
}
return [
'id' => $offering->id,
'title' => $offering->title,
'when' => $this->whenLabel( $offering ),
'capacity' => $offering->capacity,
'enrolled' => $enrolled,
'invite_only' => $offering->isInviteOnly(),
];
}
/**
* Full details for one class: the summary fields, the class's own settings
* (instructor, price, duration, description, schedule, active state), the
* roster of enrolled students (with enrolment and payment status), and for
* 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}>}
*/
private function classDetail( Offering $offering, array $enrollments ): array {
$roster = [];
foreach ( $enrollments as $enrollment ) {
if ( $enrollment->offeringId !== $offering->id ) {
continue;
}
$student = get_userdata( $enrollment->studentId );
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
$roster[] = [
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
'status' => $enrollment->status,
'payment' => $payment?->status,
];
}
$deadline = $offering->effectiveEnrollmentDeadline();
return $this->classSummary( $offering, $enrollments ) + [
'instructor' => $this->instructorName( $offering ),
'price' => $offering->price,
'currency' => $offering->currency,
'duration' => $offering->durationMinutes,
'description' => $offering->description,
'schedule_note' => $offering->scheduleNote,
'deadline' => null !== $deadline ? (string) mysql2date( 'M j, Y', $deadline ) : '',
'enrollment_open' => $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ),
'active' => $offering->isActive,
'roster' => $roster,
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
];
}
/**
* The teaching instructor's display name their real name or nickname, never
* the login. Falls back to the numeric id when the account is gone. See
* {@see UserName::format()}.
*/
private function instructorName( Offering $offering ): string {
$user = get_userdata( $offering->instructorId );
return UserName::format( $user instanceof \WP_User ? $user : null, $offering->instructorId );
}
/**
* Human-readable "when" label for a class: the class date (or weekly date
* range) and, when set, the start time. Empty when the class has no date.
*/
private function whenLabel( Offering $offering ): string {
if ( null === $offering->termStart ) {
return '';
}
$label = null === $offering->termEnd || $offering->termEnd === $offering->termStart
? (string) mysql2date( 'M j, Y', $offering->termStart )
: (string) mysql2date( 'M j, Y', $offering->termStart ) . ' ' . (string) mysql2date( 'M j, Y', $offering->termEnd );
if ( null !== $offering->classTime ) {
$label .= ' · ' . (string) mysql2date( 'g:i a', $offering->termStart . ' ' . $offering->classTime );
}
return $label;
}
/**
* Pending (not-yet-enrolled) access grants for an invite-only class, shown so
* the instructor can see who has been invited but has not enrolled yet.
*
* @return list<array{who: string, kind: string}>
*/
private function pendingInvites( int $offeringId ): array {
$out = [];
foreach ( $this->access->findByOffering( $offeringId ) as $grant ) {
if ( GroupAccess::STATUS_INVITED !== $grant->status ) {
continue;
}
if ( null !== $grant->studentId ) {
$user = get_userdata( $grant->studentId );
$out[] = [
'who' => $user ? $user->display_name : (string) $grant->studentId,
'kind' => __( 'Granted', 'unsupervised-schedular' ),
];
} else {
$out[] = [
'who' => $grant->email,
'kind' => __( 'Email invite', 'unsupervised-schedular' ),
];
}
}
return $out;
}
/**
* Handle a posted management action, returning a status notice for display.
* The action is scoped to a group class the current instructor owns, unless
* the caller is a studio admin (`view_all_lessons`) who may manage any
* instructor's class, since the studio-admin Group Classes page reaches the
* same controls for every class.
*/
private function handleFormAction( int $instructorId ): string {
// Nonce is verified by the caller before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
$offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
$ownsOrManagesAll = null !== $offering
&& ( $offering->instructorId === $instructorId || current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) );
if ( null === $offering || ! $ownsOrManagesAll || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
return esc_html__( 'That group class was not found.', 'unsupervised-schedular' );
}
if ( 'add_direct' === $action ) {
return $this->addDirect( $offering, $this->postedStudentIds() );
}
if ( 'grant_access' === $action ) {
return $this->grantAccess( $offering, $this->postedStudentIds() );
}
if ( 'invite_email' === $action ) {
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
return $this->inviteEmail( $offering, $email );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
return '';
}
/**
* Directly enrol registered students, each with a pending payment at the
* class price (comp students are settled immediately by the payment service).
*
* This is the instructor's manual enrolment path and deliberately bypasses the
* enrolment deadline and capacity, so a student can be added as a late
* enrolment after the class has closed to self-enrolment.
*
* @param list<int> $studentIds
*/
private function addDirect( Offering $offering, array $studentIds ): string {
$added = 0;
foreach ( $studentIds as $studentId ) {
if ( $this->enrollments->hasActiveEnrollment( (int) $offering->id, $studentId ) ) {
continue;
}
$enrollmentId = $this->enrollments->insert(
new Enrollment(
offeringId: (int) $offering->id,
studentId: $studentId,
instructorId: $offering->instructorId,
)
);
if ( $offering->price > 0.0 ) {
$payment = $this->paymentService->createForRegistration(
Payment::REG_ENROLLMENT,
$enrollmentId,
$studentId,
$offering->instructorId,
$offering->price,
$offering->currency,
$offering->etransferEmail
);
if ( null !== $payment && null !== $payment->id ) {
$this->enrollments->setPaymentId( $enrollmentId, $payment->id );
}
}
$this->access->markEnrolled( (int) $offering->id, $studentId );
++$added;
}
/* translators: %d: number of students added. */
return sprintf( esc_html__( '%d student(s) added to the class.', 'unsupervised-schedular' ), $added );
}
/**
* Grant registered students access to the class so it appears in their list
* for self-enrolment, notifying each by email.
*
* @param list<int> $studentIds
*/
private function grantAccess( Offering $offering, array $studentIds ): string {
$granted = 0;
foreach ( $studentIds as $studentId ) {
if (
$this->enrollments->hasActiveEnrollment( (int) $offering->id, $studentId )
|| $this->access->hasGrant( (int) $offering->id, $studentId )
) {
continue;
}
$this->access->insert(
new GroupAccess(
offeringId: (int) $offering->id,
studentId: $studentId,
status: GroupAccess::STATUS_INVITED,
invitedBy: get_current_user_id(),
)
);
$user = get_userdata( $studentId );
if ( $user instanceof \WP_User ) {
$this->mailer->sendClassAccessGranted( $user, $offering->title );
}
++$granted;
}
/* translators: %d: number of students granted access. */
return sprintf( esc_html__( '%d student(s) granted access.', 'unsupervised-schedular' ), $granted );
}
/**
* Invite someone by email. A registered address is treated as a grant; an
* unknown address gets a tokenised registration invite tied to the class,
* reusing any pending invite already outstanding for that address (in which
* case no new link is sent).
*/
private function inviteEmail( Offering $offering, string $email ): string {
if ( ! is_email( $email ) ) {
return esc_html__( 'Enter a valid email address.', 'unsupervised-schedular' );
}
$existingUserId = email_exists( $email );
if ( false !== $existingUserId ) {
return $this->grantAccess( $offering, [ (int) $existingUserId ] );
}
// Reuse an outstanding invite rather than mailing a second link; still
// attach a class grant so enrolment unlocks once they register.
$pending = $this->invites->findPendingByEmail( $email );
if ( null !== $pending ) {
$this->access->insert(
new GroupAccess(
offeringId: (int) $offering->id,
email: $email,
inviteId: $pending->id,
status: GroupAccess::STATUS_INVITED,
invitedBy: get_current_user_id(),
)
);
return esc_html__( 'This person already has a pending invitation; the class was added to it. No new link was sent.', 'unsupervised-schedular' );
}
$rawToken = wp_generate_password( 32, false );
$inviteId = $this->invites->insert(
new Invite(
email: $email,
token: Invite::hashToken( $rawToken ),
invitedBy: get_current_user_id(),
offeringId: (int) $offering->id,
)
);
if ( $inviteId <= 0 ) {
return esc_html__( 'Could not create the invite. Deactivate and reactivate the plugin to update the database, then try again.', 'unsupervised-schedular' );
}
$this->access->insert(
new GroupAccess(
offeringId: (int) $offering->id,
email: $email,
inviteId: $inviteId,
status: GroupAccess::STATUS_INVITED,
invitedBy: get_current_user_id(),
)
);
$this->mailer->sendClassInvite( $email, $this->registrationLink( $rawToken ), $offering->title );
return esc_html__( 'Invitation sent.', 'unsupervised-schedular' );
}
/**
* Registered students to offer in the add/grant selects, by display name.
*
* @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' => '' !== (string) $u->display_name ? (string) $u->display_name : (string) $u->user_email,
],
$users
)
);
}
/**
* The de-duplicated positive student ids posted from a multi-select.
*
* @return list<int>
*/
private function postedStudentIds(): array {
// Nonce is verified by the caller before this method runs.
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int below; slashes cannot survive integer coercion.
$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 ) );
}
/**
* Build the registration URL for a raw invite token, mirroring the invites
* admin page so class invites land on the same registration page.
*/
private function registrationLink( string $rawToken ): string {
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
$linkBase = $pageId > 0 ? (string) get_permalink( $pageId ) : '';
return add_query_arg( 'us_invite', rawurlencode( $rawToken ), '' !== $linkBase ? $linkBase : home_url( '/' ) );
}
}
+13 -3
View File
@@ -4,20 +4,28 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class GroupClassPage {
/**
* Renders the group-class enrolment shortcode output.
*
* @param array<string, string> $atts Shortcode attributes (unused reserved for future options).
* Supported attributes: `offering` (shortcode) / `offeringId` (block) an
* offering id that restricts the page to a single class, so the shortcode
* can be embedded on a page dedicated to that class. 0 or absent shows the
* full browsable catalog.
*
* @param array<int|string, mixed> $atts Shortcode or block attributes.
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
public function render( array $atts ): string {
if ( ! is_user_logged_in() ) {
$permalink = get_permalink();
return sprintf(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url( wp_login_url( get_permalink() ) ),
esc_url( wp_login_url( false === $permalink ? '' : $permalink ) ),
esc_html__( 'log in to enrol in a class', 'unsupervised-schedular' )
);
}
@@ -29,6 +37,8 @@ class GroupClassPage {
wp_enqueue_style( 'us-scheduler' );
wp_enqueue_script( 'us-scheduler-group' );
$offeringId = absint( Val::int( $atts['offering'] ?? $atts['offeringId'] ?? 0 ) );
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php';
return (string) ob_get_clean();
+27
View File
@@ -4,18 +4,36 @@ declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
class Installer {
public function run(): void {
$this->createTables();
$this->migrateData();
( new RoleManager() )->createRoles();
$this->scheduleBilling();
flush_rewrite_rules();
update_option( 'us_schedular_version', USC_VERSION );
}
/**
* Ensure the daily scheduled-billing scan is registered with WP-Cron. Runs on
* activation and on every version-bump re-install, so an existing site that
* predates the feature picks the event up on its next deploy.
*/
private function scheduleBilling(): void {
if ( false === wp_next_scheduled( ScheduledBillingRunner::HOOK ) ) {
wp_schedule_event( time(), 'daily', ScheduledBillingRunner::HOOK );
}
}
private function createTables(): void {
global $wpdb;
if ( ! $wpdb instanceof \wpdb ) {
return;
}
$charset = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
@@ -24,4 +42,13 @@ class Installer {
dbDelta( $sql );
}
}
private function migrateData(): void {
global $wpdb;
if ( ! $wpdb instanceof \wpdb ) {
return;
}
( new AvailabilityRepository( $wpdb ) )->splitOversizedWindows();
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
/**
* Keeps an instructor's open availability out of the way of the group classes
* they teach. When a group class is scheduled (an assigned instructor plus a
* date, time, and duration), each session occupies the instructor: any open
* private-booking slot that overlaps a session is removed so students cannot
* book the instructor at the class time, and any already-booked slot that
* overlaps is reported as a conflict for the studio to resolve by hand a
* booked lesson is never silently deleted.
*/
class ClassSlotReconciler {
public function __construct( private AvailabilityRepository $availability ) {}
/**
* Reconcile the assigned instructor's availability with the class schedule.
*
* @return array{removed: int, conflicts: list<string>} The number of open
* slots cleared, and the start datetime (`Y-m-d H:i:s`) of each booked
* slot that still clashes with a session.
*/
public function reconcile( Offering $offering ): array {
if ( Offering::KIND_GROUP_CLASS !== $offering->kind || $offering->instructorId <= 0 ) {
return [
'removed' => 0,
'conflicts' => [],
];
}
$removed = 0;
$conflicts = [];
foreach ( $offering->sessionWindows() as $window ) {
foreach ( $this->availability->findOverlapping( $offering->instructorId, $window['start'], $window['end'] ) as $slot ) {
if ( $slot->isBooked ) {
$conflicts[] = $slot->startDt;
continue;
}
if ( null !== $slot->id && $this->availability->delete( $slot->id ) ) {
++$removed;
}
}
}
return [
'removed' => $removed,
'conflicts' => $conflicts,
];
}
}
+205 -18
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Val;
class Offering {
public const KIND_PRIVATE_LESSON = 'private_lesson';
@@ -18,12 +20,48 @@ class Offering {
public const BILLING_ONE_TIME = 'one_time';
public const BILLING_FULL_TERM = 'full_term';
/** Billed 24 hours before each lesson, on a recurring schedule (see scheduled-billing.md). */
public const BILLING_WEEKLY = 'weekly';
/** Billed on the first of each month for every lesson that falls in the month. */
public const BILLING_MONTHLY = 'monthly';
/**
* All valid billing modes.
*
* @var list<string>
*/
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ];
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM, self::BILLING_WEEKLY, self::BILLING_MONTHLY ];
/**
* Billing modes whose payment is generated later by the daily billing scan
* rather than taken at registration.
*
* @var list<string>
*/
public const SCHEDULED_BILLING_MODES = [ self::BILLING_WEEKLY, self::BILLING_MONTHLY ];
/** Listed in the public catalogue; anyone with `book_lesson` may enrol. */
public const ACCESS_PUBLIC = 'public';
/** Hidden from the catalogue; only invited/added students may enrol (group classes). */
public const ACCESS_INVITE_ONLY = 'invite_only';
/**
* All valid access modes.
*
* @var list<string>
*/
public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ];
/** Maximum length of the title, matching the `title` VARCHAR(191) column. */
public const MAX_TITLE_LENGTH = 191;
/** Maximum length of the schedule note, matching the `schedule_note` VARCHAR(191) column. */
public const MAX_SCHEDULE_NOTE_LENGTH = 191;
/** Maximum length of the e-transfer email, matching the `etransfer_email` VARCHAR(191) column. */
public const MAX_ETRANSFER_EMAIL_LENGTH = 191;
public function __construct(
public readonly int $instructorId,
@@ -38,30 +76,174 @@ class Offering {
public readonly ?int $capacity = null,
public readonly ?string $termStart = null,
public readonly ?string $termEnd = null,
public readonly ?string $classTime = null,
public readonly ?string $enrollmentDeadline = null,
public readonly ?string $withdrawalDeadline = null,
public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = null,
public readonly ?int $cancellationCutoffHours = null,
public readonly string $accessMode = self::ACCESS_PUBLIC,
public readonly bool $isActive = true,
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
/**
* Whether the offering is hidden from the public catalogue and reachable
* only by invited or directly-added students.
*/
public function isInviteOnly(): bool {
return self::ACCESS_INVITE_ONLY === $this->accessMode;
}
/**
* Whether this offering's payment is deferred to the daily billing scan
* (weekly / monthly) instead of being taken at registration.
*/
public function isScheduledBilling(): bool {
return in_array( $this->billingMode, self::SCHEDULED_BILLING_MODES, true );
}
/**
* The last day on which a student may enrol in this group class. Defaults to
* the first day of the class (`term_start`) when the instructor has not set an
* explicit deadline; null only when the class has no dates at all.
*/
public function effectiveEnrollmentDeadline(): ?string {
return $this->enrollmentDeadline ?? $this->termStart;
}
/**
* Whether enrolment is still open on `$today` (a `Y-m-d` date). Enrolment stays
* open through the end of the deadline day, so the first class is still
* enrollable under the default deadline. A class with no deadline at all (no
* dates configured) is always open.
*/
public function isEnrollmentOpen( string $today ): bool {
$deadline = $this->effectiveEnrollmentDeadline();
return null === $deadline || $today <= $deadline;
}
/**
* Whether a student may still withdraw themselves from this group class on
* `$today` (a `Y-m-d` date). Withdrawal stays open through the end of the
* deadline day. Unlike the enrolment deadline there is no implicit default: a
* class with no withdrawal deadline set stays open to withdrawal for its whole
* life, so the instructor must set a date to lock students in. A withdrawal
* made while open never issues an account credit it only frees the seat and
* voids any still-pending payment.
*/
public function isWithdrawalOpen( string $today ): bool {
return null === $this->withdrawalDeadline || $today <= $this->withdrawalDeadline;
}
/**
* Normalise a submitted term date to canonical `Y-m-d`, or null when it is
* not a real calendar date. Round-trips through DateTimeImmutable so
* strings PHP would silently coerce (e.g. `2026-02-30`) are rejected.
*/
public static function normalizeDate( string $value ): ?string {
$date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
return false !== $date && $date->format( 'Y-m-d' ) === $value ? $date->format( 'Y-m-d' ) : null;
}
/**
* Last class date of a weekly term: the start date plus `$occurrences - 1`
* weeks. A one-off class (one occurrence) ends the day it starts.
*/
public static function weeklyTermEnd( string $termStart, int $occurrences ): string {
$weeks = max( 1, $occurrences ) - 1;
return ( new \DateTimeImmutable( $termStart ) )->modify( '+' . ( 7 * $weeks ) . ' days' )->format( 'Y-m-d' );
}
/**
* Normalise a submitted time-of-day to canonical `H:i:s`, or null when it is
* not a real time. Accepts the HTML `time` form (`H:i`, optionally with
* seconds); anything else is rejected so garbage never reaches the TIME column.
*/
public static function normalizeTime( string $value ): ?string {
foreach ( [ 'H:i:s', 'H:i' ] as $format ) {
$time = \DateTimeImmutable::createFromFormat( '!' . $format, $value );
if ( false !== $time && $time->format( $format ) === $value ) {
return $time->format( 'H:i:s' );
}
}
return null;
}
/**
* The concrete start/end datetimes of every session of this group class,
* derived from the class date(s), the class time, and the duration. A weekly
* class yields one window per week from `term_start` through `term_end`; a
* one-off class yields a single window. Returns an empty list unless the
* schedule is fully specified (date, time, and a positive duration), so it can
* never fabricate a session window from partial data.
*
* @return list<array{start: string, end: string}>
*/
public function sessionWindows(): array {
if (
null === $this->termStart
|| null === $this->classTime
|| null === $this->durationMinutes
|| $this->durationMinutes <= 0
) {
return [];
}
$first = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $this->termStart . ' ' . $this->classTime );
if ( false === $first ) {
return [];
}
$lastDay = null !== $this->termEnd ? $this->termEnd : $this->termStart;
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
$windows = [];
$cursor = $first;
$cursorDay = $cursor->format( 'Y-m-d' );
// Cap the walk at ten years of weeks so a term_end before term_start (or a
// bad value) can never spin into an unbounded loop.
for ( $i = 0; $i < 520 && $cursorDay <= $lastDay; $i++ ) {
$windows[] = [
'start' => $cursor->format( 'Y-m-d H:i:s' ),
'end' => $cursor->add( $step )->format( 'Y-m-d H:i:s' ),
];
$cursor = $cursor->modify( '+7 days' );
$cursorDay = $cursor->format( 'Y-m-d' );
}
return $windows;
}
public static function fromRow( \stdClass $row ): self {
return new self(
instructorId: (int) $row->instructor_id,
kind: $row->kind,
title: $row->title,
price: (float) $row->price,
currency: $row->currency,
billingMode: $row->billing_mode,
description: $row->description,
durationMinutes: null !== $row->duration_minutes ? (int) $row->duration_minutes : null,
allowWeekly: (bool) $row->allow_weekly,
capacity: null !== $row->capacity ? (int) $row->capacity : null,
termStart: $row->term_start,
termEnd: $row->term_end,
scheduleNote: $row->schedule_note,
etransferEmail: $row->etransfer_email,
isActive: (bool) $row->is_active,
id: (int) $row->id,
instructorId: Val::int( $row->instructor_id ),
kind: Val::string( $row->kind ),
title: Val::string( $row->title ),
price: Val::float( $row->price ),
currency: Val::string( $row->currency ),
billingMode: Val::string( $row->billing_mode ),
description: Val::stringOrNull( $row->description ),
durationMinutes: Val::intOrNull( $row->duration_minutes ),
allowWeekly: Val::bool( $row->allow_weekly ),
capacity: Val::intOrNull( $row->capacity ),
termStart: Val::stringOrNull( $row->term_start ),
termEnd: Val::stringOrNull( $row->term_end ),
classTime: Val::stringOrNull( $row->class_time ?? null ),
enrollmentDeadline: Val::stringOrNull( $row->enrollment_deadline ?? null ),
withdrawalDeadline: Val::stringOrNull( $row->withdrawal_deadline ?? null ),
scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
accessMode: '' !== Val::string( $row->access_mode ?? '' ) ? Val::string( $row->access_mode ) : self::ACCESS_PUBLIC,
isActive: Val::bool( $row->is_active ),
id: Val::int( $row->id ),
);
}
@@ -89,7 +271,12 @@ class Offering {
'capacity' => $this->capacity,
'term_start' => $this->termStart,
'term_end' => $this->termEnd,
'class_time' => $this->classTime,
'enrollment_deadline' => $this->enrollmentDeadline,
'withdrawal_deadline' => $this->withdrawalDeadline,
'schedule_note' => $this->scheduleNote,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'access_mode' => $this->accessMode,
'is_active' => $this->isActive,
];
+225 -20
View File
@@ -3,11 +3,17 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class OfferingController {
public function __construct( private OfferingRepository $repository ) {}
public function __construct(
private OfferingRepository $repository,
private ClassSlotReconciler $reconciler,
private AccessSettings $access = new AccessSettings(),
) {}
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) ) {
@@ -17,8 +23,27 @@ class OfferingController {
$instructorId = get_current_user_id();
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_offering_action' ) ) {
$this->handleFormAction( $instructorId, $manageAll );
$notice = $this->handleFormAction( $instructorId, $manageAll );
}
// Studio admins may assign any instructor to a class; a plain instructor
// only ever creates classes for themselves, so the picker is theirs alone.
$instructors = $manageAll ? $this->instructorOptions() : [];
// View-state query param only (which offering the form is editing) —
// nothing is mutated from it, so no nonce applies.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$editId = absint( Val::int( $_GET['usc_edit'] ?? 0 ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$editing = null;
if ( $editId > 0 ) {
$candidate = $this->repository->findById( $editId );
if ( $candidate && ( $manageAll || $candidate->instructorId === $instructorId ) ) {
$editing = $candidate;
}
}
$offerings = $manageAll
@@ -28,17 +53,42 @@ class OfferingController {
include USC_PLUGIN_DIR . 'templates/admin/offerings.php';
}
private function handleFormAction( int $instructorId, bool $manageAll ): void {
/**
* Process the posted add/update/delete action, returning a status notice for
* display (e.g. how many booking slots a scheduled class cleared, or that a
* booked lesson clashes with it). An empty string means nothing to report.
*/
private function handleFormAction( int $instructorId, bool $manageAll ): string {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) {
$this->addOffering( $instructorId );
$offering = $this->offeringFromPost( $instructorId, $manageAll );
if ( null !== $offering ) {
$this->repository->insert( $offering );
return $this->reconcileNotice( $offering );
}
}
if ( 'update' === $action ) {
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
if ( $offeringId > 0 ) {
$existing = $this->repository->findById( $offeringId );
if ( $existing && ( $manageAll || $existing->instructorId === $instructorId ) ) {
$offering = $this->offeringFromPost( $instructorId, $manageAll, $existing );
if ( null !== $offering ) {
$this->repository->update( $offeringId, $offering );
return $this->reconcileNotice( $offering );
}
}
}
}
if ( 'delete' === $action ) {
$offeringId = absint( $_POST['offering_id'] ?? 0 );
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
if ( $offeringId > 0 ) {
$offering = $this->repository->findById( $offeringId );
if ( $offering && ( $manageAll || $offering->instructorId === $instructorId ) ) {
@@ -47,42 +97,197 @@ class OfferingController {
}
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
return '';
}
private function addOffering( int $instructorId ): void {
/**
* Clear the assigned instructor's open booking slots that collide with a
* scheduled group class and describe the result, warning about any booked
* lesson that clashes (which the studio must resolve by hand).
*/
private function reconcileNotice( Offering $offering ): string {
if ( Offering::KIND_GROUP_CLASS !== $offering->kind ) {
return '';
}
$result = $this->reconciler->reconcile( $offering );
$parts = [];
if ( $result['removed'] > 0 ) {
$parts[] = sprintf(
/* translators: %d: number of open booking slots removed. */
_n(
'%d open booking slot was removed to hold the class time.',
'%d open booking slots were removed to hold the class time.',
$result['removed'],
'unsupervised-schedular'
),
$result['removed']
);
}
foreach ( $result['conflicts'] as $startDt ) {
$parts[] = sprintf(
/* translators: %s: date and time of the already-booked lesson that clashes. */
esc_html__( 'Conflict: a lesson is already booked at %s during this class.', 'unsupervised-schedular' ),
(string) mysql2date( 'M j, Y g:i a', $startDt )
);
}
return implode( ' ', $parts );
}
/**
* Instructors offered in the assignment select, by display name.
*
* Includes everyone holding the `us_instructor` role plus, when the site owner
* has left administrators acting as instructors (the default single-account
* setup), WordPress administrators who teach through the dynamic capability
* grant rather than the role. Without them a solo studio owner running the
* business from an admin account would find no one to assign a class to.
*
* @return list<array{id: int, name: string}>
*/
private function instructorOptions(): array {
$roles = [ RoleManager::INSTRUCTOR ];
if ( $this->access->adminsAreInstructors() ) {
$roles[] = 'administrator';
}
$users = array_filter(
get_users(
[
'role__in' => $roles,
'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' => '' !== (string) $u->display_name ? (string) $u->display_name : (string) $u->user_email,
],
$users
)
);
}
/**
* Build an offering from the submitted add/edit form, or null when the
* submission is invalid. When `$existing` is given the result is an edit:
* it keeps the existing id and currency so an update can never rewrite those
* from whoever submits the form.
*
* The owning instructor normally stays fixed (the creator on add, the existing
* owner on edit). A studio admin (`$manageAll`) may instead assign the class to
* any instructor via the picker; a blank or absent choice keeps the default.
*/
private function offeringFromPost( int $instructorId, bool $manageAll, ?Offering $existing = null ): ?Offering {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) );
$kind = sanitize_key( wp_unslash( $_POST['kind'] ?? '' ) );
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) );
$kind = sanitize_key( Val::string( wp_unslash( $_POST['kind'] ?? '' ) ) );
if ( '' === $title || ! in_array( $kind, Offering::VALID_KINDS, true ) ) {
return;
return null;
}
$billingMode = sanitize_key( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) );
$scheduleNote = $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) );
$etransferEmail = $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) );
// Reject over-long fixed-size fields rather than let the DB silently drop them.
if ( mb_strlen( $title ) > Offering::MAX_TITLE_LENGTH
|| ( null !== $scheduleNote && mb_strlen( $scheduleNote ) > Offering::MAX_SCHEDULE_NOTE_LENGTH )
|| ( null !== $etransferEmail && mb_strlen( $etransferEmail ) > Offering::MAX_ETRANSFER_EMAIL_LENGTH )
) {
return null;
}
$billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) );
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
$billingMode = Offering::BILLING_ONE_TIME;
}
$duration = absint( $_POST['duration_minutes'] ?? 0 );
$capacity = absint( $_POST['capacity'] ?? 0 );
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
$capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) );
$this->repository->insert(
new Offering(
instructorId: $instructorId,
// A blank cutoff means "use the studio default" (null); any entered value
// (including 0 — cancel any time) is a per-offering override.
$cutoffRaw = trim( sanitize_text_field( Val::string( wp_unslash( $_POST['cancellation_cutoff_hours'] ?? '' ) ) ) );
$cutoffHours = '' === $cutoffRaw ? null : absint( Val::int( $cutoffRaw ) );
// Term dates: a class either meets once (term ends the day it starts)
// or repeats weekly for a set number of sessions.
$termStart = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['term_start'] ?? '' ) ) ) );
$termEnd = null;
if ( null !== $termStart ) {
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['term_recurrence'] ?? 'single' ) ) );
$sessions = absint( Val::int( $_POST['term_sessions'] ?? 1 ) );
$termEnd = 'weekly' === $recurrence ? Offering::weeklyTermEnd( $termStart, $sessions ) : $termStart;
}
$classTime = Offering::normalizeTime( sanitize_text_field( Val::string( wp_unslash( $_POST['class_time'] ?? '' ) ) ) );
// A blank (or invalid) deadline means "use the default" — the first class
// day (term_start), applied by Offering::effectiveEnrollmentDeadline().
$enrollmentDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['enrollment_deadline'] ?? '' ) ) ) );
// A blank (or invalid) withdrawal deadline leaves the column NULL, which
// keeps self-withdrawal open for the class's whole life
// (Offering::isWithdrawalOpen()). A set date closes it after that day.
$withdrawalDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['withdrawal_deadline'] ?? '' ) ) ) );
return new Offering(
instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
kind: $kind,
title: $title,
price: max( 0.0, (float) sanitize_text_field( wp_unslash( $_POST['price'] ?? '0' ) ) ),
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ),
currency: null !== $existing ? $existing->currency : 'CAD',
billingMode: $billingMode,
description: $this->nullableText( sanitize_textarea_field( Val::string( wp_unslash( $_POST['description'] ?? '' ) ) ) ),
durationMinutes: $duration > 0 ? $duration : null,
allowWeekly: isset( $_POST['allow_weekly'] ),
capacity: $capacity > 0 ? $capacity : null,
scheduleNote: $this->nullableText( sanitize_text_field( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ),
etransferEmail: $this->nullableText( sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ),
)
termStart: $termStart,
termEnd: $termEnd,
classTime: $classTime,
enrollmentDeadline: $enrollmentDeadline,
withdrawalDeadline: $withdrawalDeadline,
scheduleNote: $scheduleNote,
etransferEmail: $etransferEmail,
cancellationCutoffHours: $cutoffHours,
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
isActive: isset( $_POST['is_active'] ),
id: $existing?->id,
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* The instructor the offering should belong to. A studio admin may reassign it
* via the posted `class_instructor_id`; otherwise it stays with the existing
* owner (edit) or the current user (add). A plain instructor can never change
* the owner, so the posted value is ignored unless `$manageAll` is set.
*/
private function resolveInstructorId( int $instructorId, bool $manageAll, ?Offering $existing ): int {
$fallback = null !== $existing ? $existing->instructorId : $instructorId;
if ( ! $manageAll ) {
return $fallback;
}
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$posted = absint( Val::int( $_POST['class_instructor_id'] ?? 0 ) );
return $posted > 0 ? $posted : $fallback;
}
private function nullableText( string $value ): ?string {
return '' === $value ? null : $value;
}
+159 -32
View File
@@ -4,11 +4,22 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Val;
class OfferingEndpoint {
public function __construct( private OfferingRepository $repository ) {}
public function __construct(
private OfferingRepository $repository,
private GroupAccessRepository $access,
) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void {
register_rest_route(
$route_namespace,
@@ -17,7 +28,7 @@ class OfferingEndpoint {
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'index' ],
'permission_callback' => [ $this, 'canBook' ],
'permission_callback' => [ $this, 'canRead' ],
'args' => [
'instructor_id' => [
'type' => 'integer',
@@ -56,38 +67,101 @@ class OfferingEndpoint {
}
public function index( \WP_REST_Request $request ): \WP_REST_Response {
$offerings = $this->repository->findAll(
(int) $request->get_param( 'instructor_id' ),
(string) $request->get_param( 'kind' ),
activeOnly: true,
);
$instructorId = Val::int( $request->get_param( 'instructor_id' ) );
$kind = Val::string( $request->get_param( 'kind' ) );
// Public listing: omit the private e-transfer destination email.
return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray( includeEtransferEmail: false ), $offerings ), 200 );
// The public catalogue is public offerings only; invite-only classes are
// hidden from it and surfaced separately to the students granted access.
$offerings = $this->repository->findAll( $instructorId, $kind, activeOnly: true, accessMode: Offering::ACCESS_PUBLIC );
foreach ( $this->grantedInviteOnly( $instructorId, $kind ) as $granted ) {
$offerings[] = $granted;
}
// Public listing: omit the private e-transfer destination email, and
// attach the assigned instructor's display name so the front end can show
// students who teaches each class.
return new \WP_REST_Response( array_map( [ $this, 'present' ], $offerings ), 200 );
}
/**
* A public-facing offering array with the assigned instructor's name added
* their real name or nickname, never the login (empty when the instructor
* account no longer exists). See {@see UserName::format()}.
*
* @return array<string, mixed>
*/
private function present( Offering $offering ): array {
$out = $offering->toArray( includeEtransferEmail: false );
$user = get_userdata( $offering->instructorId );
$out['instructor_name'] = UserName::format( $user instanceof \WP_User ? $user : null );
return $out;
}
/**
* The active invite-only offerings the caller has been granted access to,
* matching the same instructor/kind filters as the public catalogue.
*
* @return list<Offering>
*/
private function grantedInviteOnly( int $instructorId, string $kind ): array {
$grantedIds = $this->access->findGrantedOfferingIds( get_current_user_id() );
if ( [] === $grantedIds ) {
return [];
}
$out = [];
foreach ( $grantedIds as $offeringId ) {
$offering = $this->repository->findById( $offeringId );
if (
null === $offering
|| ! $offering->isActive
|| ! $offering->isInviteOnly()
|| ( $instructorId > 0 && $offering->instructorId !== $instructorId )
|| ( '' !== $kind && $offering->kind !== $kind )
) {
continue;
}
$out[] = $offering;
}
return $out;
}
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$title = sanitize_text_field( (string) $request->get_param( 'title' ) );
$title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) );
if ( '' === $title ) {
return $this->invalid( __( 'A title is required.', 'unsupervised-schedular' ) );
}
$kind = (string) $request->get_param( 'kind' );
$kind = Val::string( $request->get_param( 'kind' ) );
if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) {
return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) );
}
$billingMode = (string) ( $request->get_param( 'billing_mode' ) ?? Offering::BILLING_ONE_TIME );
$billingMode = Val::string( $request->get_param( 'billing_mode' ) ?? Offering::BILLING_ONE_TIME );
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
}
$scheduleNote = $this->nullableText( $request->get_param( 'schedule_note' ) );
$etransferEmail = $this->nullableEmail( $request->get_param( 'etransfer_email' ) );
$lengthError = $this->checkLengths( $title, $scheduleNote, $etransferEmail );
if ( $lengthError instanceof \WP_Error ) {
return $lengthError;
}
$offering = new Offering(
instructorId: get_current_user_id(),
kind: $kind,
title: $title,
price: $this->price( $request->get_param( 'price' ) ),
currency: sanitize_text_field( (string) ( $request->get_param( 'currency' ) ?? 'CAD' ) ),
currency: sanitize_text_field( Val::string( $request->get_param( 'currency' ) ?? 'CAD' ) ),
billingMode: $billingMode,
description: $this->nullableText( $request->get_param( 'description' ) ),
durationMinutes: $this->nullableInt( $request->get_param( 'duration_minutes' ) ),
@@ -95,8 +169,11 @@ class OfferingEndpoint {
capacity: $this->nullableInt( $request->get_param( 'capacity' ) ),
termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ),
scheduleNote: $scheduleNote,
etransferEmail: $etransferEmail,
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
);
@@ -106,7 +183,7 @@ class OfferingEndpoint {
}
public function update( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$existing = $this->repository->findById( $id );
if ( null === $existing ) {
@@ -117,22 +194,31 @@ class OfferingEndpoint {
return new \WP_Error( 'forbidden', __( 'You cannot edit this offering.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
$kind = $request->has_param( 'kind' ) ? (string) $request->get_param( 'kind' ) : $existing->kind;
$kind = $request->has_param( 'kind' ) ? Val::string( $request->get_param( 'kind' ) ) : $existing->kind;
if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) {
return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) );
}
$billingMode = $request->has_param( 'billing_mode' ) ? (string) $request->get_param( 'billing_mode' ) : $existing->billingMode;
$billingMode = $request->has_param( 'billing_mode' ) ? Val::string( $request->get_param( 'billing_mode' ) ) : $existing->billingMode;
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
}
$title = $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title;
$scheduleNote = $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote;
$etransferEmail = $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail;
$lengthError = $this->checkLengths( $title, $scheduleNote, $etransferEmail );
if ( $lengthError instanceof \WP_Error ) {
return $lengthError;
}
$offering = new Offering(
instructorId: $existing->instructorId,
kind: $kind,
title: $request->has_param( 'title' ) ? sanitize_text_field( (string) $request->get_param( 'title' ) ) : $existing->title,
title: $title,
price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price,
currency: $request->has_param( 'currency' ) ? sanitize_text_field( (string) $request->get_param( 'currency' ) ) : $existing->currency,
currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::string( $request->get_param( 'currency' ) ) ) : $existing->currency,
billingMode: $billingMode,
description: $request->has_param( 'description' ) ? $this->nullableText( $request->get_param( 'description' ) ) : $existing->description,
durationMinutes: $request->has_param( 'duration_minutes' ) ? $this->nullableInt( $request->get_param( 'duration_minutes' ) ) : $existing->durationMinutes,
@@ -140,8 +226,11 @@ class OfferingEndpoint {
capacity: $request->has_param( 'capacity' ) ? $this->nullableInt( $request->get_param( 'capacity' ) ) : $existing->capacity,
termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart,
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote,
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail,
enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline,
scheduleNote: $scheduleNote,
etransferEmail: $etransferEmail,
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
accessMode: $request->has_param( 'access_mode' ) ? $this->accessMode( $request->get_param( 'access_mode' ), $existing->accessMode ) : $existing->accessMode,
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
id: $id,
);
@@ -152,7 +241,7 @@ class OfferingEndpoint {
}
public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$existing = $this->repository->findById( $id );
if ( null === $existing ) {
@@ -173,12 +262,16 @@ class OfferingEndpoint {
}
/**
* Reading the offerings catalogue is only needed by the logged-in student
* booking flow, so it requires the same capability as booking there is no
* anonymous consumer.
* Reading the offerings catalogue has no anonymous consumer, so it stays
* behind a login. Students reach it through the booking flow, and studio
* admins and instructors reach it from the block editor's group-class
* pickers an administrator holds `manage_offerings` but not
* `book_lesson`, so both capabilities open the listing.
*/
public function canBook(): bool {
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
public function canRead(): bool {
return is_user_logged_in()
&& ( current_user_can( RoleManager::CAP_BOOK_LESSON )
|| current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) );
}
/**
@@ -194,18 +287,52 @@ class OfferingEndpoint {
return new \WP_Error( 'invalid_offering', $message, [ 'status' => 400 ] );
}
/**
* Reject any fixed-size field whose value exceeds its column length, so an
* over-long value is refused with a clear 400 rather than silently dropped
* by the database.
*/
private function checkLengths( string $title, ?string $scheduleNote, ?string $etransferEmail ): ?\WP_Error {
$fields = [
[ __( 'title', 'unsupervised-schedular' ), $title, Offering::MAX_TITLE_LENGTH ],
[ __( 'schedule note', 'unsupervised-schedular' ), $scheduleNote, Offering::MAX_SCHEDULE_NOTE_LENGTH ],
[ __( 'e-transfer email', 'unsupervised-schedular' ), $etransferEmail, Offering::MAX_ETRANSFER_EMAIL_LENGTH ],
];
foreach ( $fields as [ $name, $value, $max ] ) {
if ( null !== $value && mb_strlen( $value ) > $max ) {
return $this->invalid(
sprintf(
/* translators: 1: field name, 2: maximum character count. */
__( 'The %1$s must be %2$d characters or fewer.', 'unsupervised-schedular' ),
$name,
$max
)
);
}
}
return null;
}
private function price( mixed $value ): float {
return max( 0.0, (float) $value );
return max( 0.0, Val::float( $value ) );
}
private function nullableEmail( mixed $value ): ?string {
$email = sanitize_email( (string) $value );
$email = sanitize_email( Val::string( $value ) );
return '' !== $email ? $email : null;
}
private function accessMode( mixed $value, string $fallback ): string {
$mode = Val::string( $value );
return in_array( $mode, Offering::VALID_ACCESS_MODES, true ) ? $mode : $fallback;
}
private function nullableInt( mixed $value ): ?int {
return ( null === $value || '' === $value ) ? null : (int) $value;
return ( null === $value || '' === $value ) ? null : Val::int( $value );
}
private function nullableText( mixed $value ): ?string {
@@ -213,6 +340,6 @@ class OfferingEndpoint {
return null;
}
return sanitize_text_field( (string) $value );
return sanitize_text_field( Val::string( $value ) );
}
}
+24 -10
View File
@@ -14,11 +14,13 @@ class OfferingRepository {
/**
* Column formats aligned to {@see columns()} (instructor_id, kind, title,
* description, duration_minutes, price, currency, billing_mode, allow_weekly,
* capacity, term_start, term_end, schedule_note, etransfer_email, is_active).
* capacity, term_start, term_end, class_time, enrollment_deadline,
* withdrawal_deadline, schedule_note, etransfer_email,
* cancellation_cutoff_hours, access_mode, is_active).
*
* @var list<string>
*/
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%d' ];
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
public function insert( Offering $offering ): int {
$this->db->insert(
@@ -59,18 +61,25 @@ class OfferingRepository {
'capacity' => $offering->capacity,
'term_start' => $offering->termStart,
'term_end' => $offering->termEnd,
'class_time' => $offering->classTime,
'enrollment_deadline' => $offering->enrollmentDeadline,
'withdrawal_deadline' => $offering->withdrawalDeadline,
'schedule_note' => $offering->scheduleNote,
'etransfer_email' => $offering->etransferEmail,
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
'access_mode' => $offering->accessMode,
'is_active' => $offering->isActive ? 1 : 0,
];
}
/**
* Find offerings, optionally filtered by instructor, kind, and active state.
* Find offerings, optionally filtered by instructor, kind, active state, and
* access mode (e.g. `Offering::ACCESS_PUBLIC` to exclude invite-only classes
* from the public catalogue).
*
* @return list<Offering>
*/
public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null ): array {
public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null, ?string $accessMode = null ): array {
$where = [ '1 = 1' ];
$params = [];
@@ -89,19 +98,24 @@ class OfferingRepository {
$params[] = $activeOnly ? 1 : 0;
}
$whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY title ASC";
if ( null !== $accessMode ) {
$where[] = 'access_mode = %s';
$params[] = $accessMode;
}
$rows = $params
? $this->db->get_results( $this->db->prepare( $sql, $params ) )
: $this->db->get_results( $sql );
$whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY title ASC";
$rows = $this->db->get_results(
$this->db->prepare( $sql, array_merge( [ $this->table ], $params ) )
);
return array_map( Offering::fromRow( ... ), $rows ?? [] );
}
public function findById( int $id ): ?Offering {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Offering::fromRow( $row ) : null;
+3 -1
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
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
@@ -15,7 +17,7 @@ class BillingMethodResolver {
public function __construct( private StudioSettings $settings ) {}
public function resolve( int $studentId ): string {
$override = (string) get_user_meta( $studentId, self::META_METHOD, true );
$override = Val::string( get_user_meta( $studentId, self::META_METHOD, true ) );
if ( in_array( $override, Payment::VALID_METHODS, true ) ) {
return $override;
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Val;
/**
* A studio credit held on a student's account money already paid for a lesson
* that was later cancelled. Credits are consumed against future scheduled-billing
* charges (weekly / monthly) before the student is asked to pay, oldest first.
*/
class Credit {
public const STATUS_AVAILABLE = 'available';
public const STATUS_CONSUMED = 'consumed';
/**
* All valid credit statuses.
*
* @var list<string>
*/
public const VALID_STATUSES = [ self::STATUS_AVAILABLE, self::STATUS_CONSUMED ];
public function __construct(
public readonly int $studentId,
public readonly float $amount,
public readonly float $remaining,
public readonly string $currency = 'CAD',
public readonly ?int $sourcePaymentId = null,
public readonly ?int $sourceLessonId = null,
public readonly ?string $reason = null,
public readonly string $status = self::STATUS_AVAILABLE,
public readonly ?string $createdAt = null,
public readonly ?string $updatedAt = null,
public readonly ?int $id = null,
) {}
public static function fromRow( \stdClass $row ): self {
return new self(
studentId: Val::int( $row->student_id ),
amount: Val::float( $row->amount ),
remaining: Val::float( $row->remaining ),
currency: Val::string( $row->currency ),
sourcePaymentId: Val::intOrNull( $row->source_payment_id ?? null ),
sourceLessonId: Val::intOrNull( $row->source_lesson_id ?? null ),
reason: Val::stringOrNull( $row->reason ?? null ),
status: Val::string( $row->status ),
createdAt: Val::stringOrNull( $row->created_at ?? null ),
updatedAt: Val::stringOrNull( $row->updated_at ?? null ),
id: Val::int( $row->id ),
);
}
public function isAvailable(): bool {
return self::STATUS_AVAILABLE === $this->status && $this->remaining > 0.0;
}
/**
* Returns a plain array representation of the credit.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'student_id' => $this->studentId,
'amount' => $this->amount,
'remaining' => $this->remaining,
'currency' => $this->currency,
'source_payment_id' => $this->sourcePaymentId,
'source_lesson_id' => $this->sourceLessonId,
'reason' => $this->reason,
'status' => $this->status,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
class CreditRepository {
private string $table;
public function __construct( private \wpdb $db ) {
$this->table = $db->prefix . 'us_credits';
}
public function insert( Credit $credit ): int {
$this->db->insert(
$this->table,
[
'student_id' => $credit->studentId,
'amount' => $credit->amount,
'remaining' => $credit->remaining,
'currency' => $credit->currency,
'source_payment_id' => $credit->sourcePaymentId,
'source_lesson_id' => $credit->sourceLessonId,
'reason' => $credit->reason,
'status' => $credit->status,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%f', '%f', '%s', '%d', '%d', '%s', '%s', '%s' ]
);
return $this->db->insert_id;
}
public function findById( int $id ): ?Credit {
$row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Credit::fromRow( $row ) : null;
}
/**
* Whether a credit has already been issued for a cancelled lesson, so cancelling
* (or re-cancelling) the same lesson never grants a second credit.
*/
public function existsForLesson( int $lessonId ): bool {
$found = $this->db->get_var(
$this->db->prepare(
'SELECT id FROM %i WHERE source_lesson_id = %d LIMIT 1',
$this->table,
$lessonId
)
);
return null !== $found;
}
/**
* A student's total unused credit balance (sum of the remaining amounts of every
* still-available credit).
*/
public function availableBalance( int $studentId ): float {
$total = $this->db->get_var(
$this->db->prepare(
'SELECT COALESCE( SUM( remaining ), 0 ) FROM %i WHERE student_id = %d AND status = %s',
$this->table,
$studentId,
Credit::STATUS_AVAILABLE
)
);
return round( (float) $total, 2 );
}
/**
* A student's still-available credits, oldest first the FIFO order they are
* consumed in.
*
* @return list<Credit>
*/
public function findAvailableByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d AND status = %s AND remaining > 0 ORDER BY created_at ASC, id ASC',
$this->table,
$studentId,
Credit::STATUS_AVAILABLE
)
);
return array_map( Credit::fromRow( ... ), $rows ?? [] );
}
/**
* Every credit for a student, newest first (admin history).
*
* @return list<Credit>
*/
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d ORDER BY created_at DESC, id DESC',
$this->table,
$studentId
)
);
return array_map( Credit::fromRow( ... ), $rows ?? [] );
}
/**
* Draw down a student's credit balance by $amount, consuming their available
* credits oldest first and marking each fully-spent credit `consumed`. Stops once
* the amount is exhausted; a balance shorter than $amount simply drains to zero.
*/
public function consume( int $studentId, float $amount ): void {
$remaining = round( $amount, 2 );
if ( $remaining <= 0.0 ) {
return;
}
foreach ( $this->findAvailableByStudent( $studentId ) as $credit ) {
if ( $remaining <= 0.0 ) {
break;
}
if ( null === $credit->id ) {
continue;
}
$take = min( $credit->remaining, $remaining );
$newRemaining = round( $credit->remaining - $take, 2 );
$status = $newRemaining <= 0.0 ? Credit::STATUS_CONSUMED : Credit::STATUS_AVAILABLE;
$this->db->update(
$this->table,
[
'remaining' => $newRemaining,
'status' => $status,
'updated_at' => current_time( 'mysql' ),
],
[ 'id' => $credit->id ],
[ '%f', '%s', '%s' ],
[ '%d' ]
);
$remaining = round( $remaining - $take, 2 );
}
}
}
+66 -17
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Val;
class Payment {
public const METHOD_CARD = 'card';
@@ -42,32 +44,42 @@ class Payment {
public readonly string $status = self::STATUS_PENDING,
public readonly float $taxRate = 0.0,
public readonly float $taxAmount = 0.0,
public readonly float $creditApplied = 0.0,
public readonly ?string $dueDate = null,
public readonly ?string $periodKey = null,
public readonly ?string $noticeBatch = null,
public readonly ?string $etransferEmail = null,
public readonly ?string $stripePaymentIntentId = null,
public readonly ?string $receiptNumber = null,
public readonly ?string $receiptSentAt = null,
public readonly ?string $paidAt = null,
public readonly ?string $createdAt = null,
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
studentId: (int) $row->student_id,
instructorId: (int) $row->instructor_id,
registrationType: $row->registration_type,
registrationId: (int) $row->registration_id,
amount: (float) $row->amount,
currency: $row->currency,
method: $row->method,
status: $row->status,
taxRate: (float) $row->tax_rate,
taxAmount: (float) $row->tax_amount,
etransferEmail: $row->etransfer_email,
stripePaymentIntentId: $row->stripe_payment_intent_id,
receiptNumber: $row->receipt_number,
receiptSentAt: $row->receipt_sent_at,
paidAt: $row->paid_at,
id: (int) $row->id,
studentId: Val::int( $row->student_id ),
instructorId: Val::int( $row->instructor_id ),
registrationType: Val::string( $row->registration_type ),
registrationId: Val::int( $row->registration_id ),
amount: Val::float( $row->amount ),
currency: Val::string( $row->currency ),
method: Val::string( $row->method ),
status: Val::string( $row->status ),
taxRate: Val::float( $row->tax_rate ),
taxAmount: Val::float( $row->tax_amount ),
creditApplied: Val::float( $row->credit_applied ?? 0 ),
dueDate: Val::stringOrNull( $row->due_date ?? null ),
periodKey: Val::stringOrNull( $row->period_key ?? null ),
noticeBatch: Val::stringOrNull( $row->notice_batch ?? null ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ),
receiptNumber: Val::stringOrNull( $row->receipt_number ),
receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ),
paidAt: Val::stringOrNull( $row->paid_at ),
createdAt: Val::stringOrNull( $row->created_at ),
id: Val::int( $row->id ),
);
}
@@ -75,6 +87,15 @@ class Payment {
return self::STATUS_PAID === $this->status;
}
/**
* Whether this payment was generated by the daily billing scan (weekly /
* monthly) rather than taken at registration. Scheduled payments carry a due
* date, can cover several lessons, and are never auto-voided on cancellation.
*/
public function isScheduled(): bool {
return null !== $this->dueDate;
}
/**
* Amount billed including tax.
*/
@@ -82,6 +103,28 @@ class Payment {
return round( $this->amount + $this->taxAmount, 2 );
}
/**
* What the student still owes after any account credit applied to this payment.
* The full `total()` less `creditApplied`, floored at zero.
*/
public function netDue(): float {
return round( max( 0.0, $this->total() - $this->creditApplied ), 2 );
}
/**
* Minimal payment info embedded in registration-creation responses: enough
* for the front end to decide whether (and how) to run the payment step.
*
* @return array<string, mixed>
*/
public function toSummaryArray(): array {
return [
'id' => $this->id,
'method' => $this->method,
'status' => $this->status,
];
}
/**
* Returns a plain array representation of the payment.
*
@@ -99,11 +142,17 @@ class Payment {
'tax_rate' => $this->taxRate,
'tax_amount' => $this->taxAmount,
'total' => $this->total(),
'credit_applied' => $this->creditApplied,
'net_due' => $this->netDue(),
'currency' => $this->currency,
'method' => $this->method,
'status' => $this->status,
'due_date' => $this->dueDate,
'period_key' => $this->periodKey,
'notice_batch' => $this->noticeBatch,
'receipt_number' => $this->receiptNumber,
'paid_at' => $this->paidAt,
'created_at' => $this->createdAt,
];
}
}
+51 -11
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PaymentController {
@@ -19,10 +20,10 @@ class PaymentController {
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_payment_action' ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
if ( 'mark_paid' === sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ) ) {
if ( 'mark_paid' === sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$paymentId = absint( $_POST['payment_id'] ?? 0 );
$email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) );
$paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) );
$email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( $paymentId > 0 ) {
// Record the destination it was actually sent to before confirming.
@@ -32,22 +33,61 @@ class PaymentController {
}
}
$rows = array_map(
static function ( Payment $payment ): array {
$groups = $this->groupPending( $this->payments->findPending() );
include USC_PLUGIN_DIR . 'templates/admin/payments.php';
}
/**
* Group pending payments by their shared notice batch, so payments the daily
* scan emailed a student together (and which a single lump-sum e-transfer
* covers) are shown as one group with a combined total. Payments with no batch
* legacy at-registration e-transfers are each their own single-item group.
*
* @param list<Payment> $pending
* @return list<array{reference: string, is_group: bool, total: string, rows: list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}>}>
*/
private function groupPending( array $pending ): array {
$groups = [];
foreach ( $pending as $payment ) {
$batch = (string) $payment->noticeBatch;
$key = '' !== $batch ? 'b:' . $batch : 's:' . (string) $payment->id;
if ( ! isset( $groups[ $key ] ) ) {
$groups[ $key ] = [
'reference' => $batch,
'currency' => $payment->currency,
'total_raw' => 0.0,
'rows' => [],
];
}
$student = get_userdata( $payment->studentId );
return [
// Show what the student still owes — the amount less any account credit
// already applied to this payment.
$groups[ $key ]['total_raw'] += $payment->netDue();
$groups[ $key ]['rows'][] = [
'id' => (int) $payment->id,
'student' => $student ? $student->display_name : (string) $payment->studentId,
'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency,
'amount' => number_format( $payment->netDue(), 2 ) . ' ' . $payment->currency,
'method' => $payment->method,
'for' => $payment->registrationType . ' #' . $payment->registrationId,
'etransfer_email' => (string) $payment->etransferEmail,
];
},
$this->payments->findPending()
);
}
include USC_PLUGIN_DIR . 'templates/admin/payments.php';
return array_values(
array_map(
static fn( array $group ): array => [
'reference' => $group['reference'],
'is_group' => count( $group['rows'] ) > 1,
'total' => number_format( $group['total_raw'], 2 ) . ' ' . $group['currency'],
'rows' => $group['rows'],
],
$groups
)
);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
/**
* Emails a student a single itemised notice for every payment the daily billing
* scan generated for them in one run, so a student billed for several lessons on
* the same day receives one email with a line per item and a grand total never
* one email per lesson.
*/
class PaymentDueMailer {
/**
* Send one student their consolidated due-payment notice for the current scan.
* The optional `$reference` is the shared notice-batch code the student can quote
* on a lump-sum e-transfer so the studio can reconcile it to these payments.
*
* @param list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}> $items
* @param float $creditApplied Account credit deducted from the total this notice covers.
* @return bool False when there is no recipient or nothing to bill.
*/
public function send( \WP_User $student, array $items, string $reference = '', float $creditApplied = 0.0 ): bool {
if ( '' === (string) $student->user_email || [] === $items ) {
return false;
}
$currency = (string) $items[0]['currency'];
$total = 0.0;
$lines = [];
$emails = [];
foreach ( $items as $item ) {
$amount = (float) $item['amount'];
$total += $amount;
$lines[] = sprintf(
/* translators: 1: item description, 2: due date, 3: currency, 4: amount */
__( '- %1$s (due %2$s): %3$s %4$s', 'unsupervised-schedular' ),
(string) $item['label'],
$this->formatDate( $item['due_date'] ?? null ),
$currency,
number_format( $amount, 2 )
);
$etransfer = (string) ( $item['etransfer_email'] ?? '' );
if ( '' !== $etransfer ) {
$emails[ $etransfer ] = true;
}
}
// Account credit (from an earlier cancelled paid lesson) offsets the total.
$creditApplied = round( min( $creditApplied, $total ), 2 );
$dueTotal = round( $total - $creditApplied, 2 );
$body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n"
. implode( "\n", $lines );
if ( $creditApplied > 0.0 ) {
$body .= "\n\n" . sprintf(
/* translators: 1: currency, 2: credit amount */
__( 'Account credit applied: -%1$s %2$s', 'unsupervised-schedular' ),
$currency,
number_format( $creditApplied, 2 )
);
}
$body .= "\n\n" . sprintf(
/* translators: 1: currency, 2: total amount */
__( 'Total due: %1$s %2$s', 'unsupervised-schedular' ),
$currency,
number_format( $dueTotal, 2 )
);
if ( $dueTotal > 0.0 && [] !== $emails ) {
$body .= "\n\n" . sprintf(
/* translators: %s: e-transfer destination email address(es) */
__( 'Please send your e-transfer to: %s', 'unsupervised-schedular' ),
implode( ', ', array_keys( $emails ) )
);
}
if ( '' !== $reference ) {
$body .= "\n\n" . sprintf(
/* translators: %s: payment reference code */
__( 'Please include this reference with your payment: %s', 'unsupervised-schedular' ),
$reference
);
}
return (bool) wp_mail( $student->user_email, __( 'Payment due', 'unsupervised-schedular' ), $body );
}
/**
* Present a stored `Y-m-d` due date in a friendlier form; falls back to the
* raw value (or an empty string) when it is not a parseable date.
*/
private function formatDate( ?string $date ): string {
if ( null === $date || '' === $date ) {
return '';
}
$parsed = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date );
return false !== $parsed ? $parsed->format( 'M j, Y' ) : $date;
}
}
+9 -3
View File
@@ -4,11 +4,17 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PaymentEndpoint {
public function __construct( private PaymentService $service ) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void {
register_rest_route(
$route_namespace,
@@ -64,8 +70,8 @@ class PaymentEndpoint {
* (Stripe client secret for card; display data for e-transfer/comp).
*/
public function createIntent( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$type = (string) $request->get_param( 'registration_type' );
$registrationId = absint( $request->get_param( 'registration_id' ) );
$type = Val::string( $request->get_param( 'registration_type' ) );
$registrationId = absint( Val::int( $request->get_param( 'registration_id' ) ) );
$result = $this->service->createIntent( $type, $registrationId, get_current_user_id() );
if ( null === $result ) {
@@ -99,7 +105,7 @@ class PaymentEndpoint {
* Studio admin marks a pending payment (e-transfer) received.
*/
public function markPaid( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$id = absint( Val::int( $request->get_param( 'id' ) ) );
if ( ! $this->service->markPaid( $id ) ) {
return new \WP_Error( 'not_found', __( 'Payment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
+11 -2
View File
@@ -90,13 +90,22 @@ class PaymentReport {
}
/**
* Format one CSV record, quoting fields and escaping embedded quotes.
* Format one CSV record, quoting fields and escaping embedded quotes. Fields
* that a spreadsheet would interpret as a formula (leading =, +, -, @, tab, or
* CR e.g. a hostile student display name) are prefixed with an apostrophe so
* they open as text, never as executable formulas.
*
* @param list<string> $fields
*/
private function csvLine( array $fields ): string {
$escaped = array_map(
static fn( string $field ): string => '"' . str_replace( '"', '""', $field ) . '"',
static function ( string $field ): string {
if ( 1 === preg_match( '/^[=+\-@\t\r]/', $field ) ) {
$field = "'" . $field;
}
return '"' . str_replace( '"', '""', $field ) . '"';
},
$fields
);
+7 -5
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PaymentReportController {
@@ -21,8 +22,8 @@ class PaymentReportController {
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only report filters, no state change.
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : '' );
$instructorId = isset( $_GET['instructor_id'] ) ? absint( $_GET['instructor_id'] ) : 0;
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( Val::string( wp_unslash( $_GET['month'] ) ) ) : '' );
$instructorId = isset( $_GET['instructor_id'] ) ? absint( Val::int( $_GET['instructor_id'] ) ) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$instructorId = $this->scopeInstructor( $instructorId );
@@ -58,8 +59,8 @@ class PaymentReportController {
check_admin_referer( self::EXPORT_ACTION );
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce checked above.
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : '' );
$instructorId = isset( $_GET['instructor_id'] ) ? absint( $_GET['instructor_id'] ) : 0;
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( Val::string( wp_unslash( $_GET['month'] ) ) ) : '' );
$instructorId = isset( $_GET['instructor_id'] ) ? absint( Val::int( $_GET['instructor_id'] ) ) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$instructorId = $this->scopeInstructor( $instructorId );
@@ -92,7 +93,8 @@ class PaymentReportController {
*/
private function buildReport( string $month, int $instructorId ): PaymentReport {
$start = $month . '-01 00:00:00';
$end = gmdate( 'Y-m-d H:i:s', strtotime( $month . '-01 00:00:00 +1 month' ) );
$endTs = strtotime( $month . '-01 00:00:00 +1 month' );
$end = false === $endTs ? $start : gmdate( 'Y-m-d H:i:s', $endTs );
$rows = array_map(
static function ( Payment $payment ): array {
+97 -11
View File
@@ -25,6 +25,10 @@ class PaymentRepository {
'status' => $payment->status,
'tax_rate' => $payment->taxRate,
'tax_amount' => $payment->taxAmount,
'credit_applied' => $payment->creditApplied,
'due_date' => $payment->dueDate,
'period_key' => $payment->periodKey,
'notice_batch' => $payment->noticeBatch,
'etransfer_email' => $payment->etransferEmail,
'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
'receipt_number' => $payment->receiptNumber,
@@ -32,7 +36,7 @@ class PaymentRepository {
'paid_at' => $payment->paidAt,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s' ]
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
);
return $this->db->insert_id;
@@ -55,7 +59,8 @@ class PaymentRepository {
public function findByStripeIntentId( string $intentId ): ?Payment {
$row = $this->db->get_row(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1",
'SELECT * FROM %i WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1',
$this->table,
$intentId
)
);
@@ -73,18 +78,35 @@ class PaymentRepository {
);
}
/**
* Add to the account credit applied against a payment, reducing what the student
* still owes on it (`Payment::netDue()`). Accumulates, so a second application
* adds to the first.
*/
public function addCreditApplied( int $id, float $amount ): bool {
$sql = $this->db->prepare(
'UPDATE %i SET credit_applied = credit_applied + %f WHERE id = %d',
$this->table,
$amount,
$id
);
return null !== $sql && false !== $this->db->query( $sql );
}
/**
* Set a payment's tax rate and recompute the tax amount from its subtotal.
*/
public function updateTax( int $id, float $rate ): bool {
return false !== $this->db->query(
$this->db->prepare(
"UPDATE {$this->table} SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d",
$sql = $this->db->prepare(
'UPDATE %i SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d',
$this->table,
$rate,
$rate,
$id
)
);
return null !== $sql && false !== $this->db->query( $sql );
}
/**
@@ -94,8 +116,8 @@ class PaymentRepository {
* @return list<Payment>
*/
public function findPaidBetween( string $from, string $to, int $instructorId = 0 ): array {
$sql = "SELECT * FROM {$this->table} WHERE status = %s AND paid_at >= %s AND paid_at < %s";
$params = [ Payment::STATUS_PAID, $from, $to ];
$sql = 'SELECT * FROM %i WHERE status = %s AND paid_at >= %s AND paid_at < %s';
$params = [ $this->table, Payment::STATUS_PAID, $from, $to ];
if ( $instructorId > 0 ) {
$sql .= ' AND instructor_id = %d';
@@ -111,16 +133,62 @@ class PaymentRepository {
public function findById( int $id ): ?Payment {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Payment::fromRow( $row ) : null;
}
/**
* Tag a set of payments with a shared notice-batch reference the payments the
* daily scan emailed a student together, so the admin can see which pending
* payments a single lump-sum e-transfer covers. No-op for an empty id list.
*
* @param list<int> $ids
*/
public function assignNoticeBatch( array $ids, string $batch ): void {
if ( [] === $ids ) {
return;
}
$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
$sql = $this->db->prepare(
"UPDATE %i SET notice_batch = %s WHERE id IN ( {$placeholders} )",
$this->table,
$batch,
...$ids
);
if ( null !== $sql ) {
$this->db->query( $sql );
}
}
/**
* Whether a scheduled payment already exists for a registration and billing
* period. The daily billing scan uses this to avoid double-billing an
* enrolment for the same session (weekly) or month (monthly). A voided
* (`failed`) row still counts so a cancelled charge is not silently re-created.
*/
public function existsForPeriod( string $registrationType, int $registrationId, string $periodKey ): bool {
$found = $this->db->get_var(
$this->db->prepare(
'SELECT id FROM %i WHERE registration_type = %s AND registration_id = %d AND period_key = %s LIMIT 1',
$this->table,
$registrationType,
$registrationId,
$periodKey
)
);
return null !== $found;
}
public function findByRegistration( string $registrationType, int $registrationId ): ?Payment {
$row = $this->db->get_row(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id DESC LIMIT 1",
'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id DESC LIMIT 1',
$this->table,
$registrationType,
$registrationId
)
@@ -129,6 +197,23 @@ class PaymentRepository {
return $row ? Payment::fromRow( $row ) : null;
}
/**
* Every payment for a student, newest first (admin payment history).
*
* @return list<Payment>
*/
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d ORDER BY created_at DESC, id DESC',
$this->table,
$studentId
)
);
return array_map( Payment::fromRow( ... ), $rows ?? [] );
}
/**
* Pending payments, newest first (studio-admin confirmation queue).
*
@@ -137,7 +222,8 @@ class PaymentRepository {
public function findPending(): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE status = %s ORDER BY created_at DESC",
'SELECT * FROM %i WHERE status = %s ORDER BY created_at DESC',
$this->table,
Payment::STATUS_PENDING
)
);
+189 -4
View File
@@ -21,6 +21,7 @@ class PaymentService {
private EnrollmentRepository $enrollments,
private StudioSettings $settings,
private StripeGateway $stripe,
private CreditRepository $credits,
) {}
/**
@@ -29,8 +30,12 @@ class PaymentService {
* (card via Stripe coming soon; e-transfer confirmed manually). The
* e-transfer destination is frozen now from the offering override or the studio
* default. Returns null when the registration has no price to charge.
*
* A `$dueDate`/`$periodKey` mark a payment generated later by the daily billing
* scan (weekly / monthly) rather than taken at registration; both stay null for
* the pay-now flow.
*/
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null ): ?Payment {
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = null ): ?Payment {
if ( $amount <= 0.0 ) {
return null;
}
@@ -58,6 +63,8 @@ class PaymentService {
status: $status,
taxRate: $taxRate,
taxAmount: $taxAmount,
dueDate: $dueDate,
periodKey: $periodKey,
etransferEmail: $etransferEmail,
)
);
@@ -71,6 +78,26 @@ class PaymentService {
return $this->payments->findById( $id );
}
/**
* Whether a scheduled payment already exists for a registration and billing
* period the daily billing scan's dedup check for group enrolments (whose one
* row maps to many periodic charges). Delegates to the ledger.
*/
public function scheduledPaymentExists( string $type, int $registrationId, string $periodKey ): bool {
return $this->payments->existsForPeriod( $type, $registrationId, $periodKey );
}
/**
* Tag the payments the daily scan emailed a student together with a shared
* notice-batch reference, so a lump-sum e-transfer can be reconciled to the
* pending payments it covers. Delegates to the ledger.
*
* @param list<int> $ids
*/
public function assignNoticeBatch( array $ids, string $batch ): void {
$this->payments->assignNoticeBatch( $ids, $batch );
}
/**
* Studio-admin confirmation that a pending payment (e-transfer) was received.
* Marks it paid, confirms the registration, and emails the receipt.
@@ -89,6 +116,154 @@ class PaymentService {
return true;
}
/**
* Void the still-pending payment of a cancelled registration so it drops
* out of the confirmation queue. Paid payments are left alone refunds
* are a manual, admin-side decision. Scheduled payments (weekly / monthly)
* are also left alone: a monthly charge can cover several lessons and may
* already be collected, so cancelling one lesson must never void it or
* trigger a rebill.
*/
public function voidPending( ?int $paymentId ): void {
if ( null === $paymentId ) {
return;
}
$payment = $this->payments->findById( $paymentId );
if ( null !== $payment && ! $payment->isScheduled() && Payment::STATUS_PENDING === $payment->status ) {
$this->payments->updateStatus( $paymentId, Payment::STATUS_FAILED );
}
}
/**
* Credit a student for a cancelled lesson they had already paid for. The credit
* is one lesson's share of the covering payment's total (including tax) the
* whole total for a single-lesson payment, or `total ÷ lessons covered` for a
* payment that spans several (a monthly scheduled charge, or a weekly series paid
* upfront). The original payment is left untouched; the credit is applied to the
* student's future scheduled-billing charges. Returns null when the lesson was
* never paid, has no covering payment, or was already credited.
*/
public function creditForCancelledLesson( Lesson $lesson ): ?Credit {
if ( null === $lesson->id ) {
return null;
}
$paymentId = $lesson->paymentId;
if ( null === $paymentId && null !== $lesson->seriesId ) {
// Series lessons other than the anchor carry no payment_id of their own;
// the whole reservation is paid through the anchor's payment.
$anchor = $this->payments->findByRegistration( Payment::REG_LESSON, $lesson->seriesId );
$paymentId = $anchor?->id;
}
if ( null === $paymentId ) {
return null;
}
$payment = $this->payments->findById( $paymentId );
if ( null === $payment || ! $payment->isPaid() ) {
return null;
}
if ( $this->credits->existsForLesson( $lesson->id ) ) {
return null;
}
$share = round( $payment->total() / $this->coveredLessonCount( $lesson, $payment ), 2 );
if ( $share <= 0.0 ) {
return null;
}
$id = $this->credits->insert(
new Credit(
studentId: $payment->studentId,
amount: $share,
remaining: $share,
currency: $payment->currency,
sourcePaymentId: $payment->id,
sourceLessonId: $lesson->id,
reason: sprintf(
/* translators: %d: cancelled lesson id */
__( 'Credit for cancelled lesson #%d', 'unsupervised-schedular' ),
$lesson->id
),
)
);
return $this->credits->findById( $id );
}
/**
* How many lessons the covering payment was billed for, so its total can be split
* into a per-lesson credit. A weekly series paid upfront (unscheduled) covers the
* whole series; every other case a single booking, a weekly scheduled lesson
* (one payment each), or a monthly scheduled charge (payment linked to each
* lesson) is answered by how many lessons point at the payment. Never below one.
*/
private function coveredLessonCount( Lesson $lesson, Payment $payment ): int {
if ( ! $payment->isScheduled() && null !== $lesson->seriesId ) {
return max( 1, $this->bookings->countBySeries( $lesson->seriesId ) );
}
return max( 1, $this->bookings->countByPaymentId( (int) $payment->id ) );
}
/**
* Apply a student's available credit balance against a set of freshly-created
* pending payments (the ones a billing scan just generated for them), oldest
* charge first. Each payment's `credit_applied` is raised by the amount covered;
* a payment fully covered is marked paid-by-credit and its registration confirmed
* so it leaves the confirmation queue. The credit ledger is drawn down by the
* total applied. Returns a map of payment id to the credit applied to it, so the
* caller can reflect the reduction on the student's notice.
*
* @param list<Payment> $payments
* @return array<int, float>
*/
public function applyCredits( int $studentId, array $payments ): array {
$balance = $this->credits->availableBalance( $studentId );
if ( $balance <= 0.0 ) {
return [];
}
$applied = [];
$consumed = 0.0;
foreach ( $payments as $payment ) {
if ( null === $payment->id || $balance <= 0.0 ) {
continue;
}
$owing = $payment->netDue();
if ( $owing <= 0.0 ) {
continue;
}
$amount = round( min( $balance, $owing ), 2 );
if ( $amount <= 0.0 ) {
continue;
}
$this->payments->addCreditApplied( $payment->id, $amount );
// Fully covered by credit: settle it so it drops out of the pending queue.
if ( $amount >= $owing ) {
$this->payments->markPaid( $payment->id, 'USC-' . $payment->id );
$this->confirmRegistration( $payment->registrationType, $payment->registrationId );
}
$applied[ $payment->id ] = $amount;
$balance = round( $balance - $amount, 2 );
$consumed = round( $consumed + $amount, 2 );
}
if ( $consumed > 0.0 ) {
$this->credits->consume( $studentId, $consumed );
}
return $applied;
}
/**
* Resolve the client-side payment step for a freshly created registration.
* For a card payment a Stripe PaymentIntent is created (or replayed
@@ -179,10 +354,20 @@ class PaymentService {
}
private function confirmRegistration( string $type, int $registrationId ): void {
if ( Payment::REG_LESSON === $type ) {
$this->bookings->updateStatus( $registrationId, Lesson::STATUS_CONFIRMED );
}
if ( Payment::REG_LESSON !== $type ) {
// Group enrolments are already `active`; no status change on payment.
return;
}
// A weekly reservation's payment is linked to its anchor lesson but pays
// for the whole series, so settling it confirms every lesson in the series.
$lesson = $this->bookings->findById( $registrationId );
if ( null !== $lesson && null !== $lesson->seriesId ) {
$this->bookings->updateStatusForSeries( $lesson->seriesId, Lesson::STATUS_CONFIRMED );
return;
}
$this->bookings->updateStatus( $registrationId, Lesson::STATUS_CONFIRMED );
}
private function linkPayment( string $type, int $registrationId, int $paymentId ): void {
+382
View File
@@ -0,0 +1,382 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
/**
* Generates the pending payments that scheduled-billing offerings (weekly /
* monthly) owe as they come due, then emails each student one itemised notice.
*
* Runs from the daily WP-Cron action `us_generate_due_payments`. It is
* self-healing: every run re-scans from the current ledger state, so a missed
* day is simply picked up the next time. Dedup keeps a second run from
* double-billing private lessons via `us_lessons.payment_id`, group enrolments
* via `us_payments.period_key`.
*/
class ScheduledBillingRunner {
public const HOOK = 'us_generate_due_payments';
public function __construct(
private PaymentService $payments,
private BookingRepository $bookings,
private EnrollmentRepository $enrollments,
private OfferingRepository $offerings,
private PaymentDueMailer $mailer,
) {}
public function register(): void {
add_action( self::HOOK, [ $this, 'run' ] );
}
/**
* Generate every payment now due and send the consolidated notices.
*/
public function run(): void {
$now = $this->now();
// One notice bucket per student, filled as pending payments are created and
// flushed to a single email at the end, so a student billed for several
// lessons on one day is emailed once — never once per lesson. Each entry keeps
// the created payment and its label; credits are applied across the whole
// bucket before the notice is built, so a student's account credit offsets the
// run's charges oldest-first.
$buckets = [];
$this->billPrivateLessons( $now, $buckets );
$this->billGroupEnrollments( $now, $buckets );
$this->sendNotices( $buckets );
}
/**
* Private-lesson billing. Weekly lessons are billed one payment each once they
* are within 24 hours; monthly lessons are grouped per calendar month and billed
* one payment for the month once its 1st has arrived.
*
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets ): void {
$today = $now->format( 'Y-m-d' );
$monthly = [];
foreach ( $this->bookings->findUnbilledScheduledLessons() as $row ) {
$price = Val::float( $row->price ?? 0 );
if ( $price <= 0.0 ) {
continue;
}
$startRaw = Val::string( $row->start_dt ?? '' );
$start = false !== strtotime( $startRaw ) ? new \DateTimeImmutable( $startRaw ) : null;
if ( null === $start ) {
continue;
}
$lessonId = Val::int( $row->id );
$studentId = Val::int( $row->student_id );
$instructorId = Val::int( $row->instructor_id );
$currency = Val::string( $row->currency ?? 'CAD' );
$etransfer = Val::stringOrNull( $row->etransfer_email ?? null );
$title = Val::string( $row->title ?? '' );
if ( Offering::BILLING_MONTHLY === Val::string( $row->billing_mode ?? '' ) ) {
$monthly[ $studentId . ':' . Val::int( $row->offering_id ) . ':' . $start->format( 'Y-m' ) ][] = [
'lesson_id' => $lessonId,
'student_id' => $studentId,
'instructor_id' => $instructorId,
'currency' => $currency,
'etransfer' => $etransfer,
'title' => $title,
'price' => $price,
'start' => $start,
];
continue;
}
// Weekly: due 24 hours before the lesson.
$due = $start->modify( '-1 day' );
if ( $due->format( 'Y-m-d H:i:s' ) > $now->format( 'Y-m-d H:i:s' ) ) {
continue;
}
$this->bill(
$buckets,
Payment::REG_LESSON,
$lessonId,
$studentId,
$instructorId,
$price,
$currency,
$etransfer,
$due->format( 'Y-m-d' ),
$start->format( 'Y-m-d' ),
$title . ' — ' . $start->format( 'M j, Y' )
);
}
$this->billMonthlyLessonGroups( $today, $monthly, $buckets );
}
/**
* Bill each month's worth of monthly private lessons as one payment (count ×
* fee), once the month's 1st has arrived. The payment links to the earliest
* lesson in the group; the rest are pointed at it so they are not re-billed.
*
* @param array<string, list<array{lesson_id: int, student_id: int, instructor_id: int, currency: string, etransfer: ?string, title: string, price: float, start: \DateTimeImmutable}>> $monthly
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets ): void {
foreach ( $monthly as $group ) {
$first = $group[0]['start'];
$monthStart = $first->format( 'Y-m-01' );
// Not billable until the 1st of the lesson's month has arrived.
if ( $monthStart > $today ) {
continue;
}
$lessonIds = array_map( static fn( array $l ): int => $l['lesson_id'], $group );
$anchorId = $lessonIds[0];
$count = count( $group );
$payment = $this->bill(
$buckets,
Payment::REG_LESSON,
$anchorId,
$group[0]['student_id'],
$group[0]['instructor_id'],
$group[0]['price'] * $count,
$group[0]['currency'],
$group[0]['etransfer'],
$monthStart,
$first->format( 'Y-m' ),
sprintf(
/* translators: 1: offering title, 2: month, 3: number of lessons */
_n( '%1$s (%2$s): %3$d lesson', '%1$s (%2$s): %3$d lessons', $count, 'unsupervised-schedular' ),
$group[0]['title'],
$first->format( 'F Y' ),
$count
)
);
if ( null === $payment ) {
continue;
}
// createForRegistration links the anchor; point the rest of the month at
// the same payment so the next scan sees them as billed.
foreach ( array_slice( $lessonIds, 1 ) as $extraId ) {
$this->bookings->setPaymentId( $extraId, (int) $payment->id );
}
}
}
/**
* Group-class billing off each active enrolment's concrete session windows.
* Weekly bills one payment per session (24h before); monthly bills one payment
* per month (on the 1st) for that month's sessions. Dedup is by `period_key`
* since a single enrolment maps to many periodic charges.
*
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets ): void {
$today = $now->format( 'Y-m-d' );
$offerings = [];
foreach ( $this->enrollments->findActiveByBillingModes( Offering::SCHEDULED_BILLING_MODES ) as $enrollment ) {
$offeringId = $enrollment->offeringId;
if ( ! array_key_exists( $offeringId, $offerings ) ) {
$offerings[ $offeringId ] = $this->offerings->findById( $offeringId );
}
$offering = $offerings[ $offeringId ];
if ( null === $offering || $offering->price <= 0.0 ) {
continue;
}
$windows = $offering->sessionWindows();
if ( [] === $windows ) {
continue;
}
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
$this->billGroupMonthly( $now, $today, $enrollment, $offering, $windows, $buckets );
} else {
$this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets );
}
}
}
/**
* Bill one payment per group-class session that is now within 24 hours.
*
* @param list<array{start: string, end: string}> $windows
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void {
foreach ( $windows as $window ) {
$start = new \DateTimeImmutable( $window['start'] );
$due = $start->modify( '-1 day' );
if ( $due->format( 'Y-m-d H:i:s' ) > $now->format( 'Y-m-d H:i:s' ) ) {
continue;
}
$periodKey = $start->format( 'Y-m-d' );
if ( $this->payments->scheduledPaymentExists( Payment::REG_ENROLLMENT, (int) $enrollment->id, $periodKey ) ) {
continue;
}
$this->bill(
$buckets,
Payment::REG_ENROLLMENT,
(int) $enrollment->id,
$enrollment->studentId,
$enrollment->instructorId,
$offering->price,
$offering->currency,
$offering->etransferEmail,
$due->format( 'Y-m-d' ),
$periodKey,
$offering->title . ' — ' . $start->format( 'M j, Y' )
);
}
}
/**
* Bill one payment per calendar month of a group class, once its 1st arrives.
*
* @param list<array{start: string, end: string}> $windows
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
// Count this enrolment's sessions per calendar month.
$months = [];
foreach ( $windows as $window ) {
$start = new \DateTimeImmutable( $window['start'] );
$months[ $start->format( 'Y-m' ) ] = ( $months[ $start->format( 'Y-m' ) ] ?? 0 ) + 1;
}
foreach ( $months as $month => $count ) {
$monthStart = ( new \DateTimeImmutable( $month . '-01' ) )->format( 'Y-m-d' );
if ( $monthStart > $today ) {
continue;
}
if ( $this->payments->scheduledPaymentExists( Payment::REG_ENROLLMENT, (int) $enrollment->id, $month ) ) {
continue;
}
$this->bill(
$buckets,
Payment::REG_ENROLLMENT,
(int) $enrollment->id,
$enrollment->studentId,
$enrollment->instructorId,
$offering->price * $count,
$offering->currency,
$offering->etransferEmail,
$monthStart,
$month,
sprintf(
/* translators: 1: offering title, 2: month, 3: number of sessions */
_n( '%1$s (%2$s): %3$d session', '%1$s (%2$s): %3$d sessions', $count, 'unsupervised-schedular' ),
$offering->title,
( new \DateTimeImmutable( $month . '-01' ) )->format( 'F Y' ),
$count
)
);
}
}
/**
* Create one scheduled payment and, when it is pending (not a comp auto-pay),
* add it to the student's notice bucket with the label to show on the notice.
* Credits are applied later, once the whole bucket is known. Returns the created
* payment, or null when there was nothing to charge.
*
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function bill( array &$buckets, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment {
$payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey );
if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
$buckets[ $studentId ][] = [
'payment' => $payment,
'label' => $label,
];
}
return $payment;
}
/**
* For each student, apply any account credit they hold against the run's charges,
* tag the payments they still owe with a shared batch reference, and email them
* one itemised notice. The notice lists each charge at its full amount, then the
* credit applied and the reduced total due; a charge fully covered by credit is
* already settled and carries no reference. A lump-sum e-transfer for the balance
* reconciles to the reference.
*
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function sendNotices( array $buckets ): void {
foreach ( $buckets as $studentId => $entries ) {
$payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries );
$applied = $this->payments->applyCredits( $studentId, $payments );
$items = [];
$batchIds = [];
$creditTotal = 0.0;
foreach ( $entries as $entry ) {
$payment = $entry['payment'];
$id = (int) $payment->id;
$credited = $applied[ $id ] ?? 0.0;
$creditTotal += $credited;
$items[] = [
'label' => $entry['label'],
'amount' => $payment->total(),
'currency' => $payment->currency,
'due_date' => $payment->dueDate,
'etransfer_email' => $payment->etransferEmail,
];
// A charge still carrying a balance is what a lump-sum e-transfer covers;
// one fully settled by credit needs no reconciliation reference.
if ( round( $payment->total() - $credited, 2 ) > 0.0 ) {
$batchIds[] = $id;
}
}
$reference = [] !== $batchIds ? $this->reference() : '';
$this->payments->assignNoticeBatch( $batchIds, $reference );
$user = get_userdata( $studentId );
if ( $user instanceof \WP_User ) {
$this->mailer->send( $user, $items, $reference, round( $creditTotal, 2 ) );
}
}
}
/**
* A short, human-quotable reference shared by every payment in one student's
* notice, printed on the email and shown in the admin payments queue.
*/
private function reference(): string {
return strtoupper( substr( str_replace( '-', '', Val::string( wp_generate_uuid4() ) ), 0, 10 ) );
}
private function now(): \DateTimeImmutable {
$mysql = Val::string( current_time( 'mysql' ) );
return false !== strtotime( $mysql ) ? new \DateTimeImmutable( $mysql ) : new \DateTimeImmutable();
}
}
+2 -2
View File
@@ -67,8 +67,8 @@ class StripeGateway {
* Seam around the Stripe PaymentIntents create call so tests can stub the
* network request.
*
* @param array<string, mixed> $params
* @param array<string, mixed> $options
* @param array{amount: int, currency: string, metadata: array<string, string>, description: string} $params
* @param array{idempotency_key?: string} $options
*/
protected function paymentIntentsCreate( array $params, array $options ): PaymentIntent {
return $this->client()->paymentIntents->create( $params, $options );
+107 -13
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class StudioSettings {
@@ -15,12 +16,32 @@ class StudioSettings {
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
public const OPT_HST_RATE = 'us_hst_rate';
/**
* Studio-default cancellation cutoff, stored in hours. A student may not
* cancel a lesson once it starts within this many hours. Displayed to the
* admin in days; an offering may override it with its own hour value.
*/
public const OPT_CANCELLATION_CUTOFF_HOURS = 'us_cancellation_cutoff_hours';
public const DEFAULT_CANCELLATION_CUTOFF_HOURS = 24;
public const OPT_REGISTRATION_MODE = 'us_registration_mode';
public const MODE_INVITE = 'invite';
public const MODE_SELF_APPROVAL = 'self_approval';
/**
* Snapshots of the two core WordPress options this feature takes over while
* open registration is enabled, so disabling restores them exactly rather
* than clobbering a site that set them for its own reasons.
*/
public const OPT_PREV_USERS_CAN_REGISTER = 'us_registration_prev_can_register';
public const OPT_PREV_DEFAULT_ROLE = 'us_registration_prev_default_role';
public function publishableKey(): string {
return (string) get_option( self::OPT_PUBLISHABLE, '' );
return Val::string( get_option( self::OPT_PUBLISHABLE, '' ) );
}
public function secretKey(): string {
return (string) get_option( self::OPT_SECRET, '' );
return Val::string( get_option( self::OPT_SECRET, '' ) );
}
/**
@@ -28,7 +49,7 @@ class StudioSettings {
* webhook requests genuinely came from Stripe. Empty until configured.
*/
public function webhookSecret(): string {
return (string) get_option( self::OPT_WEBHOOK_SECRET, '' );
return Val::string( get_option( self::OPT_WEBHOOK_SECRET, '' ) );
}
public function mode(): string {
@@ -36,7 +57,7 @@ class StudioSettings {
}
public function currency(): string {
$currency = (string) get_option( self::OPT_CURRENCY, 'CAD' );
$currency = Val::string( get_option( self::OPT_CURRENCY, 'CAD' ) );
return '' !== $currency ? strtoupper( $currency ) : 'CAD';
}
@@ -46,14 +67,23 @@ class StudioSettings {
* no override).
*/
public function etransferEmail(): string {
return (string) get_option( self::OPT_ETRANSFER_EMAIL, '' );
return Val::string( get_option( self::OPT_ETRANSFER_EMAIL, '' ) );
}
/**
* Default HST/tax rate as a percentage (e.g. 13.0). 0 means no tax.
*/
public function hstRate(): float {
return max( 0.0, (float) get_option( self::OPT_HST_RATE, 0 ) );
return max( 0.0, Val::float( get_option( self::OPT_HST_RATE, 0 ) ) );
}
/**
* The studio-default cancellation cutoff in hours: a student cannot cancel a
* lesson once it starts within this window. 0 means students may cancel any
* time. Offerings without their own override inherit this value.
*/
public function cancellationCutoffHours(): int {
return max( 0, Val::int( get_option( self::OPT_CANCELLATION_CUTOFF_HOURS, self::DEFAULT_CANCELLATION_CUTOFF_HOURS ) ) );
}
/**
@@ -64,6 +94,24 @@ class StudioSettings {
return '' !== $this->publishableKey() && '' !== $this->secretKey();
}
/**
* Which student registration mode is active: `invite` (default) only a
* valid invite token grants the registration form or `self_approval`
* anyone may sign up, confirm their email, and await studio approval.
*/
public function registrationMode(): string {
return self::MODE_SELF_APPROVAL === get_option( self::OPT_REGISTRATION_MODE, self::MODE_INVITE )
? self::MODE_SELF_APPROVAL
: self::MODE_INVITE;
}
/**
* Whether anyone may self-register (the `self_approval` mode).
*/
public function openRegistrationEnabled(): bool {
return self::MODE_SELF_APPROVAL === $this->registrationMode();
}
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' ) );
@@ -85,6 +133,9 @@ class StudioSettings {
$etransferEmail = $this->etransferEmail();
$hstRate = $this->hstRate();
$stripeConfigured = $this->isStripeConfigured();
$openRegistration = $this->openRegistrationEnabled();
// Stored in hours, surfaced to the admin in whole days.
$cancellationCutoffDays = (int) round( $this->cancellationCutoffHours() / 24 );
include USC_PLUGIN_DIR . 'templates/admin/settings.php';
}
@@ -92,23 +143,66 @@ class StudioSettings {
private function save(): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$mode = sanitize_key( wp_unslash( $_POST['mode'] ?? 'test' ) );
update_option( self::OPT_PUBLISHABLE, sanitize_text_field( wp_unslash( $_POST['publishable_key'] ?? '' ) ) );
$mode = sanitize_key( Val::string( wp_unslash( $_POST['mode'] ?? 'test' ) ) );
update_option( self::OPT_PUBLISHABLE, sanitize_text_field( Val::string( wp_unslash( $_POST['publishable_key'] ?? '' ) ) ) );
// Secret fields are write-only: a blank submission keeps the stored secret,
// so an admin saving other settings never wipes the keys.
$secretKey = sanitize_text_field( wp_unslash( $_POST['secret_key'] ?? '' ) );
$secretKey = sanitize_text_field( Val::string( wp_unslash( $_POST['secret_key'] ?? '' ) ) );
if ( '' !== $secretKey ) {
update_option( self::OPT_SECRET, $secretKey );
}
$webhookSecret = sanitize_text_field( wp_unslash( $_POST['webhook_secret'] ?? '' ) );
$webhookSecret = sanitize_text_field( Val::string( wp_unslash( $_POST['webhook_secret'] ?? '' ) ) );
if ( '' !== $webhookSecret ) {
update_option( self::OPT_WEBHOOK_SECRET, $webhookSecret );
}
update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' );
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) );
update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
$hstRate = isset( $_POST['hst_rate'] ) ? (float) $_POST['hst_rate'] : 0.0;
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'] ?? '' ) ) ) );
// 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 ) );
// The cutoff is entered in whole days but stored in hours.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::int() coerces to int; slashes cannot survive numeric coercion.
$cutoffDays = isset( $_POST['cancellation_cutoff_days'] ) ? max( 0, Val::int( $_POST['cancellation_cutoff_days'] ) ) : 0;
update_option( self::OPT_CANCELLATION_CUTOFF_HOURS, $cutoffDays * 24 );
$this->applyRegistrationMode( isset( $_POST['open_registration'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* Enable or disable open (self-approval) registration, mirroring the change
* into the two core WordPress options it depends on.
*
* Enabling snapshots the current `users_can_register` and `default_role`,
* then turns registration on and makes Student the default new-user role.
* Disabling restores that snapshot, so this toggle never permanently
* overwrites a site's own membership settings. Only transitions act, so
* saving unrelated settings leaves the core options untouched.
*/
private function applyRegistrationMode( bool $enable ): void {
$currentlyOpen = $this->openRegistrationEnabled();
if ( $enable && ! $currentlyOpen ) {
update_option( self::OPT_PREV_USERS_CAN_REGISTER, get_option( 'users_can_register' ) ? '1' : '0' );
update_option( self::OPT_PREV_DEFAULT_ROLE, Val::string( get_option( 'default_role', 'subscriber' ) ) );
update_option( 'users_can_register', '1' );
update_option( 'default_role', RoleManager::STUDENT );
update_option( self::OPT_REGISTRATION_MODE, self::MODE_SELF_APPROVAL );
return;
}
if ( ! $enable && $currentlyOpen ) {
$prevCanRegister = '1' === Val::string( get_option( self::OPT_PREV_USERS_CAN_REGISTER, '0' ) );
$prevRole = Val::string( get_option( self::OPT_PREV_DEFAULT_ROLE, 'subscriber' ) );
update_option( 'users_can_register', $prevCanRegister ? '1' : '0' );
update_option( 'default_role', '' !== $prevRole ? $prevRole : 'subscriber' );
delete_option( self::OPT_PREV_USERS_CAN_REGISTER );
delete_option( self::OPT_PREV_DEFAULT_ROLE );
update_option( self::OPT_REGISTRATION_MODE, self::MODE_INVITE );
}
}
}
+55 -4
View File
@@ -3,16 +3,28 @@ declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationLoginGate;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\StudentAdminGuard;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentDueMailer;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\ReceiptMailer;
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
use Unsupervised\Schedular\Payment\StripeGateway;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
@@ -22,6 +34,7 @@ use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Update\UpdateChecker;
class Plugin {
@@ -29,10 +42,30 @@ class Plugin {
load_plugin_textdomain( 'unsupervised-schedular', false, dirname( plugin_basename( USC_PLUGIN_FILE ) ) . '/languages' );
global $wpdb;
if ( ! $wpdb instanceof \wpdb ) {
return;
}
// Re-run install steps when the plugin files were updated without a fresh
// activation (e.g. a deploy), so schema and data migrations still apply.
if ( get_option( 'us_schedular_version' ) !== USC_VERSION ) {
( new Installer() )->run();
}
$availability = new AvailabilityRepository( $wpdb );
$bookings = new BookingRepository( $wpdb );
$offerings = new OfferingRepository( $wpdb );
$questions = new QuestionRepository( $wpdb );
// One-time repair for sites where dbDelta left us_questions.offering_id
// NOT NULL (it does not reliably relax NULL-ability), which breaks
// account-scope registration questions. Guarded by its own flag rather
// than the version gate, since affected sites may already be on the
// current version. The flag is only set once the ALTER succeeds.
if ( '1' !== get_option( 'us_questions_offering_nullable', '' ) && $questions->ensureOfferingNullable() ) {
update_option( 'us_questions_offering_nullable', '1' );
}
$answers = new AnswerRepository( $wpdb );
$policies = new PolicyRepository( $wpdb );
$policyVersions = new PolicyVersionRepository( $wpdb );
@@ -40,17 +73,35 @@ class Plugin {
$acceptances = new AcceptanceRepository( $wpdb );
$invites = new InviteRepository( $wpdb );
$enrollments = new EnrollmentRepository( $wpdb );
$groupAccess = new GroupAccessRepository( $wpdb );
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
$paymentRepo = new PaymentRepository( $wpdb );
$creditRepo = new CreditRepository( $wpdb );
$settings = new StudioSettings();
$resolver = new BillingMethodResolver( $settings );
$stripe = new StripeGateway( $settings );
$paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe );
$paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe, $creditRepo );
// 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();
$bookingPage = new BookingPage();
$loginPage = new LoginPage();
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
$groupClassPage = new GroupClassPage();
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer() ) )->register();
( new UpdateChecker() )->register();
( new RoleManager() )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->register();
( new ShortcodeRegistrar( $invites, $policies, $policyVersions, $acceptances ) )->register();
( new RegistrationLoginGate() )->register();
( new StudentAdminGuard() )->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 ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
}
}
+19 -1
View File
@@ -46,7 +46,8 @@ class AcceptanceRepository {
public function findByRegistration( string $registrationType, int $registrationId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC",
'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC',
$this->table,
$registrationType,
$registrationId
)
@@ -54,4 +55,21 @@ class AcceptanceRepository {
return array_map( PolicyAcceptance::fromRow( ... ), $rows ?? [] );
}
/**
* Find every acceptance a student has recorded, newest first.
*
* @return list<PolicyAcceptance>
*/
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d ORDER BY accepted_at DESC, id DESC',
$this->table,
$studentId
)
);
return array_map( PolicyAcceptance::fromRow( ... ), $rows ?? [] );
}
}
+14 -6
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Val;
class Policy {
public const SCOPE_SIGNUP = 'signup';
@@ -16,6 +18,12 @@ class Policy {
*/
public const VALID_SCOPES = [ self::SCOPE_SIGNUP, self::SCOPE_BOOKING, self::SCOPE_BOTH ];
/** Maximum length of the title, matching the `title` VARCHAR(191) column. */
public const MAX_TITLE_LENGTH = 191;
/** Maximum length of the slug, matching the `slug` VARCHAR(191) column. */
public const MAX_SLUG_LENGTH = 191;
public function __construct(
public readonly string $title,
public readonly string $slug,
@@ -24,13 +32,13 @@ class Policy {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
title: $row->title,
slug: $row->slug,
currentVersionId: null !== $row->current_version_id ? (int) $row->current_version_id : null,
acceptanceScope: $row->acceptance_scope,
id: (int) $row->id,
title: Val::string( $row->title ),
slug: Val::string( $row->slug ),
currentVersionId: Val::intOrNull( $row->current_version_id ),
acceptanceScope: Val::string( $row->acceptance_scope ),
id: Val::int( $row->id ),
);
}
+10 -8
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Val;
class PolicyAcceptance {
public const REG_ACCOUNT = 'account';
@@ -27,15 +29,15 @@ class PolicyAcceptance {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
policyVersionId: (int) $row->policy_version_id,
studentId: (int) $row->student_id,
registrationType: $row->registration_type,
registrationId: (int) $row->registration_id,
ipAddress: $row->ip_address,
acceptedAt: $row->accepted_at,
id: (int) $row->id,
policyVersionId: Val::int( $row->policy_version_id ),
studentId: Val::int( $row->student_id ),
registrationType: Val::string( $row->registration_type ),
registrationId: Val::int( $row->registration_id ),
ipAddress: Val::stringOrNull( $row->ip_address ),
acceptedAt: Val::stringOrNull( $row->accepted_at ),
id: Val::int( $row->id ),
);
}
+12 -9
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PolicyController {
@@ -23,7 +24,7 @@ class PolicyController {
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only policy selector.
$policyId = absint( $_GET['policy_id'] ?? 0 );
$policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) );
$policyList = $this->policies->findAll();
$selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null;
$policyVersions = null !== $selectedPolicy ? $this->versions->findByPolicy( (int) $selectedPolicy->id ) : null;
@@ -34,37 +35,39 @@ class PolicyController {
private function handleFormAction(): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'create_policy' === $action ) {
$title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) );
$slugRaw = sanitize_text_field( wp_unslash( $_POST['slug'] ?? '' ) );
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) );
$slugRaw = sanitize_text_field( Val::string( wp_unslash( $_POST['slug'] ?? '' ) ) );
$slug = sanitize_title( '' !== $slugRaw ? $slugRaw : $title );
$scope = sanitize_key( wp_unslash( $_POST['acceptance_scope'] ?? Policy::SCOPE_BOOKING ) );
$scope = sanitize_key( Val::string( wp_unslash( $_POST['acceptance_scope'] ?? Policy::SCOPE_BOOKING ) ) );
if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) {
$scope = Policy::SCOPE_BOOKING;
}
if ( '' !== $title && '' !== $slug && null === $this->policies->findBySlug( $slug ) ) {
$withinLimits = mb_strlen( $title ) <= Policy::MAX_TITLE_LENGTH && mb_strlen( $slug ) <= Policy::MAX_SLUG_LENGTH;
if ( '' !== $title && '' !== $slug && $withinLimits && null === $this->policies->findBySlug( $slug ) ) {
$this->service->createPolicy( $title, $slug, $scope );
}
return;
}
$policyId = absint( $_POST['policy_id'] ?? 0 );
$policyId = absint( Val::int( $_POST['policy_id'] ?? 0 ) );
if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) {
return;
}
if ( 'add_version' === $action ) {
$body = wp_kses_post( wp_unslash( $_POST['body'] ?? '' ) );
$body = wp_kses_post( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) );
$this->service->addDraftVersion( $policyId, $body );
}
if ( 'publish_version' === $action ) {
$versionId = absint( $_POST['version_id'] ?? 0 );
$versionId = absint( Val::int( $_POST['version_id'] ?? 0 ) );
if ( $versionId > 0 ) {
$this->service->publishVersion( $policyId, $versionId );
}
+38 -11
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PolicyEndpoint {
@@ -13,6 +14,11 @@ class PolicyEndpoint {
private PolicyService $service,
) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void {
register_rest_route(
$route_namespace,
@@ -74,7 +80,7 @@ class PolicyEndpoint {
* `both`-scoped policies).
*/
public function index( \WP_REST_Request $request ): \WP_REST_Response {
$scope = (string) $request->get_param( 'scope' );
$scope = Val::string( $request->get_param( 'scope' ) );
$policies = in_array( $scope, [ Policy::SCOPE_SIGNUP, Policy::SCOPE_BOOKING ], true )
? $this->policies->findForScope( $scope )
: $this->policies->findAll();
@@ -97,7 +103,10 @@ class PolicyEndpoint {
'slug' => $policy->slug,
'policy_version_id' => $version->id,
'version_number' => $version->versionNumber,
'body' => $version->body,
// Bodies are kses'd on every write path, but the booking JS renders
// this HTML raw — sanitise at output too so a missed write path can
// never become stored XSS.
'body' => wp_kses_post( (string) $version->body ),
];
}
@@ -105,22 +114,40 @@ class PolicyEndpoint {
}
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$title = sanitize_text_field( (string) $request->get_param( 'title' ) );
$title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) );
if ( '' === $title ) {
return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) );
}
if ( mb_strlen( $title ) > Policy::MAX_TITLE_LENGTH ) {
return $this->invalid(
sprintf(
/* translators: %d: maximum character count. */
__( 'The policy title must be %d characters or fewer.', 'unsupervised-schedular' ),
Policy::MAX_TITLE_LENGTH
)
);
}
$slugParam = sanitize_text_field( (string) $request->get_param( 'slug' ) );
$slugParam = sanitize_text_field( Val::string( $request->get_param( 'slug' ) ) );
$slug = sanitize_title( '' !== $slugParam ? $slugParam : $title );
if ( '' === $slug ) {
return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) );
}
if ( mb_strlen( $slug ) > Policy::MAX_SLUG_LENGTH ) {
return $this->invalid(
sprintf(
/* translators: %d: maximum character count. */
__( 'The policy slug must be %d characters or fewer.', 'unsupervised-schedular' ),
Policy::MAX_SLUG_LENGTH
)
);
}
if ( null !== $this->policies->findBySlug( $slug ) ) {
return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$scope = (string) ( $request->get_param( 'acceptance_scope' ) ?? Policy::SCOPE_BOOKING );
$scope = Val::string( $request->get_param( 'acceptance_scope' ) ?? Policy::SCOPE_BOOKING );
if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) {
return $this->invalid( __( 'Invalid acceptance scope.', 'unsupervised-schedular' ) );
}
@@ -131,12 +158,12 @@ class PolicyEndpoint {
}
public function addVersion( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$policy = $this->policies->findById( absint( $request->get_param( 'id' ) ) );
$policy = $this->policies->findById( absint( Val::int( $request->get_param( 'id' ) ) ) );
if ( null === $policy ) {
return $this->notFound();
}
$body = wp_kses_post( (string) $request->get_param( 'body' ) );
$body = wp_kses_post( Val::string( $request->get_param( 'body' ) ) );
$id = $this->service->addDraftVersion( (int) $policy->id, $body );
return new \WP_REST_Response( [ 'id' => $id ], 201 );
@@ -152,7 +179,7 @@ class PolicyEndpoint {
return $this->invalid( __( 'Only draft versions can be edited.', 'unsupervised-schedular' ) );
}
$body = wp_kses_post( (string) $request->get_param( 'body' ) );
$body = wp_kses_post( Val::string( $request->get_param( 'body' ) ) );
$this->versions->updateBody( (int) $version->id, $body );
return new \WP_REST_Response(
@@ -170,7 +197,7 @@ class PolicyEndpoint {
return $version;
}
$this->service->publishVersion( (int) $request->get_param( 'id' ), (int) $version->id );
$this->service->publishVersion( Val::int( $request->get_param( 'id' ) ), (int) $version->id );
return new \WP_REST_Response(
[
@@ -199,8 +226,8 @@ class PolicyEndpoint {
* Load the version named in the route and confirm it belongs to the policy.
*/
private function loadVersionForPolicy( \WP_REST_Request $request ): PolicyVersion|\WP_Error {
$policyId = absint( $request->get_param( 'id' ) );
$version = $this->versions->findById( absint( $request->get_param( 'vid' ) ) );
$policyId = absint( Val::int( $request->get_param( 'id' ) ) );
$version = $this->versions->findById( absint( Val::int( $request->get_param( 'vid' ) ) ) );
if ( null === $version || $version->policyId !== $policyId ) {
return $this->notFound();
+4 -3
View File
@@ -36,7 +36,8 @@ class PolicyRepository {
public function findForScope( string $scope ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE acceptance_scope = %s OR acceptance_scope = %s ORDER BY title ASC",
'SELECT * FROM %i WHERE acceptance_scope = %s OR acceptance_scope = %s ORDER BY title ASC',
$this->table,
$scope,
Policy::SCOPE_BOTH
)
@@ -68,7 +69,7 @@ class PolicyRepository {
public function findById( int $id ): ?Policy {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Policy::fromRow( $row ) : null;
@@ -76,7 +77,7 @@ class PolicyRepository {
public function findBySlug( string $slug ): ?Policy {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE slug = %s", $slug )
$this->db->prepare( 'SELECT * FROM %i WHERE slug = %s', $this->table, $slug )
);
return $row ? Policy::fromRow( $row ) : null;
+9 -7
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Val;
class PolicyVersion {
public const STATUS_DRAFT = 'draft';
@@ -25,14 +27,14 @@ class PolicyVersion {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
policyId: (int) $row->policy_id,
versionNumber: (int) $row->version_number,
body: $row->body,
status: $row->status,
publishedAt: $row->published_at,
id: (int) $row->id,
policyId: Val::int( $row->policy_id ),
versionNumber: Val::int( $row->version_number ),
body: Val::stringOrNull( $row->body ),
status: Val::string( $row->status ),
publishedAt: Val::stringOrNull( $row->published_at ),
id: Val::int( $row->id ),
);
}
+4 -3
View File
@@ -59,7 +59,8 @@ class PolicyVersionRepository {
public function findByPolicy( int $policyId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE policy_id = %d ORDER BY version_number DESC",
'SELECT * FROM %i WHERE policy_id = %d ORDER BY version_number DESC',
$this->table,
$policyId
)
);
@@ -69,7 +70,7 @@ class PolicyVersionRepository {
public function findById( int $id ): ?PolicyVersion {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? PolicyVersion::fromRow( $row ) : null;
@@ -80,7 +81,7 @@ class PolicyVersionRepository {
*/
public function maxVersionNumber( int $policyId ): int {
$max = $this->db->get_var(
$this->db->prepare( "SELECT MAX(version_number) FROM {$this->table} WHERE policy_id = %d", $policyId )
$this->db->prepare( 'SELECT MAX(version_number) FROM %i WHERE policy_id = %d', $this->table, $policyId )
);
return null === $max ? 0 : (int) $max;
+13 -9
View File
@@ -3,17 +3,21 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Val;
class Answer {
public const REG_LESSON = 'lesson';
public const REG_ENROLLMENT = 'enrollment';
public const REG_ACCOUNT = 'account';
/**
* Polymorphic registration targets an answer can attach to.
* Polymorphic registration targets an answer can attach to. `account` is used
* by studio-wide questions answered at signup (registration_id = the user ID).
*
* @var list<string>
*/
public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT ];
public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT, self::REG_ACCOUNT ];
public function __construct(
public readonly int $questionId,
@@ -24,14 +28,14 @@ class Answer {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
questionId: (int) $row->question_id,
registrationType: $row->registration_type,
registrationId: (int) $row->registration_id,
studentId: (int) $row->student_id,
answerValue: $row->answer_value,
id: (int) $row->id,
questionId: Val::int( $row->question_id ),
registrationType: Val::string( $row->registration_type ),
registrationId: Val::int( $row->registration_id ),
studentId: Val::int( $row->student_id ),
answerValue: Val::stringOrNull( $row->answer_value ),
id: Val::int( $row->id ),
);
}
+19 -1
View File
@@ -46,7 +46,8 @@ class AnswerRepository {
public function findByRegistration( string $registrationType, int $registrationId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC",
'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC',
$this->table,
$registrationType,
$registrationId
)
@@ -54,4 +55,21 @@ class AnswerRepository {
return array_map( Answer::fromRow( ... ), $rows ?? [] );
}
/**
* Find every answer a student has submitted, newest registration first.
*
* @return list<Answer>
*/
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d ORDER BY id DESC',
$this->table,
$studentId
)
);
return array_map( Answer::fromRow( ... ), $rows ?? [] );
}
}
+38 -11
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Val;
class Question {
public const FIELD_TEXT = 'text';
@@ -10,6 +12,15 @@ class Question {
public const FIELD_SELECT = 'select';
public const FIELD_CHECKBOX = 'checkbox';
/** Maximum length of a question label, matching the `label` VARCHAR(255) column. */
public const MAX_LABEL_LENGTH = 255;
/** Question is scoped to a single offering, asked at booking/enrolment time. */
public const SCOPE_OFFERING = 'offering';
/** Question is studio-wide, asked once at account signup (no offering). */
public const SCOPE_ACCOUNT = 'account';
/**
* All valid field types.
*
@@ -22,38 +33,53 @@ class Question {
self::FIELD_CHECKBOX,
];
/**
* All valid scopes.
*
* @var list<string>
*/
public const VALID_SCOPES = [
self::SCOPE_OFFERING,
self::SCOPE_ACCOUNT,
];
/**
* Build an intake question value object.
*
* @param int|null $offeringId The owning offering, or null for account-scoped questions.
* @param list<string>|null $options Choices for a `select` field.
*/
public function __construct(
public readonly int $offeringId,
public readonly ?int $offeringId,
public readonly string $label,
public readonly string $fieldType = self::FIELD_TEXT,
public readonly ?array $options = null,
public readonly bool $isRequired = false,
public readonly int $sortOrder = 0,
public readonly bool $isActive = true,
public readonly string $scope = self::SCOPE_OFFERING,
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
$options = null;
if ( null !== $row->options && '' !== $row->options ) {
$decoded = json_decode( (string) $row->options, true );
$options = is_array( $decoded ) ? array_values( array_map( 'strval', $decoded ) ) : null;
$decoded = json_decode( Val::string( $row->options ), true );
$options = is_array( $decoded )
? array_values( array_map( static fn( mixed $v ): string => Val::string( $v ), $decoded ) )
: null;
}
return new self(
offeringId: (int) $row->offering_id,
label: $row->label,
fieldType: $row->field_type,
offeringId: Val::intOrNull( $row->offering_id ),
label: Val::string( $row->label ),
fieldType: Val::string( $row->field_type ),
options: $options,
isRequired: (bool) $row->is_required,
sortOrder: (int) $row->sort_order,
isActive: (bool) $row->is_active,
id: (int) $row->id,
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 ),
);
}
@@ -66,6 +92,7 @@ class Question {
return [
'id' => $this->id,
'offering_id' => $this->offeringId,
'scope' => $this->scope,
'label' => $this->label,
'field_type' => $this->fieldType,
'options' => $this->options,
+44 -15
View File
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
class QuestionController {
@@ -22,8 +23,13 @@ class QuestionController {
$userId = get_current_user_id();
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only offering selector.
$offeringId = absint( $_GET['offering_id'] ?? 0 );
// The selector posts either an offering id or the sentinel `account`.
// Account-signup questions are studio-wide, so only studio admins manage them.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only selector.
$selection = sanitize_text_field( Val::string( wp_unslash( $_GET['offering_id'] ?? '' ) ) );
$accountScope = $manageAll && Question::SCOPE_ACCOUNT === $selection;
$offeringId = $accountScope ? 0 : absint( Val::int( $selection ) );
$offeringList = $manageAll ? $this->offerings->findAll() : $this->offerings->findAll( $userId );
$selectedOffering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
@@ -32,7 +38,13 @@ class QuestionController {
}
$questions = null;
if ( null !== $selectedOffering ) {
if ( $accountScope ) {
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_question_action' ) ) {
$this->handleFormAction( null );
}
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT );
} elseif ( null !== $selectedOffering ) {
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_question_action' ) ) {
$this->handleFormAction( $selectedOffering );
}
@@ -43,20 +55,24 @@ class QuestionController {
include USC_PLUGIN_DIR . 'templates/admin/questions.php';
}
private function handleFormAction( Offering $offering ): void {
/**
* Handle an add/delete action for the given context: an offering, or account
* scope when $offering is null.
*/
private function handleFormAction( ?Offering $offering ): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) {
$this->addQuestion( (int) $offering->id );
$this->addQuestion( $offering );
}
if ( 'delete' === $action ) {
$questionId = absint( $_POST['question_id'] ?? 0 );
$questionId = absint( Val::int( $_POST['question_id'] ?? 0 ) );
if ( $questionId > 0 ) {
$question = $this->questions->findById( $questionId );
if ( $question && $question->offeringId === (int) $offering->id ) {
if ( $question && $this->belongsToContext( $question, $offering ) ) {
$this->questions->delete( $questionId );
}
}
@@ -64,28 +80,41 @@ class QuestionController {
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
private function addQuestion( int $offeringId ): void {
private function addQuestion( ?Offering $offering ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$label = sanitize_text_field( wp_unslash( $_POST['label'] ?? '' ) );
$fieldType = sanitize_key( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) );
$label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) );
$fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) );
if ( '' === $label || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
if ( '' === $label || mb_strlen( $label ) > Question::MAX_LABEL_LENGTH || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
return;
}
$this->questions->insert(
new Question(
offeringId: $offeringId,
offeringId: null === $offering ? null : (int) $offering->id,
label: $label,
fieldType: $fieldType,
options: $this->parseOptions( sanitize_textarea_field( wp_unslash( $_POST['options'] ?? '' ) ) ),
options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ),
isRequired: isset( $_POST['is_required'] ),
sortOrder: absint( $_POST['sort_order'] ?? 0 ),
sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ),
scope: null === $offering ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING,
)
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* Whether a question belongs to the current editing context the given
* offering, or account scope when $offering is null.
*/
private function belongsToContext( Question $question, ?Offering $offering ): bool {
if ( null === $offering ) {
return Question::SCOPE_ACCOUNT === $question->scope;
}
return $question->offeringId === (int) $offering->id;
}
private function canManageOffering( Offering $offering, int $userId, bool $manageAll ): bool {
return $manageAll || $offering->instructorId === $userId;
}

Some files were not shown because too many files have changed in this diff Show More