52 Commits
Author SHA1 Message Date
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
141 changed files with 9176 additions and 729 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
+79
View File
@@ -0,0 +1,79 @@
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
- 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
release_id="$(curl -fsS -X POST "${api}/releases" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"prerelease\":${prerelease}}" \
| jq -r '.id')"
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
+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
+135
View File
@@ -33,3 +33,138 @@
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;
}
.us-my-lesson-actions {
display: flex;
gap: 12px;
align-items: center;
}
.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-view-toggle {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.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;
}
+219
View File
@@ -0,0 +1,219 @@
/* 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 } = 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),
});
}
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 },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('Logged-out visitors', 'unsupervised-schedular') },
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 },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('After email confirmation', 'unsupervised-schedular') },
el(PageSelect, {
label: __('Sign-in page', 'unsupervised-schedular'),
help: __('Where the sign-in link shown after a student confirms their email address sends them.', 'unsupervised-schedular'),
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }),
})
),
},
{
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.', '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 }],
},
});
});
}());
+274 -22
View File
@@ -6,6 +6,7 @@
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;
@@ -43,7 +44,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 +60,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 +76,39 @@
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;
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);
}
slotList.innerHTML = groupByDay(slots).map(([key, daySlots]) => `
// 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));
}
function toggleHtml() {
return `
<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>`;
}
// Agenda-style calendar: available slots grouped by day.
function listHtml() {
return groupByDay(allSlots).map(([key, daySlots]) => `
<div class="us-day">
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
${daySlots.map((slot) => `
@@ -81,10 +119,69 @@
`).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() {
const byDay = new Map(groupByDay(allSlots));
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() {
if (!allSlots.length) {
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
return;
}
// Anchor the week view to the week of the earliest open slot (the API
// returns slots ordered by start), so the first look is never empty.
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(allSlots[0].start_dt));
slotList.innerHTML = toggleHtml() + (view === 'week' ? weekHtml() : listHtml());
wireCalendarEvents();
}
function wireCalendarEvents() {
document.getElementById('us-view-list').addEventListener('click', () => {
view = 'list';
render();
});
document.getElementById('us-view-week').addEventListener('click', () => {
view = 'week';
render();
});
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 +211,84 @@
</div>`;
}
// Active private-lesson offerings per instructor, so revisiting the
// registration form does not refetch the same catalog.
const offeringCache = new Map();
function instructorOfferings(instructorId) {
if (offeringCache.has(instructorId)) {
return Promise.resolve(offeringCache.get(instructorId));
}
return apiFetch(`offerings?instructor_id=${instructorId}&kind=private_lesson`).then((list) => {
offeringCache.set(instructorId, list);
return list;
});
}
// "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([]),
instructorOfferings(Number(slot.instructor_id)),
apiFetch('policies?scope=booking'),
])
.then(([questions, policies]) => {
renderRegistration(slot, offeringId, questions, policies);
.then(([offerings, policies]) => {
renderRegistration(slot, offerings, 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>`;
}
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, offerings, policies) {
const tiedId = Number(slot.offering_id) || 0;
const tied = tiedId ? offerings.find((o) => Number(o.id) === tiedId) : null;
// Generic slots offer every lesson type that fits the slot's length.
const choices = tiedId
? []
: offerings.filter((o) => !o.duration_minutes || Number(o.duration_minutes) === Number(slot.duration_minutes));
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 +297,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 +308,42 @@
</form>
</div>`;
// The intake questions belong to the selected offering, so they follow
// the picker instead of being fixed at render time.
let selectedId = tiedId;
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,11 +370,68 @@
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);
}
function renderMyLessons(lessons) {
const upcoming = lessons.filter((l) => l.start_dt);
if (!upcoming.length) {
myLessons.innerHTML = '';
return;
}
myLessons.innerHTML = `
<div class="us-my-lessons">
<h3>Your upcoming lessons</h3>
${upcoming.map((l) => `
<div class="us-my-lesson">
<span>${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</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>
`).join('')}
</div>`;
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';
@@ -194,8 +442,12 @@
clearError();
slotList.style.display = 'block';
confirm.style.display = 'none';
loadMyLessons();
apiFetch('availability')
.then(renderSlots)
.then((slots) => {
allSlots = slots;
render();
})
.catch((err) => showError(err.message));
}
+52 -7
View File
@@ -10,6 +10,10 @@
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.
const singleOfferingId = Number(app.dataset.offering || 0);
function apiFetch(path, options = {}) {
return fetch(restUrl + path, {
...options,
@@ -68,20 +72,45 @@
</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)`;
}
function renderClasses(offerings, enrolledOfferingIds) {
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>
${termLabel(o) ? `<p>${escHtml(termLabel(o))}</p>` : ''}
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${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>
${enrolledOfferingIds.has(Number(o.id))
? '<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>'
: `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`}
</div>
`).join('');
@@ -142,7 +171,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 +190,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 Set(enrollments
.filter((e) => e.status === 'active')
.map((e) => Number(e.offering_id)))
))
.catch((err) => showError(err.message));
}
+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"
+98 -20
View File
@@ -4,27 +4,77 @@
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) |
| `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) |
## Policy Acceptance Scope
Policies declare **when** they must be accepted via `us_policies.acceptance_scope`:
@@ -34,19 +84,38 @@ 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.
## 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`.
## Token Redirect
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
@@ -56,16 +125,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`
+41 -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,44 @@ 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).
## REST API
| Method | Endpoint | Permission |
@@ -45,13 +58,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`
+99
View File
@@ -0,0 +1,99 @@
# 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/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 the "Sign in to your account" link points to after a student confirms their email. `0` = the WordPress login screen. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. `0` = browse all classes. 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 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.
## 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.
- **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.
+25 -7
View File
@@ -15,7 +15,19 @@ 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
A group class offering carries `term_start`/`term_end` (see `offerings.md`):
one-off classes end the day they start; weekly classes run a set number of
sessions. The class card on the enrolment page shows the date or date range
with the session count.
## 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.
@@ -33,7 +45,10 @@ a class at capacity rejects further enrolments.
| `POST` | `/wp-json/us-scheduler/v1/enrollments` | `book_lesson` |
`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.
@@ -47,15 +62,18 @@ instructor's group classes if the caller has `view_own_lessons` on those offerin
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_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)
- 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. Instructor-specific enrolment views (the
> spec's "under My Lessons") are a follow-up (#71) — this iteration ships the
> studio-admin **Group Classes** page (`view_all_lessons`) plus
> per-student/per-instructor REST queries.
## Tests
- `tests/Unit/GroupClass/EnrollmentTest.php`
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
- `tests/Unit/GroupClass/GroupClassPageTest.php`
+45 -13
View File
@@ -20,41 +20,72 @@ 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).
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. 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`) with a per-lesson status badge (pending payment / confirmed) and a **Cancel** button.
## 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.
## Frontend Shortcodes
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability
@@ -68,11 +99,12 @@ kind `group_class`; see `group-classes.md`.
- 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`
+14
View File
@@ -28,11 +28,24 @@ An offering is anything a student can register for: a private-lesson type (30 or
- `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`.
## 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.
## 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 |
@@ -51,5 +64,6 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint`
## Tests
- `tests/Unit/Offering/OfferingControllerTest.php`
- `tests/Unit/Offering/OfferingRepositoryTest.php`
- `tests/Unit/Offering/OfferingTest.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`
+4 -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
@@ -94,7 +96,7 @@ 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`.
+73
View File
@@ -0,0 +1,73 @@
# 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.
Any API failure, malformed response, or asset-less release degrades to
"no update available" — never an error surfaced to the site.
## 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`
+42 -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,25 @@ 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.
- **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. Paid lessons keep their payment — refunds stay a manual decision (#72).
- **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 +59,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 +75,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:
+27 -4
View File
@@ -8,9 +8,13 @@ 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\GroupClass\EnrollmentRepository;
@@ -23,10 +27,12 @@ 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 +44,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 +53,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, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments );
$this->lessonController = new LessonController( $bookings, $payments, $availability );
$this->offeringController = new OfferingController( $offerings );
$this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites );
$this->registrationApprovalController = new RegistrationApprovalController( new RegistrationMailer() );
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) );
$this->instructorController = new InstructorController();
$this->settings = $settings;
$this->accessSettings = new AccessSettings();
@@ -168,6 +176,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 +233,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' ),
@@ -226,6 +248,7 @@ class AdminMenu {
42
);
}
}
/**
* Insert sidebar separators around the studio menus so they sit visually
+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;
+54 -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,59 @@ 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 $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 ),
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 +126,12 @@ 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,
];
}
}
+15 -7
View File
@@ -11,28 +11,34 @@ 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,
'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', '%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 +46,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 +58,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 +76,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;
}
}
+113
View File
@@ -0,0 +1,113 @@
<?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 );
}
private function siteName(): string {
$name = (string) get_bloginfo( 'name' );
return '' !== $name ? $name : __( 'the studio', 'unsupervised-schedular' );
}
}
+120 -25
View File
@@ -3,49 +3,81 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
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\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';
public function __construct(
private InviteRepository $invites,
private PolicyRepository $policies,
private PolicyVersionRepository $versions,
private AcceptanceRepository $acceptances,
private StudioSettings $settings,
private RegistrationMailer $mailer,
) {}
/**
* 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`) or
* shortcode attributes (`login_page_id`).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
public function render( array $atts ): string {
if ( is_user_logged_in() ) {
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;
$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();
// 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' ) );
$error = '';
$success = false;
$successType = '';
if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) {
$result = $this->handleSubmit( $invite );
if ( true === $result ) {
$success = true;
$result = $this->handleSubmit( $invite, $open );
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) {
$successType = $result;
} else {
$error = $result;
}
}
// 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( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
$policyForms = $this->signupPolicies();
$canRegister = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
$canRegister = $open || $inviteValid;
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
@@ -63,12 +95,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 +110,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 +156,17 @@ class RegistrationPage {
}
}
if ( email_exists( $invite->email ) ) {
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 +175,57 @@ class RegistrationPage {
}
$this->recordAcceptances( $policyForms, (int) $userId );
if ( $inviteValid && ! $invite->isGroup() ) {
$this->invites->markAccepted( (int) $invite->id, (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;
}
/**
* 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 {
if ( $loginPageId > 0 ) {
$url = get_permalink( $loginPageId );
if ( is_string( $url ) ) {
return $url;
}
}
return wp_login_url();
}
/**
* 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 );
}
/**
@@ -140,7 +235,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;
}
}
+92
View File
@@ -0,0 +1,92 @@
<?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. Paid lessons keep
* their payment — refunds are a manual, admin-side decision.
*/
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 );
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;
}
}
+58 -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,6 +145,10 @@ class StudentController {
$this->enrollments->findByStudent( (int) $student->ID )
);
$acceptances = $this->history->policyAcceptances( (int) $student->ID );
$intake = $this->history->intakeAnswers( (int) $student->ID );
$payments = $canBilling ? $this->history->payments( (int) $student->ID ) : [];
$backUrl = admin_url( 'admin.php?page=us-students' );
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
}
@@ -111,6 +164,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 : '—',
+112
View File
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
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\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,
) {}
/**
* 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 )
);
}
/**
* Every intake answer the student has submitted, newest registration first.
*
* @return list<array{question: string, answer: string, context: string}>
*/
public function intakeAnswers( int $studentId ): array {
return 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 ),
];
},
$this->answers->findByStudent( $studentId )
);
}
/**
* 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 )
);
}
/**
* 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,
];
}
}
+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 ) {
+87 -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
)
);
@@ -159,7 +183,7 @@ class AvailabilityRepository {
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 +211,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;
}
}
+114
View File
@@ -0,0 +1,114 @@
<?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 {
public static function booking(): string {
$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
);
}
return sprintf(
'<div id="us-booking-app">%s<div id="us-slot-list">%s</div></div>',
self::note( __( 'Editor preview — students see live availability on the published page.', 'unsupervised-schedular' ) ),
$dayHtml
);
}
public static function groupClasses(): string {
return sprintf(
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p>%s</p><p>%s</p><p>25.00 CAD</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
self::note( __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' ) ),
esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ),
esc_html__( 'Saturdays 10:00 AM11:00 AM', 'unsupervised-schedular' ),
esc_html__( 'A sample class shown so the page can be styled.', '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>';
}
}
+267
View File
@@ -0,0 +1,267 @@
<?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,
],
],
'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,
],
],
],
'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 {
return $this->isEditorPreview() ? BlockPreview::booking() : $this->bookingPage->render( $attributes );
}
/**
* 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 {
return $this->isEditorPreview() ? BlockPreview::groupClasses() : $this->groupClassPage->render( $attributes );
}
/**
* 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, and logged-in visitors on a page containing
* the student-login block are sent to its booking 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 ( 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 ) );
}
/**
* 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' );
}
}
+138 -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 {
@@ -27,6 +29,11 @@ class BookingEndpoint {
private PaymentService $payments,
) {}
/**
* 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 +80,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 +116,28 @@ 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, so front-end lists
* can show when the session happens without a second request.
*
* @return array<string, mixed>
*/
private function lessonWithTimes( Lesson $lesson ): array {
$slot = $this->availability->findById( $lesson->slotId );
return $lesson->toArray() + [
'start_dt' => $slot?->startDt,
'end_dt' => $slot?->endDt,
];
}
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 +154,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 +166,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 +204,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 +213,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 +245,37 @@ 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;
if ( $offering->price > 0.0 ) {
// 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 {
// Free offering: there is no payment step that would confirm these
// lessons later, so they are confirmed at booking time.
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,7 +289,7 @@ 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;
@@ -220,13 +297,45 @@ class BookingEndpoint {
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. Paid
* lessons keep their payment — refunds are a manual, admin-side decision.
*/
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 ) {
$this->bookings->updateStatus( $id, Lesson::STATUS_CANCELLED );
$this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId );
}
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 +346,23 @@ 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 );
} 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
);
+32 -4
View File
@@ -3,25 +3,34 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class BookingPage {
/**
* Renders the booking shortcode output.
* Renders the booking shortcode/block output.
*
* @param array<string, string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
* shortcode attributes (`login_page_id`).
*/
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>';
}
@@ -33,4 +42,23 @@ class BookingPage {
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
return (string) ob_get_clean();
}
/**
* 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 );
}
}
+65 -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' )
)
@@ -180,6 +214,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;
+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 ),
);
}
+50 -6
View File
@@ -4,14 +4,19 @@ 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\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,
) {}
public function renderAdminDashboard(): void {
@@ -23,7 +28,7 @@ class LessonController {
$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 {
@@ -35,6 +40,29 @@ class LessonController {
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
$this->renderLessonsPage( $rows, 'us-my-lessons' );
}
/**
* 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 +76,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 +110,14 @@ 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 );
return [
'student' => $student ? $student->display_name : (string) $lesson->studentId,
'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId,
'slot_id' => (int) $lesson->slotId,
'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 +131,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 ) );
}
}
+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 ),
);
}
+14 -5
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 {
@@ -20,6 +21,11 @@ class EnrollmentEndpoint {
private PaymentService $payments,
) {}
/**
* 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,
@@ -69,7 +75,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 ) {
@@ -87,7 +93,7 @@ class EnrollmentEndpoint {
}
$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,14 +110,17 @@ class EnrollmentEndpoint {
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
$payment = null;
if ( $offering->price > 0.0 ) {
$this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
$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
);
@@ -133,7 +142,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 +150,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;
}
+13 -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
)
);
+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();
+14
View File
@@ -4,11 +4,13 @@ declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
class Installer {
public function run(): void {
$this->createTables();
$this->migrateData();
( new RoleManager() )->createRoles();
flush_rewrite_rules();
update_option( 'us_schedular_version', USC_VERSION );
@@ -16,6 +18,9 @@ class Installer {
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 +29,13 @@ class Installer {
dbDelta( $sql );
}
}
private function migrateData(): void {
global $wpdb;
if ( ! $wpdb instanceof \wpdb ) {
return;
}
( new AvailabilityRepository( $wpdb ) )->splitOversizedWindows();
}
}
+40 -17
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';
@@ -44,24 +46,45 @@ class Offering {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
/**
* 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' );
}
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 ),
scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
isActive: Val::bool( $row->is_active ),
id: Val::int( $row->id ),
);
}
+69 -17
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class OfferingController {
@@ -21,6 +22,20 @@ class OfferingController {
$this->handleFormAction( $instructorId, $manageAll );
}
// 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
? $this->repository->findAll()
: $this->repository->findAll( $instructorId );
@@ -31,14 +46,30 @@ class OfferingController {
private function handleFormAction( int $instructorId, bool $manageAll ): 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->addOffering( $instructorId );
$offering = $this->offeringFromPost( $instructorId );
if ( null !== $offering ) {
$this->repository->insert( $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, $existing );
if ( null !== $offering ) {
$this->repository->update( $offeringId, $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 ) ) {
@@ -49,36 +80,57 @@ class OfferingController {
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
private function addOffering( int $instructorId ): void {
/**
* 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, owner, and currency so an update can never
* reassign an offering to whoever happens to submit the form.
*/
private function offeringFromPost( int $instructorId, ?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 ) );
$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,
// 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;
}
return new Offering(
instructorId: null !== $existing ? $existing->instructorId : $instructorId,
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,
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'] ?? '' ) ) ) ),
isActive: isset( $_POST['is_active'] ),
id: $existing?->id,
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
+22 -16
View File
@@ -4,11 +4,17 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class OfferingEndpoint {
public function __construct( private OfferingRepository $repository ) {}
/**
* 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,
@@ -57,8 +63,8 @@ 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' ),
Val::int( $request->get_param( 'instructor_id' ) ),
Val::string( $request->get_param( 'kind' ) ),
activeOnly: true,
);
@@ -67,17 +73,17 @@ class OfferingEndpoint {
}
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' ) );
}
@@ -87,7 +93,7 @@ class OfferingEndpoint {
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' ) ),
@@ -106,7 +112,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,12 +123,12 @@ 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' ) );
}
@@ -130,9 +136,9 @@ class OfferingEndpoint {
$offering = new Offering(
instructorId: $existing->instructorId,
kind: $kind,
title: $request->has_param( 'title' ) ? sanitize_text_field( (string) $request->get_param( 'title' ) ) : $existing->title,
title: $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->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,
@@ -152,7 +158,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 ) {
@@ -195,17 +201,17 @@ class OfferingEndpoint {
}
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 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 +219,6 @@ class OfferingEndpoint {
return null;
}
return sanitize_text_field( (string) $value );
return sanitize_text_field( Val::string( $value ) );
}
}
+5 -5
View File
@@ -90,18 +90,18 @@ class OfferingRepository {
}
$whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY title ASC";
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY title 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( 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;
}
+36 -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';
@@ -47,27 +49,29 @@ class Payment {
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 ),
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 ),
);
}
@@ -82,6 +86,20 @@ class Payment {
return round( $this->amount + $this->taxAmount, 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.
*
@@ -104,6 +122,7 @@ class Payment {
'status' => $this->status,
'receipt_number' => $this->receiptNumber,
'paid_at' => $this->paidAt,
'created_at' => $this->createdAt,
];
}
}
+4 -3
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.
+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 {
+31 -10
View File
@@ -55,7 +55,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
)
);
@@ -77,14 +78,15 @@ class PaymentRepository {
* 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 +96,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,7 +113,7 @@ 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;
@@ -120,7 +122,8 @@ class PaymentRepository {
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 +132,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 +157,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
)
);
+29 -3
View File
@@ -89,6 +89,22 @@ 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.
*/
public function voidPending( ?int $paymentId ): void {
if ( null === $paymentId ) {
return;
}
$payment = $this->payments->findById( $paymentId );
if ( null !== $payment && Payment::STATUS_PENDING === $payment->status ) {
$this->payments->updateStatus( $paymentId, Payment::STATUS_FAILED );
}
}
/**
* Resolve the client-side payment step for a freshly created registration.
* For a card payment a Stripe PaymentIntent is created (or replayed
@@ -179,10 +195,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 {
+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 );
+83 -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,24 @@ class StudioSettings {
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
public const OPT_HST_RATE = 'us_hst_rate';
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 +41,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 +49,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 +59,14 @@ 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 ) ) );
}
/**
@@ -64,6 +77,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 +116,7 @@ class StudioSettings {
$etransferEmail = $this->etransferEmail();
$hstRate = $this->hstRate();
$stripeConfigured = $this->isStripeConfigured();
$openRegistration = $this->openRegistrationEnabled();
include USC_PLUGIN_DIR . 'templates/admin/settings.php';
}
@@ -92,23 +124,61 @@ 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 ) );
$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 );
}
}
}
+33 -2
View File
@@ -3,11 +3,18 @@ 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\Booking\BookingPage;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\PaymentRepository;
@@ -22,6 +29,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,6 +37,16 @@ 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 );
@@ -48,9 +66,22 @@ class Plugin {
$stripe = new StripeGateway( $settings );
$paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe );
// 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 );
$groupClassPage = new GroupClassPage();
( new UpdateChecker() )->register();
( new RoleManager() )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
( new RegistrationLoginGate() )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $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 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 ?? [] );
}
}
+8 -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';
@@ -24,13 +26,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 ),
);
}
+9 -8
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,13 +35,13 @@ 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;
@@ -53,18 +54,18 @@ class PolicyController {
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 );
}
+20 -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,12 +114,12 @@ 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' ) );
}
$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' ) );
@@ -120,7 +129,7 @@ class PolicyEndpoint {
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 +140,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 +161,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 +179,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 +208,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;
+9 -7
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Val;
class Answer {
public const REG_LESSON = 'lesson';
@@ -24,14 +26,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 ?? [] );
}
}
+14 -10
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';
@@ -38,22 +40,24 @@ class Question {
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::int( $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 ),
id: Val::int( $row->id ),
);
}
+8 -7
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 {
@@ -23,7 +24,7 @@ class QuestionController {
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only offering selector.
$offeringId = absint( $_GET['offering_id'] ?? 0 );
$offeringId = absint( Val::int( $_GET['offering_id'] ?? 0 ) );
$offeringList = $manageAll ? $this->offerings->findAll() : $this->offerings->findAll( $userId );
$selectedOffering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
@@ -46,14 +47,14 @@ class QuestionController {
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 );
}
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 ) {
@@ -66,8 +67,8 @@ class QuestionController {
private function addQuestion( int $offeringId ): 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 ) ) {
return;
@@ -78,9 +79,9 @@ class QuestionController {
offeringId: $offeringId,
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 ) ),
)
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
+17 -11
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
class QuestionEndpoint {
@@ -13,6 +14,11 @@ class QuestionEndpoint {
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,
@@ -57,24 +63,24 @@ class QuestionEndpoint {
}
public function index( \WP_REST_Request $request ): \WP_REST_Response {
$questions = $this->questions->findByOffering( absint( $request->get_param( 'id' ) ), activeOnly: true );
$questions = $this->questions->findByOffering( absint( Val::int( $request->get_param( 'id' ) ) ), activeOnly: true );
return new \WP_REST_Response( array_map( fn( Question $q ) => $q->toArray(), $questions ), 200 );
}
public function create( \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' ) ) );
$ownerCheck = $this->requireOfferingOwner( $offeringId );
if ( $ownerCheck instanceof \WP_Error ) {
return $ownerCheck;
}
$label = sanitize_text_field( (string) $request->get_param( 'label' ) );
$label = sanitize_text_field( Val::string( $request->get_param( 'label' ) ) );
if ( '' === $label ) {
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
}
$fieldType = (string) ( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
$fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
}
@@ -85,7 +91,7 @@ class QuestionEndpoint {
fieldType: $fieldType,
options: $this->sanitizeOptions( $request->get_param( 'options' ) ),
isRequired: (bool) $request->get_param( 'is_required' ),
sortOrder: (int) $request->get_param( 'sort_order' ),
sortOrder: Val::int( $request->get_param( 'sort_order' ) ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
);
@@ -95,7 +101,7 @@ class QuestionEndpoint {
}
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->questions->findById( $id );
if ( null === $existing ) {
@@ -107,18 +113,18 @@ class QuestionEndpoint {
return $ownerCheck;
}
$fieldType = $request->has_param( 'field_type' ) ? (string) $request->get_param( 'field_type' ) : $existing->fieldType;
$fieldType = $request->has_param( 'field_type' ) ? Val::string( $request->get_param( 'field_type' ) ) : $existing->fieldType;
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
}
$question = new Question(
offeringId: $existing->offeringId,
label: $request->has_param( 'label' ) ? sanitize_text_field( (string) $request->get_param( 'label' ) ) : $existing->label,
label: $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label,
fieldType: $fieldType,
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired,
sortOrder: $request->has_param( 'sort_order' ) ? (int) $request->get_param( 'sort_order' ) : $existing->sortOrder,
sortOrder: $request->has_param( 'sort_order' ) ? Val::int( $request->get_param( 'sort_order' ) ) : $existing->sortOrder,
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
id: $id,
);
@@ -129,7 +135,7 @@ class QuestionEndpoint {
}
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->questions->findById( $id );
if ( null === $existing ) {
@@ -192,7 +198,7 @@ class QuestionEndpoint {
$options = array_values(
array_filter(
array_map(
static fn( $option ): string => sanitize_text_field( (string) $option ),
static fn( mixed $option ): string => sanitize_text_field( Val::string( $option ) ),
$value
)
)
+3 -3
View File
@@ -54,8 +54,8 @@ class QuestionRepository {
* @return list<Question>
*/
public function findByOffering( int $offeringId, bool $activeOnly = false ): array {
$sql = "SELECT * FROM {$this->table} WHERE offering_id = %d";
$params = [ $offeringId ];
$sql = 'SELECT * FROM %i WHERE offering_id = %d';
$params = [ $this->table, $offeringId ];
if ( $activeOnly ) {
$sql .= ' AND is_active = %d';
@@ -71,7 +71,7 @@ class QuestionRepository {
public function findById( int $id ): ?Question {
$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 ? Question::fromRow( $row ) : null;
+2
View File
@@ -186,11 +186,13 @@ class Schema {
email VARCHAR(191) NOT NULL,
token VARCHAR(64) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'us_student',
kind VARCHAR(10) NOT NULL DEFAULT 'personal',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
invited_by BIGINT UNSIGNED DEFAULT NULL,
accepted_user_id BIGINT UNSIGNED DEFAULT NULL,
created_at DATETIME NOT NULL,
accepted_at DATETIME DEFAULT NULL,
expires_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY token (token),
KEY email (email),
+22 -23
View File
@@ -3,44 +3,42 @@ declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
class ShortcodeRegistrar {
private BookingPage $bookingPage;
private LoginPage $loginPage;
private RegistrationPage $registrationPage;
private GroupClassPage $groupClassPage;
public function __construct(
InviteRepository $invites,
PolicyRepository $policies,
PolicyVersionRepository $policyVersions,
AcceptanceRepository $acceptances,
) {
$this->bookingPage = new BookingPage();
$this->loginPage = new LoginPage();
$this->registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances );
$this->groupClassPage = new GroupClassPage();
}
private BookingPage $bookingPage,
private LoginPage $loginPage,
private RegistrationPage $registrationPage,
private GroupClassPage $groupClassPage,
) {}
public function register(): void {
add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] );
add_shortcode( 'us_student_login', [ $this->loginPage, 'render' ] );
add_shortcode( 'us_student_register', [ $this->registrationPage, 'render' ] );
add_shortcode( 'us_group_classes', [ $this->groupClassPage, 'render' ] );
add_shortcode( 'us_booking', self::shortcode( [ $this->bookingPage, 'render' ] ) );
add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) );
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
}
/**
* Wraps a page renderer so bare shortcode usage is safe: WordPress passes
* an empty string, not an array, to the callback when a shortcode is used
* without attributes (`shortcode_parse_atts( '' )` returns `''`).
*
* @param callable(array<int|string, mixed>): string $render
* @return \Closure(mixed): string
*/
private static function shortcode( callable $render ): \Closure {
return static fn( mixed $atts ): string => $render( is_array( $atts ) ? $atts : [] );
}
public function enqueueAssets(): void {
wp_register_style( 'us-scheduler', USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
@@ -62,6 +60,7 @@ class ShortcodeRegistrar {
'restUrl' => rest_url( 'us-scheduler/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'stripeKey' => $settings->publishableKey(),
'startOfWeek' => Val::int( get_option( 'start_of_week', 1 ) ),
];
// Attach the shared config to the payment helper so it is defined before the
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Update;
use Unsupervised\Schedular\Val;
/**
* Serves plugin updates from the Gitea repository's releases.
*
* Core reads the plugin's `Update URI` header and, during every update
* check, fires the `update_plugins_{hostname}` filter for that host. This
* class answers the filter by fetching the latest published release from
* the Gitea API and returning its zip asset when it is newer than the
* installed version. Everything downstream — the Plugins-screen notice,
* one-click updates, and opt-in auto-updates — is handled by core.
*
* Drafts and releases marked "pre-release" in Gitea are never offered:
* the `/releases/latest` endpoint excludes both.
*/
class UpdateChecker {
public const HOSTNAME = 'git.unsupervised.ca';
public const REPO_URL = 'https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler';
public const API_URL = 'https://git.unsupervised.ca/api/v1/repos/Unsupervised/unsupervised-scheduler/releases/latest';
public const TRANSIENT = 'us_schedular_latest_release';
/**
* How long a release lookup (including a failed one) is cached. Core
* runs update checks on admin page loads as well as twice-daily cron,
* so the cache keeps the plugin from hammering the Gitea API.
*/
private const CACHE_TTL = 6 * 3600;
public function register(): void {
add_filter( 'update_plugins_' . self::HOSTNAME, [ $this, 'provideUpdate' ], 10, 3 );
}
/**
* `update_plugins_{hostname}` filter callback. Returns the incoming
* value untouched unless a newer release with a zip asset exists, in
* which case it returns the update array core expects.
*/
public function provideUpdate( mixed $update, mixed $plugin_data, mixed $plugin_file ): mixed {
if ( plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) {
return $update;
}
$release = $this->latestRelease();
if ( '' === $release['version'] || '' === $release['package'] ) {
return $update;
}
if ( version_compare( $release['version'], USC_VERSION, '<=' ) ) {
return $update;
}
return [
'slug' => 'unsupervised-schedular',
'version' => $release['version'],
'url' => self::REPO_URL,
'package' => $release['package'],
];
}
/**
* The latest published release, from the transient cache when fresh.
*
* @return array{version: string, package: string} Empty strings when no
* usable release exists.
*/
private function latestRelease(): array {
$cached = get_transient( self::TRANSIENT );
if ( is_array( $cached ) ) {
return [
'version' => Val::string( $cached['version'] ?? '' ),
'package' => Val::string( $cached['package'] ?? '' ),
];
}
$release = $this->fetchLatestRelease();
set_transient( self::TRANSIENT, $release, self::CACHE_TTL );
return $release;
}
/**
* Ask the Gitea API for the latest published release's version and zip asset.
*
* @return array{version: string, package: string}
*/
private function fetchLatestRelease(): array {
$none = [
'version' => '',
'package' => '',
];
$response = wp_remote_get(
self::API_URL,
[
'timeout' => 10,
'headers' => [ 'Accept' => 'application/json' ],
]
);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return $none;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $body ) ) {
return $none;
}
// Release tags are named v1.2.3; the plugin header carries the bare version.
$version = preg_replace( '/^v/i', '', Val::string( $body['tag_name'] ?? '' ) ) ?? '';
// The release workflow attaches the built plugin zip (top-level
// unsupervised-schedular/ folder, production autoloader) as an asset.
// Gitea's auto-generated source archives are not usable packages.
$package = '';
$assets = $body['assets'] ?? null;
if ( is_array( $assets ) ) {
foreach ( $assets as $asset ) {
if ( ! is_array( $asset ) ) {
continue;
}
if ( str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) {
$package = Val::string( $asset['browser_download_url'] ?? '' );
break;
}
}
}
if ( '' === $version || '' === $package ) {
return $none;
}
return [
'version' => $version,
'package' => $package,
];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular;
/**
* Runtime coercion helpers for values crossing untyped WordPress boundaries
* (wpdb rows, REST request params, superglobals). Each method narrows a mixed
* value with an explicit runtime check instead of a blind cast, so an
* unexpected shape degrades to a safe default rather than leaking garbage
* into typed code.
*/
final class Val {
/**
* Coerce to int; non-numeric values become 0.
*/
public static function int( mixed $value ): int {
return is_numeric( $value ) ? (int) $value : 0;
}
/**
* Coerce to int, preserving null (e.g. nullable DB columns).
*/
public static function intOrNull( mixed $value ): ?int {
return null === $value ? null : self::int( $value );
}
/**
* Coerce to float; non-numeric values become 0.0.
*/
public static function float( mixed $value ): float {
return is_numeric( $value ) ? (float) $value : 0.0;
}
/**
* Coerce to string; non-scalar values become ''.
*/
public static function string( mixed $value ): string {
if ( is_string( $value ) ) {
return $value;
}
return is_scalar( $value ) ? (string) $value : '';
}
/**
* Coerce to string, preserving null (e.g. nullable DB columns).
*/
public static function stringOrNull( mixed $value ): ?string {
return null === $value ? null : self::string( $value );
}
/**
* Coerce to bool using PHP truthiness (DB tinyint flags, option values).
*/
public static function bool( mixed $value ): bool {
return (bool) $value;
}
}
+101 -13
View File
@@ -8,12 +8,33 @@ if (! defined('ABSPATH')) {
/**
* @var list<\Unsupervised\Schedular\Availability\AvailabilitySlot> $slots
* @var list<\Unsupervised\Schedular\Offering\Offering> $offeringChoices
* @var 'list'|'week' $view
* @var string $weekStart
* @var list<array{date: string, slots: list<\Unsupervised\Schedular\Availability\AvailabilitySlot>}> $weekDays
* @var string $prevWeek
* @var string $nextWeek
*/
$baseUrl = admin_url('admin.php?page=us-availability');
$deleteForm = static function (\Unsupervised\Schedular\Availability\AvailabilitySlot $slot): void {
?>
<form method="post" style="display:inline;">
<?php wp_nonce_field('usc_availability_action'); ?>
<input type="hidden" name="usc_action" value="delete">
<input type="hidden" name="slot_id" value="<?php echo esc_attr((string) $slot->id); ?>">
<button type="submit" class="button button-small button-link-delete">
<?php esc_html_e('Delete', 'unsupervised-schedular'); ?>
</button>
</form>
<?php
};
?>
<div class="wrap">
<h1><?php esc_html_e('My Availability', 'unsupervised-schedular'); ?></h1>
<h2><?php esc_html_e('Add Slot', 'unsupervised-schedular'); ?></h2>
<h2><?php esc_html_e('Add Availability', 'unsupervised-schedular'); ?></h2>
<p><?php esc_html_e('The window must start and end on the same day. It is split into bookable slots of the chosen lesson length — for example, 9:00 AM4:00 PM with 60-minute lessons creates seven slots.', 'unsupervised-schedular'); ?></p>
<form method="post">
<?php wp_nonce_field('usc_availability_action'); ?>
<input type="hidden" name="usc_action" value="add">
@@ -56,17 +77,81 @@ if (! defined('ABSPATH')) {
</td>
</tr>
</table>
<?php submit_button(esc_html__('Add Slot', 'unsupervised-schedular')); ?>
<?php submit_button(esc_html__('Add Availability', 'unsupervised-schedular')); ?>
</form>
<h2><?php esc_html_e('Current Slots', 'unsupervised-schedular'); ?></h2>
<?php if (empty($slots)) : ?>
<ul class="subsubsub" style="margin-bottom:12px;">
<li>
<a href="<?php echo esc_url($baseUrl); ?>" <?php echo 'week' === $view ? 'class="current"' : ''; ?>><?php esc_html_e('Week', 'unsupervised-schedular'); ?></a> |
</li>
<li>
<a href="<?php echo esc_url(add_query_arg('usc_view', 'list', $baseUrl)); ?>" <?php echo 'list' === $view ? 'class="current"' : ''; ?>><?php esc_html_e('List', 'unsupervised-schedular'); ?></a>
</li>
</ul>
<div class="clear"></div>
<?php if ('week' === $view) : ?>
<p>
<a class="button" href="<?php echo esc_url(add_query_arg('usc_week', $prevWeek, $baseUrl)); ?>">&lsaquo; <?php esc_html_e('Previous week', 'unsupervised-schedular'); ?></a>
<strong style="margin:0 12px;">
<?php
/* translators: %s: date of the first day of the displayed week */
echo esc_html(sprintf(__('Week of %s', 'unsupervised-schedular'), (string) mysql2date('M j, Y', $weekStart)));
?>
</strong>
<a class="button" href="<?php echo esc_url(add_query_arg('usc_week', $nextWeek, $baseUrl)); ?>"><?php esc_html_e('Next week', 'unsupervised-schedular'); ?> &rsaquo;</a>
</p>
<table class="wp-list-table widefat fixed">
<thead>
<tr>
<?php foreach ($weekDays as $day) : ?>
<th><?php echo esc_html((string) mysql2date('D M j', $day['date'])); ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<tr>
<?php foreach ($weekDays as $day) : ?>
<td style="vertical-align:top;">
<?php if (empty($day['slots'])) : ?>
<span aria-hidden="true">—</span>
<?php endif; ?>
<?php foreach ($day['slots'] as $slot) : ?>
<p style="margin:0 0 8px;">
<?php echo esc_html((string) mysql2date('g:i A', $slot->startDt) . '' . (string) mysql2date('g:i A', $slot->endDt)); ?><br>
<?php if ($slot->isBooked) : ?>
<em><?php esc_html_e('Booked', 'unsupervised-schedular'); ?></em>
<?php else : ?>
<?php $deleteForm($slot); ?>
<?php endif; ?>
</p>
<?php endforeach; ?>
</td>
<?php endforeach; ?>
</tr>
</tbody>
</table>
<?php elseif (empty($slots)) : ?>
<p><?php esc_html_e('No availability slots configured.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<?php
// Bulk-delete form. The row checkboxes live inside the table and are
// associated via the HTML form attribute, because the table also
// contains the per-row delete forms and forms cannot nest.
?>
<form method="post" id="usc-bulk-delete-form" onsubmit="return confirm('<?php echo esc_js(__('Delete the selected slots?', 'unsupervised-schedular')); ?>');">
<?php wp_nonce_field('usc_availability_action'); ?>
<input type="hidden" name="usc_action" value="bulk_delete">
</form>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<td class="manage-column column-cb check-column">
<input type="checkbox" id="cb-select-all-1">
<label for="cb-select-all-1"><span class="screen-reader-text"><?php esc_html_e('Select all', 'unsupervised-schedular'); ?></span></label>
</td>
<th><?php esc_html_e('Start', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('End', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Length', 'unsupervised-schedular'); ?></th>
@@ -77,25 +162,28 @@ if (! defined('ABSPATH')) {
<tbody>
<?php foreach ($slots as $slot) : ?>
<tr>
<td><?php echo esc_html($slot->startDt); ?></td>
<td><?php echo esc_html($slot->endDt); ?></td>
<th scope="row" class="check-column">
<?php if (! $slot->isBooked) : ?>
<input type="checkbox" name="slot_ids[]" form="usc-bulk-delete-form" value="<?php echo esc_attr((string) $slot->id); ?>">
<?php endif; ?>
</th>
<td><?php echo esc_html((string) mysql2date('M j, Y g:i A', $slot->startDt)); ?></td>
<td><?php echo esc_html((string) mysql2date('M j, Y g:i A', $slot->endDt)); ?></td>
<td><?php echo esc_html((string) $slot->durationMinutes . ' min'); ?></td>
<td><?php echo $slot->isBooked ? esc_html__('Booked', 'unsupervised-schedular') : esc_html__('Available', 'unsupervised-schedular'); ?></td>
<td>
<?php if (! $slot->isBooked) : ?>
<form method="post" style="display:inline;">
<?php wp_nonce_field('usc_availability_action'); ?>
<input type="hidden" name="usc_action" value="delete">
<input type="hidden" name="slot_id" value="<?php echo esc_attr((string) $slot->id); ?>">
<button type="submit" class="button button-small button-link-delete">
<?php esc_html_e('Delete', 'unsupervised-schedular'); ?>
</button>
</form>
<?php $deleteForm($slot); ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p>
<button type="submit" class="button" form="usc-bulk-delete-form">
<?php esc_html_e('Delete selected', 'unsupervised-schedular'); ?>
</button>
</p>
<?php endif; ?>
</div>
+41 -7
View File
@@ -9,11 +9,26 @@ if (! defined('ABSPATH')) {
* @var list<\Unsupervised\Schedular\Auth\Invite> $pendingInvites
* @var int $registrationPageId
* @var string $registrationPageUrl
* @var string $newInviteUrl One-time registration link for a just-created invite.
* @var string $inviteError Error message when invite creation failed.
*/
?>
<div class="wrap">
<h1><?php esc_html_e('Invites', 'unsupervised-schedular'); ?></h1>
<p class="description"><?php esc_html_e('Invite a student by email, then send them the registration link below. They complete signup and accept any required policies through the [us_student_register] page.', 'unsupervised-schedular'); ?></p>
<p class="description"><?php esc_html_e('Invite a student by email, then send them the registration link. They complete signup and accept any required policies through the [us_student_register] page.', 'unsupervised-schedular'); ?></p>
<?php if ($inviteError !== '') : ?>
<div class="notice notice-error inline">
<p><?php echo esc_html($inviteError); ?></p>
</div>
<?php endif; ?>
<?php if ($newInviteUrl !== '') : ?>
<div class="notice notice-success inline">
<p><?php esc_html_e('Invite created. Copy the registration link now — for security it is not stored and cannot be shown again. To re-send a lost link, revoke the invite and create a new one.', 'unsupervised-schedular'); ?></p>
<p><input type="text" class="large-text code" readonly value="<?php echo esc_attr($newInviteUrl); ?>" onclick="this.select()"></p>
</div>
<?php endif; ?>
<h2><?php esc_html_e('Registration Page', 'unsupervised-schedular'); ?></h2>
<form method="post">
@@ -58,6 +73,23 @@ if (! defined('ABSPATH')) {
<?php submit_button(esc_html__('Generate Invitation Link', 'unsupervised-schedular')); ?>
</form>
<h2><?php esc_html_e('Group Invite Link', 'unsupervised-schedular'); ?></h2>
<p class="description"><?php esc_html_e('Generate a shareable link (e.g. for a newsletter). Anyone with the link can register until it expires: they enter their own email and must confirm it, but no admin approval is needed afterwards.', 'unsupervised-schedular'); ?></p>
<form method="post">
<?php wp_nonce_field('usc_invite_action'); ?>
<input type="hidden" name="usc_action" value="group_invite">
<table class="form-table">
<tr>
<th><label for="expires_at"><?php esc_html_e('Expires on', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="expires_at" id="expires_at" required min="<?php echo esc_attr((string) current_time('Y-m-d')); ?>">
<p class="description"><?php esc_html_e('The link stops working at the end of this day.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
</table>
<?php submit_button(esc_html__('Generate Group Link', 'unsupervised-schedular')); ?>
</form>
<h2><?php esc_html_e('Pending Invites', 'unsupervised-schedular'); ?></h2>
<?php if (empty($pendingInvites)) : ?>
@@ -66,25 +98,27 @@ if (! defined('ABSPATH')) {
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Registration link', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Invite', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Created', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Expires', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php $linkBase = $registrationPageUrl !== '' ? $registrationPageUrl : home_url('/'); ?>
<?php $now = current_time('mysql'); ?>
<?php foreach ($pendingInvites as $invite) : ?>
<?php $link = esc_url(add_query_arg('us_invite', $invite->token, $linkBase)); ?>
<tr>
<td>
<?php echo esc_html($invite->email); ?>
<?php echo $invite->isGroup() ? esc_html__('Group link', 'unsupervised-schedular') : esc_html($invite->email); ?>
<?php if ($invite->isExpired($now)) : ?>
<span class="us-invite-expired" style="color:#b32d2e;">— <?php esc_html_e('expired', 'unsupervised-schedular'); ?></span>
<?php endif; ?>
</td>
<td>
<input type="text" class="large-text code" readonly value="<?php echo esc_attr($link); ?>" onclick="this.select()">
<?php echo esc_html((string) $invite->createdAt); ?>
</td>
<td>
<?php echo esc_html($invite->expiresAt !== null ? (string) mysql2date('M j, Y', $invite->expiresAt) : '—'); ?>
</td>
<td>
<form method="post" style="display:inline;">
+60 -4
View File
@@ -5,12 +5,68 @@ if (! defined('ABSPATH')) {
exit;
}
/** @var list<array{student: string, instructor: string, slot_id: int, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows */
/**
* @var list<array{student: string, instructor: string, time: string, day: string, time_short: string, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows
* @var 'list'|'week' $view
* @var string $weekStart
* @var list<array{date: string, items: list<array{student: string, time_short: string, status: string}>}> $weekDays
* @var string $prevWeek
* @var string $nextWeek
* @var string $baseUrl
*/
?>
<div class="wrap">
<h1><?php esc_html_e('Lessons', 'unsupervised-schedular'); ?></h1>
<?php if (empty($rows)) : ?>
<ul class="subsubsub" style="margin-bottom:12px;">
<li>
<a href="<?php echo esc_url($baseUrl); ?>" <?php echo 'week' === $view ? 'class="current"' : ''; ?>><?php esc_html_e('Week', 'unsupervised-schedular'); ?></a> |
</li>
<li>
<a href="<?php echo esc_url(add_query_arg('usc_view', 'list', $baseUrl)); ?>" <?php echo 'list' === $view ? 'class="current"' : ''; ?>><?php esc_html_e('List', 'unsupervised-schedular'); ?></a>
</li>
</ul>
<div class="clear"></div>
<?php if ('week' === $view) : ?>
<p>
<a class="button" href="<?php echo esc_url(add_query_arg('usc_week', $prevWeek, $baseUrl)); ?>">&lsaquo; <?php esc_html_e('Previous week', 'unsupervised-schedular'); ?></a>
<strong style="margin:0 12px;">
<?php
/* translators: %s: date of the first day of the displayed week */
echo esc_html(sprintf(__('Week of %s', 'unsupervised-schedular'), (string) mysql2date('M j, Y', $weekStart)));
?>
</strong>
<a class="button" href="<?php echo esc_url(add_query_arg('usc_week', $nextWeek, $baseUrl)); ?>"><?php esc_html_e('Next week', 'unsupervised-schedular'); ?> &rsaquo;</a>
</p>
<table class="wp-list-table widefat fixed">
<thead>
<tr>
<?php foreach ($weekDays as $day) : ?>
<th><?php echo esc_html((string) mysql2date('D M j', $day['date'])); ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<tr>
<?php foreach ($weekDays as $day) : ?>
<td style="vertical-align:top;">
<?php if (empty($day['items'])) : ?>
<span aria-hidden="true">—</span>
<?php endif; ?>
<?php foreach ($day['items'] as $item) : ?>
<p style="margin:0 0 8px;">
<strong><?php echo esc_html($item['time_short']); ?></strong><br>
<?php echo esc_html($item['student']); ?><br>
<em><?php echo esc_html($item['status']); ?></em>
</p>
<?php endforeach; ?>
</td>
<?php endforeach; ?>
</tr>
</tbody>
</table>
<?php elseif (empty($rows)) : ?>
<p><?php esc_html_e('No upcoming lessons.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
@@ -18,7 +74,7 @@ if (! defined('ABSPATH')) {
<tr>
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Slot ID', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Date/Time', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
@@ -31,7 +87,7 @@ if (! defined('ABSPATH')) {
<tr>
<td><?php echo esc_html($row['student']); ?></td>
<td><?php echo esc_html($row['instructor']); ?></td>
<td><?php echo esc_html((string) $row['slot_id']); ?></td>
<td><?php echo esc_html($row['time']); ?></td>
<td><?php echo esc_html($row['status']); ?></td>
<td>
<?php if ($row['tax_editable']) : ?>
+72 -12
View File
@@ -7,64 +7,111 @@ if (! defined('ABSPATH')) {
exit;
}
/** @var list<\Unsupervised\Schedular\Offering\Offering> $offerings */
/**
* @var list<\Unsupervised\Schedular\Offering\Offering> $offerings
* @var \Unsupervised\Schedular\Offering\Offering|null $editing Offering loaded into the form, or null when adding.
*/
$baseUrl = admin_url('admin.php?page=us-offerings');
// Prefill the sessions control from the stored term dates: a term longer than
// one day was created as weekly sessions one week apart.
$termRecurrence = 'single';
$termSessions = 10;
if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $editing->termEnd !== $editing->termStart) {
$termRecurrence = 'weekly';
$termSessions = (int) round(((int) strtotime($editing->termEnd) - (int) strtotime($editing->termStart)) / 604800) + 1;
}
?>
<div class="wrap">
<h1><?php esc_html_e('Offerings', 'unsupervised-schedular'); ?></h1>
<h2><?php esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
<h2><?php $editing ? esc_html_e('Edit Offering', 'unsupervised-schedular') : esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
<form method="post">
<?php wp_nonce_field('usc_offering_action'); ?>
<?php if ($editing) : ?>
<input type="hidden" name="usc_action" value="update">
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $editing->id); ?>">
<?php else : ?>
<input type="hidden" name="usc_action" value="add">
<?php endif; ?>
<table class="form-table">
<tr>
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="title" id="title" class="regular-text" required></td>
<td><input type="text" name="title" id="title" class="regular-text" required value="<?php echo esc_attr($editing->title ?? ''); ?>"></td>
</tr>
<tr>
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
<td>
<select name="kind" id="kind">
<option value="<?php echo esc_attr(Offering::KIND_PRIVATE_LESSON); ?>"><?php esc_html_e('Private lesson', 'unsupervised-schedular'); ?></option>
<option value="<?php echo esc_attr(Offering::KIND_GROUP_CLASS); ?>"><?php esc_html_e('Group class', 'unsupervised-schedular'); ?></option>
<option value="<?php echo esc_attr(Offering::KIND_GROUP_CLASS); ?>" <?php echo $editing && Offering::KIND_GROUP_CLASS === $editing->kind ? 'selected' : ''; ?>><?php esc_html_e('Group class', 'unsupervised-schedular'); ?></option>
</select>
</td>
</tr>
<tr>
<th><label for="description"><?php esc_html_e('Description', 'unsupervised-schedular'); ?></label></th>
<td><textarea name="description" id="description" class="large-text" rows="4"><?php echo esc_textarea($editing->description ?? ''); ?></textarea></td>
</tr>
<tr>
<th><label for="duration_minutes"><?php esc_html_e('Duration (minutes)', 'unsupervised-schedular'); ?></label></th>
<td><input type="number" name="duration_minutes" id="duration_minutes" min="0" step="1"></td>
<td><input type="number" name="duration_minutes" id="duration_minutes" min="0" step="1" value="<?php echo esc_attr((string) ($editing->durationMinutes ?? '')); ?>"></td>
</tr>
<tr>
<th><label for="price"><?php esc_html_e('Price (dollars)', 'unsupervised-schedular'); ?></label></th>
<td><input type="number" name="price" id="price" min="0" step="0.01" value="0.00"></td>
<td><input type="number" name="price" id="price" min="0" step="0.01" value="<?php echo esc_attr(number_format($editing->price ?? 0.0, 2, '.', '')); ?>"></td>
</tr>
<tr>
<th><label for="billing_mode"><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></label></th>
<td>
<select name="billing_mode" id="billing_mode">
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>"><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
<option value="<?php echo esc_attr(Offering::BILLING_FULL_TERM); ?>"><?php esc_html_e('Full term upfront', 'unsupervised-schedular'); ?></option>
<option value="<?php echo esc_attr(Offering::BILLING_FULL_TERM); ?>" <?php echo $editing && Offering::BILLING_FULL_TERM === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Full term upfront', 'unsupervised-schedular'); ?></option>
</select>
</td>
</tr>
<tr>
<th><?php esc_html_e('Weekly reservation', 'unsupervised-schedular'); ?></th>
<td><label><input type="checkbox" name="allow_weekly" value="1"> <?php esc_html_e('Allow weekly recurring reservation (private)', 'unsupervised-schedular'); ?></label></td>
<td><label><input type="checkbox" name="allow_weekly" value="1" <?php echo $editing && $editing->allowWeekly ? 'checked' : ''; ?>> <?php esc_html_e('Allow weekly recurring reservation (private)', 'unsupervised-schedular'); ?></label></td>
</tr>
<tr>
<th><label for="capacity"><?php esc_html_e('Capacity', 'unsupervised-schedular'); ?></label></th>
<td><input type="number" name="capacity" id="capacity" min="0" step="1"> <span class="description"><?php esc_html_e('Group classes only', 'unsupervised-schedular'); ?></span></td>
<td><input type="number" name="capacity" id="capacity" min="0" step="1" value="<?php echo esc_attr((string) ($editing->capacity ?? '')); ?>"> <span class="description"><?php esc_html_e('Group classes only', 'unsupervised-schedular'); ?></span></td>
</tr>
<tr>
<th><label for="term_start"><?php esc_html_e('Start date', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="term_start" id="term_start" value="<?php echo esc_attr($editing->termStart ?? ''); ?>">
<span class="description"><?php esc_html_e('Group classes only — date of the first class', 'unsupervised-schedular'); ?></span>
</td>
</tr>
<tr>
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
<td>
<label><input type="radio" name="term_recurrence" value="single" <?php echo 'single' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label>
&nbsp;
<label><input type="radio" name="term_recurrence" value="weekly" <?php echo 'weekly' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('Weekly for', 'unsupervised-schedular'); ?></label>
<input type="number" name="term_sessions" min="1" max="52" value="<?php echo esc_attr((string) $termSessions); ?>" style="width:5em;"> <?php esc_html_e('sessions', 'unsupervised-schedular'); ?>
<p class="description"><?php esc_html_e('The end date is calculated from the start date and the number of weekly sessions.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr>
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>"></td>
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->scheduleNote ?? ''); ?>"></td>
</tr>
<tr>
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>"></td>
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
</tr>
<tr>
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
<td><label><input type="checkbox" name="is_active" value="1" <?php echo null === $editing || $editing->isActive ? 'checked' : ''; ?>> <?php esc_html_e('Open for registration', 'unsupervised-schedular'); ?></label></td>
</tr>
</table>
<?php submit_button(esc_html__('Add Offering', 'unsupervised-schedular')); ?>
<?php submit_button($editing ? esc_html__('Update Offering', 'unsupervised-schedular') : esc_html__('Add Offering', 'unsupervised-schedular')); ?>
<?php if ($editing) : ?>
<p><a href="<?php echo esc_url($baseUrl); ?>"><?php esc_html_e('Cancel editing', 'unsupervised-schedular'); ?></a></p>
<?php endif; ?>
</form>
<h2><?php esc_html_e('Current Offerings', 'unsupervised-schedular'); ?></h2>
@@ -75,11 +122,13 @@ if (! defined('ABSPATH')) {
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th style="width:4em;"><?php esc_html_e('ID', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Title', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Duration', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Term', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
@@ -87,13 +136,24 @@ if (! defined('ABSPATH')) {
<tbody>
<?php foreach ($offerings as $offering) : ?>
<tr>
<td><?php echo esc_html((string) $offering->id); ?></td>
<td><?php echo esc_html($offering->title); ?></td>
<td><?php echo esc_html($offering->kind); ?></td>
<td><?php echo $offering->durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '&mdash;'; ?></td>
<td><?php echo esc_html(number_format($offering->price, 2) . ' ' . $offering->currency); ?></td>
<td><?php echo esc_html($offering->billingMode); ?></td>
<td>
<?php if (null === $offering->termStart) : ?>
&mdash;
<?php elseif (null === $offering->termEnd || $offering->termEnd === $offering->termStart) : ?>
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart)); ?>
<?php else : ?>
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart) . ' ' . (string) mysql2date('M j, Y', $offering->termEnd)); ?>
<?php endif; ?>
</td>
<td><?php echo $offering->isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
<td>
<a class="button button-small" href="<?php echo esc_url(add_query_arg('usc_edit', (int) $offering->id, $baseUrl)); ?>"><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></a>
<form method="post" style="display:inline;">
<?php wp_nonce_field('usc_offering_action'); ?>
<input type="hidden" name="usc_action" value="delete">
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
use Unsupervised\Schedular\Auth\RegistrationApprovalController;
/**
* @var array<int, \WP_User> $awaitingApproval Confirmed, awaiting a decision.
* @var array<int, \WP_User> $awaitingConfirmation Not yet confirmed their email.
*/
?>
<div class="wrap">
<h1><?php esc_html_e('Pending Students', 'unsupervised-schedular'); ?></h1>
<div class="notice notice-info inline">
<p>
<?php esc_html_e('Students who signed themselves up appear here. Once they confirm their email you can approve them into a full student account, or reject the application to remove it.', 'unsupervised-schedular'); ?>
</p>
</div>
<h2><?php esc_html_e('Awaiting approval', 'unsupervised-schedular'); ?></h2>
<?php if (empty($awaitingApproval)) : ?>
<p><?php esc_html_e('No students are awaiting approval.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($awaitingApproval as $user) : ?>
<tr>
<td><?php echo esc_html((string) $user->display_name); ?></td>
<td><?php echo esc_html((string) $user->user_email); ?></td>
<td><?php echo esc_html((string) $user->user_registered); ?></td>
<td>
<form method="post" style="display:inline">
<?php wp_nonce_field(RegistrationApprovalController::NONCE_ACTION); ?>
<input type="hidden" name="user_id" value="<?php echo esc_attr((string) $user->ID); ?>">
<button type="submit" name="usc_action" value="approve" class="button button-primary"><?php esc_html_e('Approve', 'unsupervised-schedular'); ?></button>
</form>
<form method="post" style="display:inline" onsubmit="return confirm('<?php echo esc_js(__('Reject and delete this application?', 'unsupervised-schedular')); ?>');">
<?php wp_nonce_field(RegistrationApprovalController::NONCE_ACTION); ?>
<input type="hidden" name="user_id" value="<?php echo esc_attr((string) $user->ID); ?>">
<button type="submit" name="usc_action" value="reject" class="button button-link-delete"><?php esc_html_e('Reject', 'unsupervised-schedular'); ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h2><?php esc_html_e('Awaiting email confirmation', 'unsupervised-schedular'); ?></h2>
<?php if (empty($awaitingConfirmation)) : ?>
<p><?php esc_html_e('No students are awaiting email confirmation.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($awaitingConfirmation as $user) : ?>
<tr>
<td><?php echo esc_html((string) $user->display_name); ?></td>
<td><?php echo esc_html((string) $user->user_email); ?></td>
<td><?php echo esc_html((string) $user->user_registered); ?></td>
<td>
<form method="post" style="display:inline" onsubmit="return confirm('<?php echo esc_js(__('Reject and delete this application?', 'unsupervised-schedular')); ?>');">
<?php wp_nonce_field(RegistrationApprovalController::NONCE_ACTION); ?>
<input type="hidden" name="user_id" value="<?php echo esc_attr((string) $user->ID); ?>">
<button type="submit" name="usc_action" value="reject" class="button button-link-delete"><?php esc_html_e('Reject', 'unsupervised-schedular'); ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
+22
View File
@@ -15,6 +15,7 @@ if (! defined('ABSPATH')) {
* @var string $etransferEmail
* @var float $hstRate
* @var bool $stripeConfigured
* @var bool $openRegistration
*/
?>
<div class="wrap">
@@ -88,6 +89,27 @@ if (! defined('ABSPATH')) {
</td>
</tr>
</table>
<h2><?php esc_html_e('Registration', 'unsupervised-schedular'); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e('Student sign-up', 'unsupervised-schedular'); ?></th>
<td>
<fieldset>
<label>
<input type="checkbox" name="open_registration" value="1" <?php checked($openRegistration); ?>>
<?php esc_html_e('Allow anyone to sign up (email confirmation + admin approval)', 'unsupervised-schedular'); ?>
</label>
<p class="description">
<?php esc_html_e('Off by default — students join by invite only. When on, anyone can register on the student registration page; each new account must confirm its email and then be approved by a studio admin under Students → Pending Students before it can be used.', 'unsupervised-schedular'); ?>
</p>
<p class="description">
<?php esc_html_e('Turning this on also switches on Settings → General → “Anyone can register” and sets the default new-user role to Student. Turning it off restores those two settings to what they were before.', 'unsupervised-schedular'); ?>
</p>
</fieldset>
</td>
</tr>
</table>
<?php submit_button(esc_html__('Save Settings', 'unsupervised-schedular')); ?>
</form>
</div>
+151 -8
View File
@@ -7,16 +7,21 @@ if (! defined('ABSPATH')) {
/**
* @var \WP_User $student
* @var list<array{start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $upcoming
* @var list<array{start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
* @var list<array{offering: string, status: string}> $enrolments
* @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $upcoming
* @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
* @var list<array{id: int, offering: string, status: string}> $enrolments
* @var list<array{policy: string, version: string, context: string, accepted_at: string}> $acceptances
* @var list<array{question: string, answer: string, context: string}> $intake
* @var list<array{created_at: string, context: string, method: string, status: string, amount: float, tax_amount: float, total: float, currency: string, receipt: string}> $payments
* @var string $backUrl
* @var bool $canBilling
* @var string $billingOverride
* @var string $billingDefault
* @var string $notice
* @var string $error
*/
$renderLessons = static function (array $rows): void {
$renderLessons = static function (array $rows, bool $withActions = false): void {
if (empty($rows)) {
echo '<p>' . esc_html__('None.', 'unsupervised-schedular') . '</p>';
return;
@@ -29,15 +34,32 @@ $renderLessons = static function (array $rows): void {
<th><?php esc_html_e('Offering', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<?php if ($withActions) : ?>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row) : ?>
<tr>
<td><?php echo esc_html($row['start_dt'] !== '' ? $row['start_dt'] : '—'); ?></td>
<td><?php echo esc_html($row['start_dt'] !== '' ? (string) mysql2date('M j, Y g:i A', $row['start_dt']) : '—'); ?></td>
<td><?php echo esc_html($row['offering']); ?></td>
<td><?php echo esc_html($row['instructor']); ?></td>
<td><?php echo esc_html($row['status']); ?></td>
<?php if ($withActions) : ?>
<td>
<?php if ($row['status'] !== 'cancelled') : ?>
<form method="post" style="display:inline">
<?php wp_nonce_field('usc_student_actions'); ?>
<input type="hidden" name="usc_action" value="cancel_lesson">
<input type="hidden" name="lesson_id" value="<?php echo esc_attr((string) $row['id']); ?>">
<button type="submit" class="button-link button-link-delete" onclick="return confirm('<?php echo esc_js(__('Cancel this lesson? The slot is freed and any pending payment is voided.', 'unsupervised-schedular')); ?>');">
<?php esc_html_e('Cancel lesson', 'unsupervised-schedular'); ?>
</button>
</form>
<?php endif; ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
@@ -51,11 +73,33 @@ $renderLessons = static function (array $rows): void {
<a href="<?php echo esc_url($backUrl); ?>" class="page-title-action"><?php esc_html_e('Back to students', 'unsupervised-schedular'); ?></a>
</h1>
<?php if ($notice !== '') : ?>
<div class="notice notice-success is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<?php if ($error !== '') : ?>
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
<?php endif; ?>
<h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
<form method="post">
<?php wp_nonce_field('usc_student_actions'); ?>
<input type="hidden" name="usc_action" value="update_account">
<table class="form-table">
<tr><th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th><td><?php echo esc_html($student->user_email); ?></td></tr>
<tr><th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th><td><?php echo esc_html($student->user_registered); ?></td></tr>
<tr>
<th><label for="usc-display-name"><?php esc_html_e('Display name', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" id="usc-display-name" name="display_name" class="regular-text" value="<?php echo esc_attr($student->display_name); ?>" required></td>
</tr>
<tr>
<th><label for="usc-user-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label></th>
<td><input type="email" id="usc-user-email" name="user_email" class="regular-text" value="<?php echo esc_attr($student->user_email); ?>" required></td>
</tr>
<tr>
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($student->user_registered); ?></td>
</tr>
</table>
<?php submit_button(esc_html__('Save account details', 'unsupervised-schedular'), 'secondary', 'submit', false); ?>
</form>
<?php if ($canBilling) : ?>
<h2><?php esc_html_e('Billing method', 'unsupervised-schedular'); ?></h2>
@@ -78,7 +122,7 @@ $renderLessons = static function (array $rows): void {
<?php endif; ?>
<h2><?php esc_html_e('Upcoming lessons', 'unsupervised-schedular'); ?></h2>
<?php $renderLessons($upcoming); ?>
<?php $renderLessons($upcoming, true); ?>
<h2><?php esc_html_e('Past lessons', 'unsupervised-schedular'); ?></h2>
<?php $renderLessons($past); ?>
@@ -92,6 +136,7 @@ $renderLessons = static function (array $rows): void {
<tr>
<th><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
@@ -99,9 +144,107 @@ $renderLessons = static function (array $rows): void {
<tr>
<td><?php echo esc_html($enrolment['offering']); ?></td>
<td><?php echo esc_html($enrolment['status']); ?></td>
<td>
<?php if ($enrolment['status'] === 'active') : ?>
<form method="post" style="display:inline">
<?php wp_nonce_field('usc_student_actions'); ?>
<input type="hidden" name="usc_action" value="withdraw_enrollment">
<input type="hidden" name="enrollment_id" value="<?php echo esc_attr((string) $enrolment['id']); ?>">
<button type="submit" class="button-link button-link-delete" onclick="return confirm('<?php echo esc_js(__('Withdraw this student from the class? The seat is freed and any pending payment is voided.', 'unsupervised-schedular')); ?>');">
<?php esc_html_e('Withdraw', 'unsupervised-schedular'); ?>
</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h2><?php esc_html_e('Policy acceptances', 'unsupervised-schedular'); ?></h2>
<?php if (empty($acceptances)) : ?>
<p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Policy', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Version', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Context', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Accepted', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($acceptances as $acceptance) : ?>
<tr>
<td><?php echo esc_html($acceptance['policy']); ?></td>
<td><?php echo esc_html($acceptance['version']); ?></td>
<td><?php echo esc_html($acceptance['context']); ?></td>
<td><?php echo esc_html($acceptance['accepted_at'] !== '' ? (string) mysql2date('M j, Y g:i A', $acceptance['accepted_at']) : '—'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h2><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h2>
<?php if (empty($intake)) : ?>
<p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Context', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($intake as $row) : ?>
<tr>
<td><?php echo esc_html($row['question']); ?></td>
<td><?php echo esc_html($row['answer']); ?></td>
<td><?php echo esc_html($row['context']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($canBilling) : ?>
<h2><?php esc_html_e('Payment history', 'unsupervised-schedular'); ?></h2>
<?php if (empty($payments)) : ?>
<p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Date', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Context', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Method', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Subtotal', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Receipt', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($payments as $payment) : ?>
<tr>
<td><?php echo esc_html($payment['created_at'] !== '' ? (string) mysql2date('M j, Y g:i A', $payment['created_at']) : '—'); ?></td>
<td><?php echo esc_html($payment['context']); ?></td>
<td><?php echo esc_html($payment['method']); ?></td>
<td><?php echo esc_html($payment['status']); ?></td>
<td><?php echo esc_html(number_format_i18n($payment['amount'], 2)); ?></td>
<td><?php echo esc_html(number_format_i18n($payment['tax_amount'], 2)); ?></td>
<td><?php echo esc_html(number_format_i18n($payment['total'], 2) . ' ' . $payment['currency']); ?></td>
<td><?php echo esc_html($payment['receipt']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php endif; ?>
</div>
+1
View File
@@ -6,6 +6,7 @@ if (! defined('ABSPATH')) {
}
?>
<div id="us-booking-app" data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>">
<div id="us-my-lessons"></div>
<div id="us-slot-list">
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
</div>
+3 -1
View File
@@ -4,8 +4,10 @@ declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/** @var int $offeringId Offering id when the page is restricted to a single class; 0 for the full catalog. */
?>
<div id="us-group-app">
<div id="us-group-app"<?php echo $offeringId > 0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>
<div id="us-group-list">
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
</div>
+29 -4
View File
@@ -7,16 +7,36 @@ if (! defined('ABSPATH')) {
/**
* @var \Unsupervised\Schedular\Auth\Invite|null $invite
* @var bool $inviteValid Whether $invite can still be redeemed — only then is the email fixed.
* @var string $token Raw invite token from the request (only its hash is stored).
* @var bool $canRegister
* @var bool $success
* @var bool $open Whether open (self-approval) registration is enabled.
* @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm).
* @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'.
* @var string $loginUrl Where the post-confirmation sign-in link points.
* @var string $error
* @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
*/
?>
<div class="us-register-form">
<?php if ($success) : ?>
<?php if ($successType === 'invite') : ?>
<p class="us-success"><?php esc_html_e('Your account has been created and you are now logged in.', 'unsupervised-schedular'); ?></p>
<?php elseif (! $canRegister) : ?>
<?php elseif ($successType === 'confirm') : ?>
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, a studio admin will review and approve your account.', 'unsupervised-schedular'); ?></p>
<?php elseif ($successType === 'confirm_group') : ?>
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, your account is ready to use.', 'unsupervised-schedular'); ?></p>
<?php elseif ($confirmResult === 'ready') : ?>
<p class="us-success"><?php esc_html_e('Thanks — your email is confirmed and your account is ready to use.', 'unsupervised-schedular'); ?></p>
<p><a href="<?php echo esc_url($loginUrl); ?>"><?php esc_html_e('Sign in to your account', 'unsupervised-schedular'); ?></a></p>
<?php elseif ($confirmResult === '1') : ?>
<p class="us-success"><?php esc_html_e('Thanks — your email is confirmed. Your account is now awaiting studio approval; we will email you when it is ready.', 'unsupervised-schedular'); ?></p>
<p><a href="<?php echo esc_url($loginUrl); ?>"><?php esc_html_e('Sign in to your account', 'unsupervised-schedular'); ?></a></p>
<?php else : ?>
<?php if ($confirmResult === 'expired') : ?>
<p class="us-error" role="alert"><?php esc_html_e('That confirmation link is invalid or has expired. Please contact the studio.', 'unsupervised-schedular'); ?></p>
<?php endif; ?>
<?php if (! $canRegister) : ?>
<p><?php esc_html_e('Registration is by invitation only. Please use the link from your invitation email, or contact the studio.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<?php if ($error !== '') : ?>
@@ -25,11 +45,15 @@ if (! defined('ABSPATH')) {
<form method="post" action="">
<?php wp_nonce_field('us_student_register'); ?>
<input type="hidden" name="us_invite" value="<?php echo esc_attr($invite->token); ?>">
<input type="hidden" name="us_invite" value="<?php echo esc_attr($token); ?>">
<p>
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
<?php if ($inviteValid && $invite !== null && ! $invite->isGroup()) : ?>
<input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly>
<?php else : ?>
<input type="email" name="email" id="us-reg-email" autocomplete="email" required>
<?php endif; ?>
</p>
<p>
<label for="us-reg-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?></label>
@@ -64,4 +88,5 @@ if (! defined('ABSPATH')) {
</p>
</form>
<?php endif; ?>
<?php endif; ?>
</div>
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class EmailConfirmationHandlerTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('wp_unslash')->alias(static fn ($v) => $v);
Functions\when('sanitize_key')->alias(static fn ($v) => $v);
}
protected function tearDown(): void
{
unset($_REQUEST['action']);
$_GET = [];
parent::tearDown();
}
/**
* Stub everything maybeConfirm() needs for a valid token belonging to user
* 9, with wp_safe_redirect throwing so the redirect URL can be asserted.
*
* @param bool $autoApprove Whether user 9 carries the auto-approve marker.
*/
private function stubConfirmContext(bool $autoApprove): void
{
$this->stubMode(StudioSettings::MODE_INVITE);
Functions\when('is_admin')->justReturn(false);
Functions\when('sanitize_text_field')->returnArg();
Functions\when('get_permalink')->justReturn('http://wp/register/');
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
Functions\when('get_users')->justReturn([9]);
Functions\when('get_user_meta')->alias(static function (int $id, string $key) use ($autoApprove) {
if ($key === RegistrationStatus::META_CONFIRM_EXPIRES) {
return '2030-01-01 00:00:00';
}
if ($key === RegistrationStatus::META_AUTO_APPROVE) {
return $autoApprove ? '1' : '';
}
return '';
});
Functions\when('update_user_meta')->justReturn(true);
Functions\when('delete_user_meta')->justReturn(true);
Functions\when('wp_safe_redirect')->alias(static function (string $url): void {
throw new \RuntimeException('redirect:' . $url);
});
$_GET['us_confirm'] = 'rawtoken';
}
public function testConfirmAutoApprovesGroupLinkSignupWithoutAdminReview(): void
{
$this->stubConfirmContext(true);
$user = Mockery::mock(\WP_User::class);
Functions\when('get_user_by')->justReturn($user);
$mailer = Mockery::mock(RegistrationMailer::class);
$mailer->shouldReceive('sendApproved')->once()->with($user)->andReturn(true);
$mailer->shouldNotReceive('notifyAdminsPending');
$handler = new EmailConfirmationHandler(new StudioSettings(), $mailer);
try {
$handler->maybeConfirm();
self::fail('Expected a redirect');
} catch (\RuntimeException $e) {
self::assertStringContainsString('us_confirmed=ready', $e->getMessage());
}
}
public function testConfirmWithoutAutoApproveNotifiesAdminsAndStaysPending(): void
{
$this->stubConfirmContext(false);
$user = Mockery::mock(\WP_User::class);
Functions\when('get_user_by')->justReturn($user);
$mailer = Mockery::mock(RegistrationMailer::class);
$mailer->shouldReceive('notifyAdminsPending')->once()->with($user)->andReturn(true);
$mailer->shouldNotReceive('sendApproved');
$handler = new EmailConfirmationHandler(new StudioSettings(), $mailer);
try {
$handler->maybeConfirm();
self::fail('Expected a redirect');
} catch (\RuntimeException $e) {
self::assertStringContainsString('us_confirmed=1', $e->getMessage());
}
}
private function handler(): EmailConfirmationHandler
{
return new EmailConfirmationHandler(new StudioSettings(), new RegistrationMailer());
}
private function stubMode(string $mode): void
{
Functions\when('get_option')->alias(static function (string $name, $default = false) use ($mode) {
if ($name === StudioSettings::OPT_REGISTRATION_MODE) {
return $mode;
}
if ($name === RegistrationController::OPTION_PAGE) {
return 5;
}
return $default;
});
}
public function testRegisterUrlPassesThroughWhenClosed(): void
{
$this->stubMode(StudioSettings::MODE_INVITE);
self::assertSame('http://wp/register', $this->handler()->registerUrl('http://wp/register'));
}
public function testRegisterUrlPointsAtRegistrationPageWhenOpen(): void
{
$this->stubMode(StudioSettings::MODE_SELF_APPROVAL);
Functions\when('get_permalink')->justReturn('http://studio.test/register');
self::assertSame('http://studio.test/register', $this->handler()->registerUrl('http://wp/register'));
}
public function testBlockNativeRegistrationIsNoOpWhenClosed(): void
{
$this->stubMode(StudioSettings::MODE_INVITE);
$_REQUEST['action'] = 'register';
// Must not redirect/exit when open registration is off.
Functions\expect('wp_safe_redirect')->never();
$this->handler()->blockNativeRegistration();
unset($_REQUEST['action']);
self::assertTrue(true);
}
public function testBlockNativeRegistrationIgnoresOtherActions(): void
{
$this->stubMode(StudioSettings::MODE_SELF_APPROVAL);
$_REQUEST['action'] = 'lostpassword';
Functions\expect('wp_safe_redirect')->never();
$this->handler()->blockNativeRegistration();
unset($_REQUEST['action']);
self::assertTrue(true);
}
public function testBlockRegistrationErrorsRejectsWhenOpen(): void
{
$this->stubMode(StudioSettings::MODE_SELF_APPROVAL);
$errors = $this->handler()->blockRegistrationErrors(new \WP_Error());
self::assertSame('us_registration_redirect', $errors->get_error_code());
}
public function testBlockRegistrationErrorsPassesThroughWhenClosed(): void
{
$this->stubMode(StudioSettings::MODE_INVITE);
$errors = $this->handler()->blockRegistrationErrors(new \WP_Error());
self::assertSame('', $errors->get_error_code());
}
}
+39 -5
View File
@@ -34,21 +34,55 @@ class InviteRepositoryTest extends TestCase
Mockery::on(static function (array $d): bool {
return $d['email'] === '[email protected]'
&& $d['token'] === 'tok123'
&& $d['kind'] === Invite::KIND_PERSONAL
&& $d['status'] === Invite::STATUS_PENDING
&& $d['invited_by'] === 2;
&& $d['invited_by'] === 2
&& $d['expires_at'] === null;
}),
['%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s']
['%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s']
);
$this->db->insert_id = 5;
self::assertSame(5, $this->repo->insert(new Invite('[email protected]', 'tok123', invitedBy: 2)));
}
public function testInsertReturnsZeroWhenDbInsertFails(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00');
$this->db->shouldReceive('insert')->once()->andReturn(false);
$this->db->insert_id = 99; // Stale id from an earlier insert must not leak out.
self::assertSame(0, $this->repo->insert(new Invite('[email protected]', 'tok123')));
}
public function testInsertPersistsGroupKindAndExpiry(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_invites',
Mockery::on(static function (array $d): bool {
return $d['email'] === ''
&& $d['kind'] === Invite::KIND_GROUP
&& $d['expires_at'] === '2026-08-31 23:59:59';
}),
Mockery::type('array')
);
$this->db->insert_id = 6;
$invite = new Invite('', 'tok456', invitedBy: 2, kind: Invite::KIND_GROUP, expiresAt: '2026-08-31 23:59:59');
self::assertSame(6, $this->repo->insert($invite));
}
public function testFindByTokenReturnsInvite(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/token = %s/'), 'tok123')
->with(Mockery::pattern('/token = %s/'), 'wp_us_invites', 'tok123')
->andReturn('SELECT ...');
$this->db->shouldReceive('get_row')->andReturn($this->row());
@@ -71,7 +105,7 @@ class InviteRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/email = %s AND status = %s/'), '[email protected]', Invite::STATUS_PENDING)
->with(Mockery::pattern('/email = %s AND status = %s/'), 'wp_us_invites', '[email protected]', Invite::STATUS_PENDING)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_row')->andReturn($this->row());
@@ -83,7 +117,7 @@ class InviteRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/status = %s/'), Invite::STATUS_PENDING)
->with(Mockery::pattern('/status = %s/'), 'wp_us_invites', Invite::STATUS_PENDING)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);

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