Compare commits
4
Commits
f552c3952a
..
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
771942be8b | ||
|
|
242150569b
|
||
|
|
fae1fd08ba | ||
|
|
2c4b481077
|
@@ -17,12 +17,19 @@ each change under the current top section as you work.
|
||||
- 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
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -109,6 +109,10 @@ class BlockRegistrar {
|
||||
'type' => 'number',
|
||||
'default' => 0,
|
||||
],
|
||||
'inviteOnlyMessage' => [
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
],
|
||||
],
|
||||
],
|
||||
'us-scheduler/group-classes' => [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -69,6 +69,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 +115,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 +228,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 +264,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,
|
||||
|
||||
@@ -213,6 +213,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,6 +233,7 @@ class OfferingController {
|
||||
termEnd: $termEnd,
|
||||
classTime: $classTime,
|
||||
enrollmentDeadline: $enrollmentDeadline,
|
||||
withdrawalDeadline: $withdrawalDeadline,
|
||||
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'] ?? '' ) ) ) ),
|
||||
cancellationCutoffHours: $cutoffHours,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,6 +55,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 );
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' ] );
|
||||
}
|
||||
|
||||
@@ -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 instructor’s 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,7 +138,7 @@ 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>
|
||||
</tr>
|
||||
@@ -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)) : ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,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 = [
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user