8 Commits
Author SHA1 Message Date
thatguygriff c611268bdb Merge pull request 'Fix field-length saves, student wp-admin access, and empty instructor picker' (#111) from fix/field-length-student-admin-instructor-picker into main
CI / Tests (PHP 8.1) (push) Successful in 39s
CI / Tests (PHP 8.2) (push) Successful in 1m2s
CI / No Debug Code (push) Successful in 3s
CI / PHPStan (push) Successful in 2m54s
CI / Coding Standards (push) Successful in 2m58s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
Release / Build and Publish Release (push) Successful in 2m59s
Release / Open next-version bump PR (push) Successful in 5s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #111
2026-07-24 23:27:30 +00:00
thatguygriffandClaude Opus 4.8 721c4be1d6 Fix field-length saves, student wp-admin access, and empty instructor picker
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m47s
CI / PHPStan (pull_request) Successful in 3m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Three bug fixes for the 1.2.1 section:

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

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

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

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

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

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

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

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

composer test, composer lint, composer cs all pass.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 15:56:40 -03:00
41 changed files with 1220 additions and 82 deletions
+14
View File
@@ -11,18 +11,32 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work.
## [1.2.1]
### Fixed
- Registration questions, offering titles/notes, and policy names longer than their storage limit are no longer silently discarded. Previously typing a fixed-size field past its maximum length reported success but saved nothing — the database quietly rejected the over-long value. These fields now cap the input in the form, and the API rejects an over-long value with a clear error.
- Students can no longer reach the WordPress dashboard. A student who navigates to `wp-admin` is redirected to the site front end and the admin toolbar is hidden for them, so they only ever see the studio's booking pages. Anyone who runs the studio — administrators, studio admins, and instructors — keeps full `wp-admin` access.
- The instructor picker on the **Add/Edit Offering** form no longer comes up empty for a solo studio owner. When the person running the studio teaches from a WordPress administrator account (the default single-account setup), they now appear in the instructor dropdown and can be assigned to a class.
## [1.2.0]
### Added
- Offerings can now bill on a schedule: **weekly** (a pending payment 24 hours before each lesson) or **monthly** (one payment on the 1st for that month's lessons), alongside the existing one-time and full-term modes. Applies to both private lessons and group classes. A daily job generates due payments, and each student receives one consolidated itemised email per scan; batched payments share a reference so the admin Payments queue groups them with a lump-sum total for e-transfer reconciliation. Cancelling a lesson never voids a scheduled payment.
- Cancelling a lesson that was **already paid for** now credits the student that money instead of leaving it as a manual refund. The credit is one lesson's share of what they paid — the whole amount for a single lesson, or a per-lesson slice of a monthly charge or a full-term series. The daily billing scan automatically applies any available credit against a student's upcoming weekly/monthly charges before emailing their notice, which shows the credit applied and the reduced total due; a charge fully covered by credit is settled and leaves the admin Payments queue. A student's outstanding credit balance is shown on their **student detail** page in the studio admin. Still-pending (unpaid) payments continue to be voided on cancellation as before.
- Group classes now carry an **enrolment deadline** the instructor sets on the offering. It defaults to the first day of the class, and once it passes students can no longer enrol — the enrolment page shows the class as closed and the API rejects late enrolments. While enrolment is open, each class card shows an "Enrol by" date.
- Group classes now also carry a **withdrawal deadline** the instructor sets per class. Up to that day a student can withdraw themselves from the class (the group-class page shows a **Withdraw** button) — this frees their seat and voids any pending payment but does **not** credit their account. After the deadline self-withdrawal closes and the student must ask the studio, who can still withdraw them by hand from the student detail page. Leaving the deadline blank keeps self-withdrawal open indefinitely.
- The **Add/Edit Offering** form now shows only the fields relevant to the selected kind: the group-class settings (capacity, dates, times, enrolment/withdrawal deadlines, sessions, schedule note, invite-only) appear only for a group class, and the weekly-reservation option only for a private lesson.
- Instructors can add students to any group class by hand from its details page (**Add students directly**), which now appears for public classes too, not just invite-only ones. This bypasses the enrolment deadline and capacity, so a student can be enrolled as a **late enrolment** after the class has closed to self-enrolment.
- Studio admins and instructors can open a **lesson detail view** from the Scheduler and My Lessons lists, showing the offering booked, the policy versions the student accepted (with acceptance time and IP), and their intake answers. On My Lessons an instructor may only open their own lessons; the studio Scheduler may open any.
- The **Student Registration** block's "registration is by invitation only" message is now customisable, under a new **Invitation-only notice** panel (shortcode: `invite_only_message`). Leaving it blank keeps the default wording.
### Changed
- The student **upcoming lessons** panel now shows each booked offering's name and length beside the time, and lists only the soonest five lessons with a "Show all" reveal. The Scheduler and My Lessons week/list views likewise show the booked offering.
### Fixed
- Accepting an invitation now keeps the student signed in. Previously the registration form processed the submission after the page had started rendering, so the sign-in cookie was never sent and the new student was bounced back to the (logged-out) registration page; it is now handled before any output, and the student lands logged in.
- Account-registration questions now save. On sites first installed before account-scope questions existed, the `us_questions.offering_id` column was left `NOT NULL` (the schema migration relied on `dbDelta`, which does not reliably relax a column to allow `NULL`), so saving an account question failed with "Column 'offering_id' cannot be null". A one-time, self-healing migration relaxes the column on the next load.
## [1.1.1]
### Fixed
+16 -3
View File
@@ -5,7 +5,7 @@
const { registerBlockType } = wp.blocks;
const { createElement: el, useState, useEffect } = wp.element;
const { useBlockProps, InspectorControls } = wp.blockEditor;
const { PanelBody, SelectControl, ToggleControl } = wp.components;
const { PanelBody, SelectControl, ToggleControl, TextareaControl } = wp.components;
const { useSelect } = wp.data;
const apiFetch = wp.apiFetch;
const ServerSideRender = wp.serverSideRender;
@@ -151,10 +151,12 @@
shortcode: 'us_student_register',
attributes: {
loginPageId: { type: 'number', default: 0 },
inviteOnlyMessage: { type: 'string', default: '' },
},
inspector: (attributes, setAttributes) => el(
inspector: (attributes, setAttributes) => [
el(
PanelBody,
{ title: __('After email confirmation', 'unsupervised-schedular') },
{ title: __('After email confirmation', 'unsupervised-schedular'), key: 'confirmation' },
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'),
@@ -163,6 +165,17 @@
onChange: (loginPageId) => setAttributes({ loginPageId }),
})
),
el(
PanelBody,
{ title: __('Invitation-only notice', 'unsupervised-schedular'), key: 'invite-only' },
el(TextareaControl, {
label: __('Message', 'unsupervised-schedular'),
help: __('Shown when registration is invite-only and the visitor has no valid invite link. Leave blank to use the default wording.', 'unsupervised-schedular'),
value: attributes.inviteOnlyMessage,
onChange: (inviteOnlyMessage) => setAttributes({ inviteOnlyMessage }),
})
),
],
},
{
name: 'us-scheduler/group-classes',
+30 -6
View File
@@ -123,7 +123,14 @@
return !deadline || todayYmd() <= deadline;
}
function renderClasses(offerings, enrolledOfferingIds) {
// Self-withdrawal closes at the end of the withdrawal-deadline day. Unlike
// enrolment there is no implicit default: an unset deadline keeps withdrawal
// open. Mirrors the server-side Offering::isWithdrawalOpen() gate.
function isWithdrawalOpen(o) {
return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline;
}
function renderClasses(offerings, enrolledMap) {
let groups = offerings.filter((o) => o.kind === 'group_class');
if (singleOfferingId) {
groups = groups.filter((o) => Number(o.id) === singleOfferingId);
@@ -143,11 +150,14 @@
${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>
${!enrolledOfferingIds.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
: ''}
${enrolledOfferingIds.has(Number(o.id))
? '<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>'
${enrolledMap.has(Number(o.id))
? `<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>
${isWithdrawalOpen(o)
? `<button data-enrollment-id="${enrolledMap.get(Number(o.id))}" class="us-withdraw-btn">Withdraw</button>`
: '<p class="us-withdraw-closed">Withdrawal has closed — contact the studio to withdraw.</p>'}`
: (isEnrollmentOpen(o)
? `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`
: '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>')}
@@ -158,6 +168,20 @@
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
btn.addEventListener('click', () => openEnrolment(offering));
});
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId));
});
}
function withdraw(enrollmentId) {
clearError();
if (!window.confirm('Withdraw from this class? Your seat is released and any pending payment is cancelled.')) {
return;
}
apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' })
.then(loadClasses)
.catch((err) => showError(err.message));
}
function openEnrolment(offering) {
@@ -240,9 +264,9 @@
])
.then(([offerings, enrollments]) => renderClasses(
offerings,
new Set(enrollments
new Map(enrollments
.filter((e) => e.status === 'active')
.map((e) => Number(e.offering_id)))
.map((e) => [Number(e.offering_id), e.id]))
))
.catch((err) => showError(err.message));
}
+2 -1
View File
@@ -97,7 +97,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
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. If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`.
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. The submission is processed on `template_redirect` (`RegistrationPage::maybeHandleSubmit()`) **before** any page output so `wp_set_auth_cookie()` actually persists — it then post/redirect/gets back to the page with `?us_registered=invite`, where the now-logged-in student sees the "created and logged in" confirmation. (Processing the form inside `render()`, which runs during `the_content`, sent the cookie after headers and left the student logged out on the next view.) If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`.
## Flow (self-approval mode)
1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`).
@@ -126,6 +126,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
## Frontend Shortcode
- `[us_student_register]` — the registration page. In `invite` mode: shows the form for a valid pending invite, else an "by invitation only" message. In `self_approval` mode: shows the form to anyone (editable email), and renders confirmation-result notices from `?us_confirmed=1|expired`.
- The invitation-only message is customisable: block attribute `inviteOnlyMessage` (set under the block's **Invitation-only notice** panel) / shortcode attribute `invite_only_message`. Blank falls back to the default wording (`RegistrationPage::inviteOnlyMessage()`).
## Token Redirect
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
+24 -1
View File
@@ -62,11 +62,34 @@ deadline (and capacity) so a **late enrolment** can be added after the class has
closed. Past the deadline the details page labels these as late enrolments. See
**Admin Interface** below.
## Withdrawal Flow
A student may withdraw themselves from a class they are enrolled in through the same
group-class page: an active enrolment shows a **Withdraw** button.
`POST /enrollments/{id}/withdraw` marks the enrolment `cancelled` (freeing its
capacity seat) and voids any still-pending payment. It **never issues an account
credit** — a timely withdrawal is a clean exit, not a refund (credits are reserved
for cancelled lessons; see `credits.md`).
Self-withdrawal is bounded by the class's **withdrawal deadline** (the instructor's
`withdrawal_deadline`; see `offerings.md`). Unlike the enrolment deadline it has no
implicit default — a class with no deadline set stays open to withdrawal for its
whole life. Past the deadline `POST /enrollments/{id}/withdraw` rejects the request
with `403 withdrawal_closed`, and the class card shows "Withdrawal has closed —
contact the studio to withdraw." in place of the Withdraw button. The endpoint also
returns `404 not_found` for an unknown enrolment and `403 forbidden` when the
enrolment is not the caller's own; a withdrawal of an already-cancelled enrolment is
idempotent.
The deadline only bounds student **self**-withdrawal. A studio admin can withdraw a
student at any time from the **student detail page** (`Auth\StudentActions::withdrawEnrollment`),
which is never subject to the deadline.
## REST API
| Method | Endpoint | Permission |
|----------|----------------------------------------------|----------------------------------|
|----------|-------------------------------------------------|----------------------------------|
| `GET` | `/wp-json/us-scheduler/v1/enrollments` | Any logged-in user |
| `POST` | `/wp-json/us-scheduler/v1/enrollments` | `book_lesson` |
| `POST` | `/wp-json/us-scheduler/v1/enrollments/{id}/withdraw` | Owner (the enrolled student) |
`POST /enrollments` body: `offering_id`, `answers[]` (`question_id` → value),
`accepted_policy_version_ids[]`, and payment data (see `payments.md`). The
+15
View File
@@ -22,6 +22,7 @@ An offering is anything a student can register for: a private-lesson type (30 or
| `term_end` | DATE | Group / term offerings — last day; NULL otherwise |
| `class_time` | TIME | Group only — time of day each session starts; NULL otherwise |
| `enrollment_deadline` | DATE | Group only — last day students may enrol; NULL defaults to `term_start` (the first class day) |
| `withdrawal_deadline` | DATE | Group only — last day a student may withdraw themselves; NULL keeps self-withdrawal open indefinitely |
| `schedule_note` | VARCHAR(191) | Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"|
| `cancellation_cutoff_hours` | SMALLINT UNSIGNED | Optional per-offering cancellation cutoff in hours; NULL inherits the studio default (see `cancellation-cutoff.md`) |
| `access_mode` | VARCHAR(20) | `public` (listed in the catalog) or `invite_only` (group classes hidden from the catalog — see `group-classes.md`) |
@@ -68,6 +69,20 @@ against that effective deadline (inclusive — the deadline day is still open).
enrolment endpoint enforces it (`403 enrollment_closed`) and the front-end
group-class list mirrors the same rule; see `group-classes.md`.
## Withdrawal deadline
A group class also carries an optional `withdrawal_deadline` — the last day a
student may withdraw *themselves* from the class. Unlike the enrolment deadline it
has **no implicit default**: `Offering::isWithdrawalOpen($today)` treats an unset
(NULL) deadline as always open, so a class only closes to self-withdrawal once the
instructor sets a date and it passes (comparison is inclusive — the deadline day is
still open). A withdrawal made while open frees the seat and voids any still-pending
payment but **never issues an account credit** (credits are reserved for cancelled
lessons; see `credits.md`). Once the deadline passes the student must contact the
studio, and an admin withdraws them by hand from the student detail page — the admin
path is never subject to the deadline. The student endpoint enforces it
(`403 withdrawal_closed`) and the front-end group-class list mirrors the rule; see
`group-classes.md`.
## Instructor assignment
Every offering has an owning `instructor_id`. A studio admin
(`manage_instructors`) sees an **Instructor** picker on the offering form and may
+1
View File
@@ -80,6 +80,7 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
- Signup step two: `Unsupervised\Schedular\Auth\RegistrationPage`, `templates/frontend/register-page.php`, `assets/js/register.js`
- Admin review: `Unsupervised\Schedular\Auth\StudentHistory::registrationInfo()`, `templates/admin/student-detail.php`
- Schema: `us_questions.scope` + nullable `us_questions.offering_id` (requires a plugin version bump so `dbDelta` runs)
- Nullability repair: `dbDelta` does **not** reliably relax a column from `NOT NULL` to `NULL`, so sites created before account-scope questions kept `offering_id NOT NULL` and rejected account inserts. `QuestionRepository::ensureOfferingNullable()` re-applies the nullable definition (idempotent `ALTER … MODIFY`); `Plugin::boot()` runs it once, guarded by the `us_questions_offering_nullable` option rather than the version gate (affected sites may already be on the current version)
## Tests
- `tests/Unit/Registration/QuestionRepositoryTest.php`
+102 -14
View File
@@ -30,6 +30,13 @@ class RegistrationPage {
*/
private const RESULT_CONFIRM_GROUP = 'confirm_group';
/**
* Validation error from the most recent submission processed on
* `template_redirect`, carried over to {@see render()} so it can be shown
* inline with the form. Empty when the last submit succeeded or none ran.
*/
private string $submitError = '';
public function __construct(
private InviteRepository $invites,
private PolicyRepository $policies,
@@ -45,15 +52,29 @@ class RegistrationPage {
/**
* Renders the student registration shortcode output.
*
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
* shortcode attributes (`login_page_id`).
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`,
* `inviteOnlyMessage`) or shortcode
* attributes (`login_page_id`,
* `invite_only_message`).
*/
public function render( array $atts ): string {
// A just-completed invite signup is redirected back here already logged
// in (see maybeHandleSubmit); its success flag distinguishes that from a
// visitor who simply happens to be signed in already.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
if ( is_user_logged_in() ) {
if ( self::RESULT_INVITE === $registered ) {
return '<div class="us-register-form"><p class="us-success">'
. esc_html__( 'Your account has been created and you are now logged in.', 'unsupervised-schedular' )
. '</p></div>';
}
return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked below.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked in maybeHandleSubmit.
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) );
// Only the token's hash is stored, so hash the submitted token for lookup.
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
@@ -65,17 +86,12 @@ class RegistrationPage {
// fail to submit — the stale invite's address.
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
$error = '';
$successType = '';
if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) {
$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;
}
}
// The submission itself is processed in maybeHandleSubmit on
// template_redirect (before any output), so the invite auto-login cookie
// is actually sent. Its success signal returns here as ?us_registered;
// only a validation error is carried on the instance to show inline.
$successType = in_array( $registered, [ self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ? $registered : '';
$error = $this->submitError;
// Result of an email-confirmation link (set by EmailConfirmationHandler's redirect).
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, not a state change.
@@ -87,6 +103,7 @@ class RegistrationPage {
$policyForms = $this->signupPolicies();
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$canRegister = $open || $inviteValid;
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
// The two-step script only matters when there is a second step to reveal.
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
@@ -98,6 +115,77 @@ class RegistrationPage {
return (string) ob_get_clean();
}
/**
* Process a submitted registration on `template_redirect`, before any page
* output. Running here (rather than inside {@see render()}, which fires
* during `the_content` after headers are sent) is what lets the invite
* branch's `wp_set_auth_cookie()` actually persist — otherwise the student
* appears logged in for a single render and is logged out on the next view.
*
* On success the request is redirected (post/redirect/get) with a
* `?us_registered` flag so a refresh cannot resubmit; a validation error is
* stashed for {@see render()} to show inline with the form.
*/
public function maybeHandleSubmit(): void {
if ( ! isset( $_POST['us_register'] ) || is_user_logged_in() ) {
return;
}
if ( ! check_admin_referer( 'us_student_register' ) ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer above.
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) );
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
$open = $this->settings->openRegistrationEnabled();
$result = $this->handleSubmit( $invite, $open );
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) {
$this->redirect( add_query_arg( 'us_registered', $result, $this->currentUrl() ) );
return;
}
$this->submitError = $result;
}
/**
* The current page's clean permalink, used as the post/redirect/get target
* so the invite token and any stale flags are dropped from the URL.
*/
private function currentUrl(): string {
$url = get_permalink();
return is_string( $url ) ? $url : home_url( '/' );
}
/**
* Issues the post-submit redirect and stops the request. Split out so tests
* can observe the target without the process exiting.
*/
protected function redirect( string $url ): void {
wp_safe_redirect( $url );
exit;
}
/**
* The message shown when registration is closed and no valid invite is
* present. Studios can override the default via the block
* (`inviteOnlyMessage`) or shortcode (`invite_only_message`) attribute.
*
* @param array<int|string, mixed> $atts
*/
private function inviteOnlyMessage( array $atts ): string {
$custom = trim( Val::string( $atts['inviteOnlyMessage'] ?? $atts['invite_only_message'] ?? '' ) );
if ( '' !== $custom ) {
return $custom;
}
return esc_html__( 'Registration is by invitation only. Please use the link from your invitation email, or contact the studio.', 'unsupervised-schedular' );
}
/**
* Redirect to the configured registration page when an invite token lands
* elsewhere (e.g. a link generated before the page was selected). Hooked on
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* Keeps front-end-only users (students) out of wp-admin entirely.
*
* Students authenticate through the front-end login shortcode and do all of
* their work — booking, viewing lessons, paying — on the site's public pages.
* They have no reason to see the WordPress dashboard, profile screen, or admin
* bar, so this guard redirects them to the front end if they reach wp-admin and
* hides the admin bar for them everywhere.
*
* Access is decided by capability, not role: anyone holding a back-office
* capability (a WordPress administrator, studio admin, or instructor) keeps full
* wp-admin access, while a user with none of them is treated as front-end only.
*/
class StudentAdminGuard {
/**
* Capabilities that grant a genuine reason to be in wp-admin. A user holding
* none of these is front-end only and is kept out of the dashboard.
*
* @var list<string>
*/
private const BACK_OFFICE_CAPS = [
'manage_options',
RoleManager::CAP_MANAGE_INSTRUCTORS,
RoleManager::CAP_MANAGE_STUDENTS,
RoleManager::CAP_MANAGE_OFFERINGS,
RoleManager::CAP_MANAGE_QUESTIONS,
RoleManager::CAP_MANAGE_POLICIES,
RoleManager::CAP_MANAGE_BILLING,
RoleManager::CAP_MANAGE_AVAILABILITY,
RoleManager::CAP_VIEW_ALL_LESSONS,
RoleManager::CAP_VIEW_ALL_PAYMENTS,
RoleManager::CAP_VIEW_OWN_PAYMENTS,
RoleManager::CAP_EXPORT_PAYMENTS,
];
public function register(): void {
add_action( 'admin_init', [ $this, 'redirectFromDashboard' ] );
add_filter( 'show_admin_bar', [ $this, 'hideAdminBar' ] );
}
/**
* Redirect a front-end-only user away from any wp-admin page to the site
* home, so the dashboard and profile screens are never reachable.
*/
public function redirectFromDashboard(): void {
if ( ! $this->shouldBlockAdminAccess() ) {
return;
}
wp_safe_redirect( home_url( '/' ) );
exit;
}
/**
* Whether the current request into wp-admin should be bounced to the front
* end. AJAX requests are always allowed through so front-end features that
* call admin-ajax keep working.
*/
public function shouldBlockAdminAccess(): bool {
if ( wp_doing_ajax() ) {
return false;
}
if ( ! is_user_logged_in() ) {
return false;
}
return ! $this->hasBackOfficeAccess();
}
/**
* Hide the admin bar for front-end-only users; leave it untouched for anyone
* with back-office access.
*
* @param bool $show Whether WordPress would otherwise show the admin bar.
*/
public function hideAdminBar( bool $show ): bool {
if ( is_user_logged_in() && ! $this->hasBackOfficeAccess() ) {
return false;
}
return $show;
}
/**
* Whether the current user holds any capability that warrants wp-admin access.
*/
private function hasBackOfficeAccess(): bool {
foreach ( self::BACK_OFFICE_CAPS as $cap ) {
if ( current_user_can( $cap ) ) {
return true;
}
}
return false;
}
}
+4
View File
@@ -109,6 +109,10 @@ class BlockRegistrar {
'type' => 'number',
'default' => 0,
],
'inviteOnlyMessage' => [
'type' => 'string',
'default' => '',
],
],
],
'us-scheduler/group-classes' => [
+56
View File
@@ -59,6 +59,18 @@ class EnrollmentEndpoint {
],
]
);
register_rest_route(
$route_namespace,
'/enrollments/(?P<id>\d+)/withdraw',
[
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'withdraw' ],
'permission_callback' => [ $this, 'isLoggedIn' ],
],
]
);
}
public function index( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
@@ -148,6 +160,50 @@ class EnrollmentEndpoint {
);
}
/**
* Withdraw the current student from a group class they enrolled in. Allowed
* only while the offering's withdrawal deadline is open (a class with no
* deadline set stays open indefinitely); once it passes, the student must
* contact the studio and an admin withdraws them by hand. A timely withdrawal
* frees the seat and voids any still-pending payment but never issues an
* account credit — that is reserved for cancelled lessons.
*/
public function withdraw( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$enrollment = $this->enrollments->findById( $id );
if ( null === $enrollment ) {
return new \WP_Error( 'not_found', __( 'Enrolment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( get_current_user_id() !== $enrollment->studentId ) {
return new \WP_Error( 'forbidden', __( 'You cannot withdraw from this class.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( Enrollment::STATUS_ACTIVE === $enrollment->status ) {
$offering = $this->offerings->findById( $enrollment->offeringId );
if ( null !== $offering && ! $offering->isWithdrawalOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) {
return new \WP_Error(
'withdrawal_closed',
__( 'Withdrawal for this class has closed. Please contact the studio.', 'unsupervised-schedular' ),
[ 'status' => 403 ]
);
}
$this->enrollments->updateStatus( $id, Enrollment::STATUS_CANCELLED );
$this->payments->voidPending( $enrollment->paymentId );
}
return new \WP_REST_Response(
[
'id' => $id,
'status' => Enrollment::STATUS_CANCELLED,
],
200
);
}
public function isLoggedIn(): bool {
return is_user_logged_in();
}
+25
View File
@@ -54,6 +54,15 @@ class Offering {
*/
public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ];
/** Maximum length of the title, matching the `title` VARCHAR(191) column. */
public const MAX_TITLE_LENGTH = 191;
/** Maximum length of the schedule note, matching the `schedule_note` VARCHAR(191) column. */
public const MAX_SCHEDULE_NOTE_LENGTH = 191;
/** Maximum length of the e-transfer email, matching the `etransfer_email` VARCHAR(191) column. */
public const MAX_ETRANSFER_EMAIL_LENGTH = 191;
public function __construct(
public readonly int $instructorId,
public readonly string $kind,
@@ -69,6 +78,7 @@ class Offering {
public readonly ?string $termEnd = null,
public readonly ?string $classTime = null,
public readonly ?string $enrollmentDeadline = null,
public readonly ?string $withdrawalDeadline = null,
public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = null,
public readonly ?int $cancellationCutoffHours = null,
@@ -114,6 +124,19 @@ class Offering {
return null === $deadline || $today <= $deadline;
}
/**
* Whether a student may still withdraw themselves from this group class on
* `$today` (a `Y-m-d` date). Withdrawal stays open through the end of the
* deadline day. Unlike the enrolment deadline there is no implicit default: a
* class with no withdrawal deadline set stays open to withdrawal for its whole
* life, so the instructor must set a date to lock students in. A withdrawal
* made while open never issues an account credit — it only frees the seat and
* voids any still-pending payment.
*/
public function isWithdrawalOpen( string $today ): bool {
return null === $this->withdrawalDeadline || $today <= $this->withdrawalDeadline;
}
/**
* Normalise a submitted term date to canonical `Y-m-d`, or null when it is
* not a real calendar date. Round-trips through DateTimeImmutable so
@@ -214,6 +237,7 @@ class Offering {
termEnd: Val::stringOrNull( $row->term_end ),
classTime: Val::stringOrNull( $row->class_time ?? null ),
enrollmentDeadline: Val::stringOrNull( $row->enrollment_deadline ?? null ),
withdrawalDeadline: Val::stringOrNull( $row->withdrawal_deadline ?? null ),
scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
@@ -249,6 +273,7 @@ class Offering {
'term_end' => $this->termEnd,
'class_time' => $this->classTime,
'enrollment_deadline' => $this->enrollmentDeadline,
'withdrawal_deadline' => $this->withdrawalDeadline,
'schedule_note' => $this->scheduleNote,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'access_mode' => $this->accessMode,
+34 -4
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
@@ -11,6 +12,7 @@ class OfferingController {
public function __construct(
private OfferingRepository $repository,
private ClassSlotReconciler $reconciler,
private AccessSettings $access = new AccessSettings(),
) {}
public function renderPage(): void {
@@ -137,15 +139,26 @@ class OfferingController {
}
/**
* Registered instructors offered in the assignment select, by display name.
* Instructors offered in the assignment select, by display name.
*
* Includes everyone holding the `us_instructor` role plus, when the site owner
* has left administrators acting as instructors (the default single-account
* setup), WordPress administrators — who teach through the dynamic capability
* grant rather than the role. Without them a solo studio owner running the
* business from an admin account would find no one to assign a class to.
*
* @return list<array{id: int, name: string}>
*/
private function instructorOptions(): array {
$roles = [ RoleManager::INSTRUCTOR ];
if ( $this->access->adminsAreInstructors() ) {
$roles[] = 'administrator';
}
$users = array_filter(
get_users(
[
'role' => RoleManager::INSTRUCTOR,
'role__in' => $roles,
'orderby' => 'display_name',
'order' => 'ASC',
]
@@ -184,6 +197,17 @@ class OfferingController {
return null;
}
$scheduleNote = $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) );
$etransferEmail = $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) );
// Reject over-long fixed-size fields rather than let the DB silently drop them.
if ( mb_strlen( $title ) > Offering::MAX_TITLE_LENGTH
|| ( null !== $scheduleNote && mb_strlen( $scheduleNote ) > Offering::MAX_SCHEDULE_NOTE_LENGTH )
|| ( null !== $etransferEmail && mb_strlen( $etransferEmail ) > Offering::MAX_ETRANSFER_EMAIL_LENGTH )
) {
return null;
}
$billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) );
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
$billingMode = Offering::BILLING_ONE_TIME;
@@ -213,6 +237,11 @@ class OfferingController {
// day (term_start), applied by Offering::effectiveEnrollmentDeadline().
$enrollmentDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['enrollment_deadline'] ?? '' ) ) ) );
// A blank (or invalid) withdrawal deadline leaves the column NULL, which
// keeps self-withdrawal open for the class's whole life
// (Offering::isWithdrawalOpen()). A set date closes it after that day.
$withdrawalDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['withdrawal_deadline'] ?? '' ) ) ) );
return new Offering(
instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
kind: $kind,
@@ -228,8 +257,9 @@ class OfferingController {
termEnd: $termEnd,
classTime: $classTime,
enrollmentDeadline: $enrollmentDeadline,
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'] ?? '' ) ) ) ),
withdrawalDeadline: $withdrawalDeadline,
scheduleNote: $scheduleNote,
etransferEmail: $etransferEmail,
cancellationCutoffHours: $cutoffHours,
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
isActive: isset( $_POST['is_active'] ),
+50 -5
View File
@@ -148,6 +148,14 @@ class OfferingEndpoint {
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
}
$scheduleNote = $this->nullableText( $request->get_param( 'schedule_note' ) );
$etransferEmail = $this->nullableEmail( $request->get_param( 'etransfer_email' ) );
$lengthError = $this->checkLengths( $title, $scheduleNote, $etransferEmail );
if ( $lengthError instanceof \WP_Error ) {
return $lengthError;
}
$offering = new Offering(
instructorId: get_current_user_id(),
kind: $kind,
@@ -162,8 +170,8 @@ class OfferingEndpoint {
termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ),
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
scheduleNote: $scheduleNote,
etransferEmail: $etransferEmail,
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
@@ -196,10 +204,19 @@ class OfferingEndpoint {
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
}
$title = $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title;
$scheduleNote = $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote;
$etransferEmail = $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail;
$lengthError = $this->checkLengths( $title, $scheduleNote, $etransferEmail );
if ( $lengthError instanceof \WP_Error ) {
return $lengthError;
}
$offering = new Offering(
instructorId: $existing->instructorId,
kind: $kind,
title: $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title,
title: $title,
price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price,
currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::string( $request->get_param( 'currency' ) ) ) : $existing->currency,
billingMode: $billingMode,
@@ -210,8 +227,8 @@ class OfferingEndpoint {
termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart,
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline,
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote,
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail,
scheduleNote: $scheduleNote,
etransferEmail: $etransferEmail,
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
accessMode: $request->has_param( 'access_mode' ) ? $this->accessMode( $request->get_param( 'access_mode' ), $existing->accessMode ) : $existing->accessMode,
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
@@ -266,6 +283,34 @@ class OfferingEndpoint {
return new \WP_Error( 'invalid_offering', $message, [ 'status' => 400 ] );
}
/**
* Reject any fixed-size field whose value exceeds its column length, so an
* over-long value is refused with a clear 400 rather than silently dropped
* by the database.
*/
private function checkLengths( string $title, ?string $scheduleNote, ?string $etransferEmail ): ?\WP_Error {
$fields = [
[ __( 'title', 'unsupervised-schedular' ), $title, Offering::MAX_TITLE_LENGTH ],
[ __( 'schedule note', 'unsupervised-schedular' ), $scheduleNote, Offering::MAX_SCHEDULE_NOTE_LENGTH ],
[ __( 'e-transfer email', 'unsupervised-schedular' ), $etransferEmail, Offering::MAX_ETRANSFER_EMAIL_LENGTH ],
];
foreach ( $fields as [ $name, $value, $max ] ) {
if ( null !== $value && mb_strlen( $value ) > $max ) {
return $this->invalid(
sprintf(
/* translators: 1: field name, 2: maximum character count. */
__( 'The %1$s must be %2$d characters or fewer.', 'unsupervised-schedular' ),
$name,
$max
)
);
}
}
return null;
}
private function price( mixed $value ): float {
return max( 0.0, Val::float( $value ) );
}
+4 -3
View File
@@ -15,12 +15,12 @@ class OfferingRepository {
* Column formats aligned to {@see columns()} (instructor_id, kind, title,
* description, duration_minutes, price, currency, billing_mode, allow_weekly,
* capacity, term_start, term_end, class_time, enrollment_deadline,
* schedule_note, etransfer_email, cancellation_cutoff_hours, access_mode,
* is_active).
* withdrawal_deadline, schedule_note, etransfer_email,
* cancellation_cutoff_hours, access_mode, is_active).
*
* @var list<string>
*/
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
public function insert( Offering $offering ): int {
$this->db->insert(
@@ -63,6 +63,7 @@ class OfferingRepository {
'term_end' => $offering->termEnd,
'class_time' => $offering->classTime,
'enrollment_deadline' => $offering->enrollmentDeadline,
'withdrawal_deadline' => $offering->withdrawalDeadline,
'schedule_note' => $offering->scheduleNote,
'etransfer_email' => $offering->etransferEmail,
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
+12
View File
@@ -10,6 +10,7 @@ use Unsupervised\Schedular\Auth\RegistrationLoginGate;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\StudentAdminGuard;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
@@ -55,6 +56,16 @@ class Plugin {
$bookings = new BookingRepository( $wpdb );
$offerings = new OfferingRepository( $wpdb );
$questions = new QuestionRepository( $wpdb );
// One-time repair for sites where dbDelta left us_questions.offering_id
// NOT NULL (it does not reliably relax NULL-ability), which breaks
// account-scope registration questions. Guarded by its own flag rather
// than the version gate, since affected sites may already be on the
// current version. The flag is only set once the ALTER succeeds.
if ( '1' !== get_option( 'us_questions_offering_nullable', '' ) && $questions->ensureOfferingNullable() ) {
update_option( 'us_questions_offering_nullable', '1' );
}
$answers = new AnswerRepository( $wpdb );
$policies = new PolicyRepository( $wpdb );
$policyVersions = new PolicyVersionRepository( $wpdb );
@@ -86,6 +97,7 @@ class Plugin {
( new UpdateChecker() )->register();
( new RoleManager() )->register();
( new RegistrationLoginGate() )->register();
( new StudentAdminGuard() )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register();
+6
View File
@@ -18,6 +18,12 @@ class Policy {
*/
public const VALID_SCOPES = [ self::SCOPE_SIGNUP, self::SCOPE_BOOKING, self::SCOPE_BOTH ];
/** Maximum length of the title, matching the `title` VARCHAR(191) column. */
public const MAX_TITLE_LENGTH = 191;
/** Maximum length of the slug, matching the `slug` VARCHAR(191) column. */
public const MAX_SLUG_LENGTH = 191;
public function __construct(
public readonly string $title,
public readonly string $slug,
+3 -1
View File
@@ -47,7 +47,9 @@ class PolicyController {
$scope = Policy::SCOPE_BOOKING;
}
if ( '' !== $title && '' !== $slug && null === $this->policies->findBySlug( $slug ) ) {
$withinLimits = mb_strlen( $title ) <= Policy::MAX_TITLE_LENGTH && mb_strlen( $slug ) <= Policy::MAX_SLUG_LENGTH;
if ( '' !== $title && '' !== $slug && $withinLimits && null === $this->policies->findBySlug( $slug ) ) {
$this->service->createPolicy( $title, $slug, $scope );
}
+18
View File
@@ -118,12 +118,30 @@ class PolicyEndpoint {
if ( '' === $title ) {
return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) );
}
if ( mb_strlen( $title ) > Policy::MAX_TITLE_LENGTH ) {
return $this->invalid(
sprintf(
/* translators: %d: maximum character count. */
__( 'The policy title must be %d characters or fewer.', 'unsupervised-schedular' ),
Policy::MAX_TITLE_LENGTH
)
);
}
$slugParam = sanitize_text_field( Val::string( $request->get_param( 'slug' ) ) );
$slug = sanitize_title( '' !== $slugParam ? $slugParam : $title );
if ( '' === $slug ) {
return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) );
}
if ( mb_strlen( $slug ) > Policy::MAX_SLUG_LENGTH ) {
return $this->invalid(
sprintf(
/* translators: %d: maximum character count. */
__( 'The policy slug must be %d characters or fewer.', 'unsupervised-schedular' ),
Policy::MAX_SLUG_LENGTH
)
);
}
if ( null !== $this->policies->findBySlug( $slug ) ) {
return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
+3
View File
@@ -12,6 +12,9 @@ class Question {
public const FIELD_SELECT = 'select';
public const FIELD_CHECKBOX = 'checkbox';
/** Maximum length of a question label, matching the `label` VARCHAR(255) column. */
public const MAX_LABEL_LENGTH = 255;
/** Question is scoped to a single offering, asked at booking/enrolment time. */
public const SCOPE_OFFERING = 'offering';
+1 -1
View File
@@ -85,7 +85,7 @@ class QuestionController {
$label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) );
$fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) );
if ( '' === $label || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
if ( '' === $label || mb_strlen( $label ) > Question::MAX_LABEL_LENGTH || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
return;
}
+24 -1
View File
@@ -79,6 +79,9 @@ class QuestionEndpoint {
if ( '' === $label ) {
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
}
if ( mb_strlen( $label ) > Question::MAX_LABEL_LENGTH ) {
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
}
$fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
@@ -118,9 +121,17 @@ class QuestionEndpoint {
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
}
$label = $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label;
if ( '' === $label ) {
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
}
if ( mb_strlen( $label ) > Question::MAX_LABEL_LENGTH ) {
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
}
$question = new Question(
offeringId: $existing->offeringId,
label: $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label,
label: $label,
fieldType: $fieldType,
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired,
@@ -217,4 +228,16 @@ class QuestionEndpoint {
private function invalid( string $message ): \WP_Error {
return new \WP_Error( 'invalid_question', $message, [ 'status' => 400 ] );
}
/**
* Build a uniform "too long" validation message for a named field.
*/
private function tooLongMessage( string $field, int $max ): string {
return sprintf(
/* translators: 1: field name, 2: maximum character count. */
__( 'The %1$s must be %2$d characters or fewer.', 'unsupervised-schedular' ),
$field,
$max
);
}
}
+22
View File
@@ -106,4 +106,26 @@ class QuestionRepository {
[ '%d' ]
);
}
/**
* Relax `offering_id` to allow NULL for account-scope questions (which are
* not tied to an offering).
*
* The account-questions feature (v1.1.0) made the column nullable in the
* schema, but dbDelta does not reliably change a column from NOT NULL to
* NULL, so sites created before then keep the old NOT NULL column and reject
* account-scope inserts with "Column 'offering_id' cannot be null". This
* MODIFY is idempotent — re-applying the nullable definition is a no-op.
*
* @return bool True when the statement ran (or was already applied), false
* if it could not be prepared or the query failed.
*/
public function ensureOfferingNullable(): bool {
$sql = $this->db->prepare(
'ALTER TABLE %i MODIFY offering_id BIGINT UNSIGNED NULL DEFAULT NULL',
$this->table
);
return null !== $sql && false !== $this->db->query( $sql );
}
}
+1
View File
@@ -65,6 +65,7 @@ class Schema {
term_end DATE DEFAULT NULL,
class_time TIME DEFAULT NULL,
enrollment_deadline DATE DEFAULT NULL,
withdrawal_deadline DATE DEFAULT NULL,
schedule_note VARCHAR(191) DEFAULT NULL,
etransfer_email VARCHAR(191) DEFAULT NULL,
cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL,
+3
View File
@@ -23,6 +23,9 @@ class ShortcodeRegistrar {
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' ] ) );
// Process registration submissions before output so the invite branch's
// auth cookie is actually sent (render() runs too late, during the_content).
add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] );
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
}
+37 -11
View File
@@ -45,7 +45,7 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<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 value="<?php echo esc_attr($editing->title ?? ''); ?>"></td>
<td><input type="text" name="title" id="title" class="regular-text" maxlength="<?php echo esc_attr((string) Offering::MAX_TITLE_LENGTH); ?>" required value="<?php echo esc_attr($editing->title ?? ''); ?>"></td>
</tr>
<tr>
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
@@ -92,36 +92,43 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
</select>
</td>
</tr>
<tr>
<tr class="us-private-only">
<th><?php esc_html_e('Weekly reservation', 'unsupervised-schedular'); ?></th>
<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>
<tr class="us-group-only">
<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" value="<?php echo esc_attr((string) ($editing->capacity ?? '')); ?>"> <span class="description"><?php esc_html_e('Group classes only', 'unsupervised-schedular'); ?></span></td>
</tr>
<tr>
<tr class="us-group-only">
<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>
<tr class="us-group-only">
<th><label for="class_time"><?php esc_html_e('Class time', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="time" name="class_time" id="class_time" value="<?php echo esc_attr(null === ($editing->classTime ?? null) ? '' : substr((string) $editing->classTime, 0, 5)); ?>">
<span class="description"><?php esc_html_e('Group classes only — the time each session starts. Combined with the duration to block the instructors availability.', 'unsupervised-schedular'); ?></span>
</td>
</tr>
<tr>
<tr class="us-group-only">
<th><label for="enrollment_deadline"><?php esc_html_e('Enrolment deadline', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="enrollment_deadline" id="enrollment_deadline" value="<?php echo esc_attr($editing->enrollmentDeadline ?? ''); ?>">
<p class="description"><?php esc_html_e('Group classes only — the last day students may enrol. Leave blank to default to the first day of the class.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr>
<tr class="us-group-only">
<th><label for="withdrawal_deadline"><?php esc_html_e('Withdrawal deadline', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="withdrawal_deadline" id="withdrawal_deadline" value="<?php echo esc_attr($editing->withdrawalDeadline ?? ''); ?>">
<p class="description"><?php esc_html_e('Group classes only — the last day a student may withdraw themselves. A withdrawal on or before this day frees the seat and voids any pending payment without crediting the student; after it, students can no longer withdraw online. Leave blank to allow withdrawal any time.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr class="us-group-only">
<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>
@@ -131,13 +138,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<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>
<tr class="us-group-only">
<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'); ?>" value="<?php echo esc_attr($editing->scheduleNote ?? ''); ?>"></td>
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" maxlength="<?php echo esc_attr((string) Offering::MAX_SCHEDULE_NOTE_LENGTH); ?>" 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'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" maxlength="<?php echo esc_attr((string) Offering::MAX_ETRANSFER_EMAIL_LENGTH); ?>" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
</tr>
<tr>
<th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th>
@@ -146,7 +153,7 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<p class="description"><?php esc_html_e('How many hours before a lesson a student may still cancel it. Leave blank to use the studio default; 0 lets students cancel any time.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr>
<tr class="us-group-only">
<th><?php esc_html_e('Invite only', 'unsupervised-schedular'); ?></th>
<td>
<label><input type="checkbox" name="invite_only" value="1" <?php echo $editing && $editing->isInviteOnly() ? 'checked' : ''; ?>> <?php esc_html_e('Hide from the booking list — students join by invitation only (group classes)', 'unsupervised-schedular'); ?></label>
@@ -164,6 +171,25 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<?php endif; ?>
</form>
<?php // Progressive enhancement: only show the fields relevant to the chosen
// kind. Without JS every row stays visible (the pre-toggle behaviour), so
// the form is fully usable either way. ?>
<script>
(function () {
var kind = document.getElementById('kind');
if (!kind) return;
var groupOnly = document.querySelectorAll('.us-group-only');
var privateOnly = document.querySelectorAll('.us-private-only');
function sync() {
var isGroup = kind.value === '<?php echo esc_js(Offering::KIND_GROUP_CLASS); ?>';
groupOnly.forEach(function (row) { row.style.display = isGroup ? '' : 'none'; });
privateOnly.forEach(function (row) { row.style.display = isGroup ? 'none' : ''; });
}
kind.addEventListener('change', sync);
sync();
}());
</script>
<h2><?php esc_html_e('Current Offerings', 'unsupervised-schedular'); ?></h2>
<?php if (empty($offerings)) : ?>
+2 -2
View File
@@ -24,12 +24,12 @@ if (! defined('ABSPATH')) {
<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" maxlength="<?php echo esc_attr((string) Policy::MAX_TITLE_LENGTH); ?>" required></td>
</tr>
<tr>
<th><label for="slug"><?php esc_html_e('Slug', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="text" name="slug" id="slug" class="regular-text" placeholder="<?php esc_attr_e('e.g. cancellation (defaults from title)', 'unsupervised-schedular'); ?>">
<input type="text" name="slug" id="slug" class="regular-text" maxlength="<?php echo esc_attr((string) Policy::MAX_SLUG_LENGTH); ?>" placeholder="<?php esc_attr_e('e.g. cancellation (defaults from title)', 'unsupervised-schedular'); ?>">
</td>
</tr>
<tr>
+1 -1
View File
@@ -53,7 +53,7 @@ if (! defined('ABSPATH')) {
<table class="form-table">
<tr>
<th><label for="label"><?php esc_html_e('Question', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="label" id="label" class="regular-text" required></td>
<td><input type="text" name="label" id="label" class="regular-text" maxlength="<?php echo esc_attr((string) Question::MAX_LABEL_LENGTH); ?>" required></td>
</tr>
<tr>
<th><label for="field_type"><?php esc_html_e('Field type', 'unsupervised-schedular'); ?></label></th>
+2 -1
View File
@@ -12,6 +12,7 @@ if (! defined('ABSPATH')) {
* @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 string $inviteOnlyMessage Text shown when registration is closed and no valid invite is present.
* @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'.
@@ -74,7 +75,7 @@ $renderQuestionField = static function (Question $question): void {
<?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>
<p><?php echo esc_html($inviteOnlyMessage); ?></p>
<?php else : ?>
<?php if ($error !== '') : ?>
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
+99 -2
View File
@@ -59,11 +59,14 @@ class RegistrationPageTest extends TestCase
'settings' => Mockery::mock(StudioSettings::class),
];
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
$this->ctx['page'] = new RegistrationPage(
$invites,
$policies,
Mockery::mock(PolicyVersionRepository::class),
Mockery::mock(AcceptanceRepository::class),
$this->ctx['versions'],
$this->ctx['acceptances'],
$this->ctx['settings'],
$this->ctx['mailer'],
$questions,
@@ -403,4 +406,98 @@ class RegistrationPageTest extends TestCase
self::assertSame('invite', $this->submit($invite, false));
}
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void
{
$_POST = [ 'us_register' => '1', 'password' => 'password123', 'display_name' => 'Ada' ];
$_REQUEST = [ 'us_invite' => 'raw-token' ];
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
Functions\when('get_permalink')->justReturn('http://home.test/register/');
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
// The cookie must be set here — during template_redirect, before output —
// which is the whole point of processing the submit outside render().
Functions\expect('wp_set_current_user')->once()->with(42);
Functions\expect('wp_set_auth_cookie')->once()->with(42);
$invite = new Invite(email: '[email protected]', token: 'hash', createdAt: '2024-01-01 00:00:00', id: 9);
$this->ctx['invites']->shouldReceive('findByToken')->once()->andReturn($invite);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
$page = Mockery::mock(
RegistrationPage::class,
[
$this->ctx['invites'],
$this->ctx['policies'],
$this->ctx['versions'],
$this->ctx['acceptances'],
$this->ctx['settings'],
$this->ctx['mailer'],
$this->ctx['questions'],
$this->ctx['answers'],
$this->ctx['access'],
]
)->makePartial()->shouldAllowMockingProtectedMethods();
$captured = '';
$page->shouldReceive('redirect')->once()->with(Mockery::on(static function (string $url) use (&$captured): bool {
$captured = $url;
return true;
}));
$page->maybeHandleSubmit();
self::assertStringContainsString('us_registered=invite', $captured);
}
public function testMaybeHandleSubmitStoresValidationErrorWithoutRedirecting(): void
{
// Too-short password: handleSubmit returns an error and no redirect fires.
$_POST = [ 'us_register' => '1', 'password' => 'short', 'display_name' => 'Ada' ];
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('check_admin_referer')->justReturn(true);
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true);
// A redirect would call exit; reaching the assertion proves none happened.
$this->ctx['page']->maybeHandleSubmit();
$error = (new \ReflectionProperty(RegistrationPage::class, 'submitError'))->getValue($this->ctx['page']);
self::assertNotSame('', $error);
}
public function testInviteSuccessRedirectShowsLoggedInWelcome(): void
{
// After the PRG redirect the student is logged in; the us_registered flag
// distinguishes a just-completed signup from an already-logged-in visitor.
$_GET = [ 'us_registered' => 'invite' ];
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
$html = $this->ctx['page']->render([]);
self::assertStringContainsString('us-success', $html);
self::assertStringContainsString('now logged in', $html);
}
public function testInviteOnlyMessageCanBeCustomised(): void
{
// Closed registration and no invite → the invitation-only gate shows.
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\when('wp_login_url')->justReturn('http://home.test/wp-login.php');
Functions\when('wp_nonce_field')->justReturn('');
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
$html = $this->ctx['page']->render([ 'inviteOnlyMessage' => 'Ask the front desk for a link.' ]);
self::assertStringContainsString('Ask the front desk for a link.', $html);
self::assertStringNotContainsString('by invitation only', $html);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\StudentAdminGuard;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class StudentAdminGuardTest extends TestCase
{
private StudentAdminGuard $guard;
protected function setUp(): void
{
parent::setUp();
$this->guard = new StudentAdminGuard();
Functions\when('wp_doing_ajax')->justReturn(false);
}
/**
* @param list<string> $held Capabilities the user is treated as holding.
*/
private function stubUser(bool $loggedIn, array $held = []): void
{
Functions\when('is_user_logged_in')->justReturn($loggedIn);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => in_array($cap, $held, true));
}
public function testBlocksStudentWithNoBackOfficeCapabilities(): void
{
// A student holds only front-end capabilities.
$this->stubUser(true, [RoleManager::CAP_BOOK_LESSON, RoleManager::CAP_VIEW_LESSONS]);
self::assertTrue($this->guard->shouldBlockAdminAccess());
}
public function testAllowsInstructor(): void
{
$this->stubUser(true, [RoleManager::CAP_MANAGE_AVAILABILITY]);
self::assertFalse($this->guard->shouldBlockAdminAccess());
}
public function testAllowsAdministrator(): void
{
$this->stubUser(true, ['manage_options']);
self::assertFalse($this->guard->shouldBlockAdminAccess());
}
public function testDoesNotBlockLoggedOutRequests(): void
{
$this->stubUser(false);
self::assertFalse($this->guard->shouldBlockAdminAccess());
}
public function testDoesNotBlockAjaxRequests(): void
{
Functions\when('wp_doing_ajax')->justReturn(true);
$this->stubUser(true, [RoleManager::CAP_BOOK_LESSON]);
self::assertFalse($this->guard->shouldBlockAdminAccess());
}
public function testHidesAdminBarForStudent(): void
{
$this->stubUser(true, [RoleManager::CAP_BOOK_LESSON]);
self::assertFalse($this->guard->hideAdminBar(true));
}
public function testKeepsAdminBarForInstructor(): void
{
$this->stubUser(true, [RoleManager::CAP_MANAGE_AVAILABILITY]);
self::assertTrue($this->guard->hideAdminBar(true));
}
public function testLeavesAdminBarUntouchedForLoggedOutVisitor(): void
{
$this->stubUser(false);
self::assertFalse($this->guard->hideAdminBar(false));
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ class BlockRegistrarTest extends TestCase
array_keys($registered['us-scheduler/student-login']['attributes'])
);
self::assertSame(
['loginPageId'],
['loginPageId', 'inviteOnlyMessage'],
array_keys($registered['us-scheduler/student-register']['attributes'])
);
self::assertSame(
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
@@ -181,4 +182,74 @@ class EnrollmentEndpointTest extends TestCase
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(201, $result->get_status());
}
public function testWithdrawCancelsEnrolmentAndVoidsPendingWithoutCrediting(): void
{
// No withdrawal deadline set, so withdrawal is open. current_time is 2026-07-24.
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 5, 3, Enrollment::STATUS_ACTIVE, 41, 3));
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0));
$this->enrollments->shouldReceive('updateStatus')->once()->with(3, Enrollment::STATUS_CANCELLED)->andReturn(true);
$this->payments->shouldReceive('voidPending')->once()->with(41);
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(200, $result->get_status());
self::assertSame(Enrollment::STATUS_CANCELLED, $result->get_data()['status']);
}
public function testWithdrawRejectedAfterDeadline(): void
{
// current_time is stubbed to 2026-07-24, past the 2026-07-10 deadline.
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-01', withdrawalDeadline: '2026-07-10', id: 8);
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 5, 3, Enrollment::STATUS_ACTIVE, 41, 3));
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
$this->enrollments->shouldReceive('updateStatus')->never();
$this->payments->shouldReceive('voidPending')->never();
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('withdrawal_closed', $result->get_error_code());
self::assertSame(403, $result->error_data['withdrawal_closed']['status']);
}
public function testWithdrawRejectsAnotherStudentsEnrolment(): void
{
// Enrolment belongs to student 9, but the caller is student 5.
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 9, 3, Enrollment::STATUS_ACTIVE, 41, 3));
$this->enrollments->shouldReceive('updateStatus')->never();
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('forbidden', $result->get_error_code());
self::assertSame(403, $result->error_data['forbidden']['status']);
}
public function testWithdrawReturnsNotFoundForUnknownEnrolment(): void
{
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(null);
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('not_found', $result->get_error_code());
self::assertSame(404, $result->error_data['not_found']['status']);
}
public function testWithdrawIsIdempotentForAlreadyCancelledEnrolment(): void
{
// Already cancelled: no status change, no deadline check, no payment void.
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 5, 3, Enrollment::STATUS_CANCELLED, null, 3));
$this->offerings->shouldReceive('findById')->never();
$this->enrollments->shouldReceive('updateStatus')->never();
$this->payments->shouldReceive('voidPending')->never();
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(200, $result->get_status());
self::assertSame(Enrollment::STATUS_CANCELLED, $result->get_data()['status']);
}
}
@@ -32,6 +32,8 @@ class OfferingControllerTest extends TestCase
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3);
Functions\when('get_users')->justReturn([]);
// Default single-account setup: admins act as instructors.
Functions\when('get_option')->justReturn('1');
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('admin_url')->justReturn('admin.php?page=us-offerings');
Functions\when('add_query_arg')->alias(
@@ -139,6 +141,43 @@ class OfferingControllerTest extends TestCase
$this->render();
}
public function testAddGroupClassStoresWithdrawalDeadline(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Ballet Beginners',
'kind' => Offering::KIND_GROUP_CLASS,
'term_start' => '2026-09-08',
'withdrawal_deadline' => '2026-08-31',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => '2026-08-31' === $o->withdrawalDeadline
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
$this->render();
}
public function testBlankWithdrawalDeadlineLeavesItNull(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Choir',
'kind' => Offering::KIND_GROUP_CLASS,
'term_start' => '2026-09-08',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => null === $o->withdrawalDeadline
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
$this->render();
}
public function testGarbageClassTimeIsRejected(): void
{
$_POST = [
@@ -413,6 +452,51 @@ class OfferingControllerTest extends TestCase
self::assertStringNotContainsString('Edit Offering', $html);
}
public function testInstructorPickerIncludesAdministratorsWhenTheyActAsInstructors(): void
{
// The reported bug: a solo studio owner runs the business from a WordPress
// administrator account and teaches through the dynamic capability grant,
// so they never hold the us_instructor role. The picker must still list
// them, otherwise there is no one to assign a class to.
Functions\when('get_option')->justReturn('1');
$admin = Mockery::mock(\WP_User::class);
$admin->ID = 3;
$admin->display_name = 'Studio Owner';
$queriedRoles = [];
Functions\when('get_users')->alias(static function (array $args) use (&$queriedRoles, $admin): array {
$queriedRoles = $args['role__in'];
return [$admin];
});
$this->repository->shouldReceive('findAll')->andReturn([]);
$html = $this->render();
self::assertContains('us_instructor', $queriedRoles);
self::assertContains('administrator', $queriedRoles);
self::assertStringContainsString('Studio Owner', $html);
self::assertStringContainsString('<option value="3"', $html);
}
public function testInstructorPickerExcludesAdministratorsWhenGrantDisabled(): void
{
// With the "admins are instructors" toggle off, an admin is not a teacher,
// so only the explicit us_instructor role is queried.
Functions\when('get_option')->justReturn('0');
$queriedRoles = null;
Functions\when('get_users')->alias(static function (array $args) use (&$queriedRoles): array {
$queriedRoles = $args['role__in'];
return [];
});
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->render();
self::assertSame(['us_instructor'], $queriedRoles);
}
private function render(): string
{
ob_start();
@@ -118,4 +118,37 @@ class OfferingEndpointTest extends TestCase
self::assertArrayNotHasKey('etransfer_email', $data[0]);
}
public function testCreateRejectsTitleLongerThanColumnLimit(): void
{
Functions\when('sanitize_text_field')->returnArg();
Functions\when('sanitize_email')->returnArg();
$this->repository->shouldNotReceive('insert');
$request = new \WP_REST_Request([
'kind' => Offering::KIND_GROUP_CLASS,
'title' => str_repeat('a', Offering::MAX_TITLE_LENGTH + 1),
]);
$response = $this->endpoint->create($request);
self::assertInstanceOf(\WP_Error::class, $response);
self::assertSame(400, $response->error_data['invalid_offering']['status']);
}
public function testCreateRejectsScheduleNoteLongerThanColumnLimit(): void
{
Functions\when('sanitize_text_field')->returnArg();
Functions\when('sanitize_email')->returnArg();
$this->repository->shouldNotReceive('insert');
$request = new \WP_REST_Request([
'kind' => Offering::KIND_GROUP_CLASS,
'title' => 'Choir',
'schedule_note' => str_repeat('a', Offering::MAX_SCHEDULE_NOTE_LENGTH + 1),
]);
$response = $this->endpoint->create($request);
self::assertInstanceOf(\WP_Error::class, $response);
self::assertSame(400, $response->error_data['invalid_offering']['status']);
}
}
@@ -192,6 +192,29 @@ class OfferingRepositoryTest extends TestCase
self::assertSame(1, $this->repo->insert($offering));
}
public function testInsertPersistsWithdrawalDeadline(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_offerings',
Mockery::on(static fn (array $data): bool => $data['withdrawal_deadline'] === '2026-08-31'),
Mockery::type('array')
);
$this->db->insert_id = 1;
$offering = new Offering(
instructorId: 5,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
withdrawalDeadline: '2026-08-31',
);
self::assertSame(1, $this->repo->insert($offering));
}
public function testDeleteCallsWpdbDelete(): void
{
$this->db->shouldReceive('delete')
+25
View File
@@ -331,4 +331,29 @@ class OfferingTest extends TestCase
self::assertSame('2026-08-31', $offering->toArray()['enrollment_deadline']);
}
public function testIsWithdrawalOpenOnAndBeforeTheDeadlineDay(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', withdrawalDeadline: '2026-08-31');
self::assertTrue($offering->isWithdrawalOpen('2026-08-30'));
self::assertTrue($offering->isWithdrawalOpen('2026-08-31'));
self::assertFalse($offering->isWithdrawalOpen('2026-09-01'));
}
public function testIsWithdrawalOpenAlwaysTrueWithoutADeadline(): void
{
// Unlike the enrolment deadline, a withdrawal deadline has no default:
// an unset deadline leaves self-withdrawal open indefinitely.
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08');
self::assertTrue($offering->isWithdrawalOpen('2099-01-01'));
}
public function testToArrayIncludesWithdrawalDeadline(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', withdrawalDeadline: '2026-08-31', id: 10);
self::assertSame('2026-08-31', $offering->toArray()['withdrawal_deadline']);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Policy;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Policy\Policy;
use Unsupervised\Schedular\Policy\PolicyEndpoint;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyService;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class PolicyEndpointTest extends TestCase
{
private PolicyRepository&Mockery\MockInterface $policies;
private PolicyService&Mockery\MockInterface $service;
private PolicyEndpoint $endpoint;
protected function setUp(): void
{
parent::setUp();
Functions\when('sanitize_text_field')->returnArg();
Functions\when('sanitize_title')->returnArg();
$this->policies = Mockery::mock(PolicyRepository::class);
$this->service = Mockery::mock(PolicyService::class);
$this->endpoint = new PolicyEndpoint(
$this->policies,
Mockery::mock(PolicyVersionRepository::class),
$this->service,
);
}
public function testCreateRejectsTitleLongerThanColumnLimit(): void
{
$this->service->shouldNotReceive('createPolicy');
$request = new \WP_REST_Request([
'title' => str_repeat('a', Policy::MAX_TITLE_LENGTH + 1),
]);
$response = $this->endpoint->create($request);
self::assertInstanceOf(\WP_Error::class, $response);
self::assertSame(400, $response->error_data['invalid_policy']['status']);
}
public function testCreateRejectsSlugLongerThanColumnLimit(): void
{
$this->service->shouldNotReceive('createPolicy');
$request = new \WP_REST_Request([
'title' => 'Cancellation',
'slug' => str_repeat('a', Policy::MAX_SLUG_LENGTH + 1),
]);
$response = $this->endpoint->create($request);
self::assertInstanceOf(\WP_Error::class, $response);
self::assertSame(400, $response->error_data['invalid_policy']['status']);
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Registration;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionEndpoint;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class QuestionEndpointTest extends TestCase
{
private QuestionRepository&Mockery\MockInterface $questions;
private OfferingRepository&Mockery\MockInterface $offerings;
private QuestionEndpoint $endpoint;
protected function setUp(): void
{
parent::setUp();
Functions\when('get_current_user_id')->justReturn(5);
Functions\when('current_user_can')->justReturn(false);
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
Functions\when('sanitize_text_field')->returnArg();
$this->questions = Mockery::mock(QuestionRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->endpoint = new QuestionEndpoint($this->questions, $this->offerings);
// The caller (instructor 5) owns offering 9, so the ownership gate passes
// and validation is reached.
$this->offerings->shouldReceive('findById')->with(9)->andReturn(
new Offering(instructorId: 5, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 9)
);
}
public function testCreateRejectsLabelLongerThanColumnLimit(): void
{
// The insert must never be attempted for an over-long label — the bug was
// that it reached the DB, silently failed, and returned success anyway.
$this->questions->shouldNotReceive('insert');
$request = new \WP_REST_Request([
'offering_id' => 9,
'label' => str_repeat('a', Question::MAX_LABEL_LENGTH + 1),
]);
$response = $this->endpoint->create($request);
self::assertInstanceOf(\WP_Error::class, $response);
self::assertSame(400, $response->error_data['invalid_question']['status']);
}
public function testCreateAcceptsLabelAtColumnLimit(): void
{
$this->questions->shouldReceive('insert')->once()->andReturn(42);
$request = new \WP_REST_Request([
'offering_id' => 9,
'label' => str_repeat('a', Question::MAX_LABEL_LENGTH),
]);
$response = $this->endpoint->create($request);
self::assertInstanceOf(\WP_REST_Response::class, $response);
self::assertSame(201, $response->get_status());
}
}
@@ -212,4 +212,28 @@ class QuestionRepositoryTest extends TestCase
self::assertTrue($this->repo->delete(4));
}
public function testEnsureOfferingNullableRunsAlterAndReportsSuccess(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/ALTER TABLE %i MODIFY offering_id .*NULL/'), 'wp_us_questions')
->andReturn('ALTER TABLE `wp_us_questions` MODIFY offering_id BIGINT UNSIGNED NULL DEFAULT NULL');
$this->db->shouldReceive('query')
->once()
->with('ALTER TABLE `wp_us_questions` MODIFY offering_id BIGINT UNSIGNED NULL DEFAULT NULL')
->andReturn(0);
// A successful DDL query returns 0 rows affected (not false).
self::assertTrue($this->repo->ensureOfferingNullable());
}
public function testEnsureOfferingNullableReportsFailureWhenQueryFails(): void
{
$this->db->shouldReceive('prepare')->once()->andReturn('ALTER ...');
$this->db->shouldReceive('query')->once()->andReturn(false);
self::assertFalse($this->repo->ensureOfferingNullable());
}
}
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.2.0
* Version: 1.2.1
* Requires at least: 6.2
* Requires PHP: 8.1
* Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit;
}
define('USC_VERSION', '1.2.0');
define('USC_VERSION', '1.2.1');
define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));