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

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

Closes #121

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

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

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

Closes #117

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

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

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

Closes #115

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

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

Closes #114

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 20:22:04 -03:00
thatguygriff 3aa65bad06 Merge pull request 'Bump version to 1.2.1' (#110) from release/bump-1.2.1 into main
CI / Tests (PHP 8.1) (push) Successful in 41s
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m52s
CI / Coding Standards (push) Successful in 3m4s
CI / Tests (PHP 8.3) (push) Successful in 2m41s
CI / Build Plugin Zip (push) Successful in 2m49s
Reviewed-on: #110
2026-07-24 19:34:30 +00:00
Release Bot f3ba09b195 Bump version to 1.2.1 and open changelog section 2026-07-24 19:34:11 +00:00
38 changed files with 1220 additions and 102 deletions
+14
View File
@@ -11,6 +11,20 @@ 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 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. each change under the current top section as you work.
## [1.2.2]
### Added
- The booking calendar now has a **lesson type** filter above it, so a student browsing open times can narrow them to the types they actually want. Because not every open time can be booked as every private-lesson type — some times are tied to a specific type, others only take types of a matching length — the filter shows just the times bookable as the ticked types, and re-anchors the week view on the earliest one so it never opens on an empty week. Picking one of those times narrows the **Lesson type** picker on the registration form to the same list, and when only one type is left it is chosen automatically with its questions loaded. Tick nothing (or use **Show all types**) to see every open time as before. The filter is hidden when the studio only offers one private-lesson type.
- The **Group Classes** block can now be pinned to a single class, under **Classes shown → Class** in the block sidebar (shortcode: `[us_group_classes offering="…"]`). Pick a class and the block shows only that one, so it can be embedded on a page that describes the class. In this mode the class's own description is left out to avoid repeating the page copy — the card shows the schedule, instructor, price, enrolment deadline and the enrol/withdraw controls. Leaving it on **All classes** keeps the full browsable catalog with descriptions.
- The **Student Registration** block can now send students onward to a page of your choosing once they finish registering. Its **After email confirmation** panel is now **After registration**: the page you pick there is where the link shown to a newly registered student points — the "Sign in to your account" link after they confirm their email, and a "Continue to your account" link for an invited student, who is signed in immediately. A new **Redirect automatically** option takes them straight there instead of showing the link. Registration errors are never skipped — a failed sign-up and an expired confirmation link still show their message on the page, as does the "check your email to confirm your address" step. The redirect needs a page to be chosen; with none set, students see the link (or, for invited students, just the confirmation) as before.
## [1.2.1]
### Fixed
- Registration questions, offering titles/notes, and policy names longer than their storage limit are no longer silently discarded. Previously typing a fixed-size field past its maximum length reported success but saved nothing — the database quietly rejected the over-long value. These fields now cap the input in the form, and the API rejects an over-long value with a clear error.
- Students can no longer reach the WordPress dashboard. A student who navigates to `wp-admin` is redirected to the site front end and the admin toolbar is hidden for them, so they only ever see the studio's booking pages. Anyone who runs the studio — administrators, studio admins, and instructors — keeps full `wp-admin` access.
- The instructor picker on the **Add/Edit Offering** form no longer comes up empty for a solo studio owner. When the person running the studio teaches from a WordPress administrator account (the default single-account setup), they now appear in the instructor dropdown and can be assigned to a class.
## [1.2.0] ## [1.2.0]
### Added ### Added
+30
View File
@@ -117,6 +117,36 @@
color: #8a6d1a; color: #8a6d1a;
} }
.us-type-filter {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
margin-bottom: 12px;
padding: 8px 12px;
border: 1px solid #eee;
border-radius: 4px;
}
.us-type-filter-heading {
font-weight: 600;
}
.us-type-filter-choice {
display: inline-flex;
align-items: center;
gap: 6px;
}
.us-type-filter-clear {
margin-left: auto;
padding: 4px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.us-view-toggle { .us-view-toggle {
display: flex; display: flex;
gap: 8px; gap: 8px;
+10 -3
View File
@@ -151,18 +151,25 @@
shortcode: 'us_student_register', shortcode: 'us_student_register',
attributes: { attributes: {
loginPageId: { type: 'number', default: 0 }, loginPageId: { type: 'number', default: 0 },
autoRedirect: { type: 'boolean', default: false },
inviteOnlyMessage: { type: 'string', default: '' }, inviteOnlyMessage: { type: 'string', default: '' },
}, },
inspector: (attributes, setAttributes) => [ inspector: (attributes, setAttributes) => [
el( el(
PanelBody, PanelBody,
{ title: __('After email confirmation', 'unsupervised-schedular'), key: 'confirmation' }, { title: __('After registration', 'unsupervised-schedular'), key: 'confirmation' },
el(PageSelect, { el(PageSelect, {
label: __('Sign-in page', 'unsupervised-schedular'), label: __('Sign-in page', 'unsupervised-schedular'),
help: __('Where the sign-in link shown after a student confirms their email address sends them.', 'unsupervised-schedular'), help: __('Where students are sent once registration finishes — after they confirm their email address, or straight away for an invited student.', 'unsupervised-schedular'),
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'), defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
value: attributes.loginPageId, value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }), onChange: (loginPageId) => setAttributes({ loginPageId }),
}),
el(ToggleControl, {
label: __('Redirect automatically', 'unsupervised-schedular'),
help: __('Send students straight to that page instead of showing the link. Requires a page to be chosen; errors and the "check your email" step are never skipped.', 'unsupervised-schedular'),
checked: !!attributes.autoRedirect,
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
}) })
), ),
el( el(
@@ -192,7 +199,7 @@
{ title: __('Classes shown', 'unsupervised-schedular') }, { title: __('Classes shown', 'unsupervised-schedular') },
el(GroupClassSelect, { el(GroupClassSelect, {
label: __('Class', 'unsupervised-schedular'), label: __('Class', 'unsupervised-schedular'),
help: __('Show only one group class, for embedding on a page dedicated to it.', 'unsupervised-schedular'), help: __('Show only one group class, for embedding on a page dedicated to it. That classs description is left out — the card shows just the schedule, price and enrolment controls.', 'unsupervised-schedular'),
value: attributes.offeringId, value: attributes.offeringId,
onChange: (offeringId) => setAttributes({ offeringId }), onChange: (offeringId) => setAttributes({ offeringId }),
}) })
+147 -41
View File
@@ -81,6 +81,43 @@
let view = 'week'; let view = 'week';
let weekStart = null; let weekStart = null;
// Every active private-lesson type the student may book, across instructors.
let catalog = [];
// Lesson types the student has filtered the calendar down to; empty means
// "no filter" — every open slot is shown.
const selectedTypeIds = new Set();
// Whether an offering can be booked into a slot — the client-side mirror of
// the rule `POST /bookings` enforces: a slot tied to an offering takes that
// offering only, and a generic slot takes any of its instructor's types
// whose length fits.
function offeringFitsSlot(offering, slot) {
if (Number(offering.instructor_id) !== Number(slot.instructor_id)) return false;
const tiedId = Number(slot.offering_id) || 0;
if (tiedId) return Number(offering.id) === tiedId;
return !offering.duration_minutes
|| Number(offering.duration_minutes) === Number(slot.duration_minutes);
}
const filterActive = () => selectedTypeIds.size > 0;
const typeSelected = (offering) => !filterActive() || selectedTypeIds.has(Number(offering.id));
// The lesson types this slot could be booked as, honouring the filter.
function slotChoices(slot) {
return catalog.filter((o) => offeringFitsSlot(o, slot) && typeSelected(o));
}
// With a filter set, a slot is only shown when one of the chosen lesson
// types can actually be booked into it.
function visibleSlots() {
if (!filterActive()) return allSlots;
return allSlots.filter((slot) => slotChoices(slot).length > 0);
}
const pad = (n) => String(n).padStart(2, '0'); const pad = (n) => String(n).padStart(2, '0');
const toKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; const toKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
@@ -106,9 +143,40 @@
</div>`; </div>`;
} }
// "Piano Lesson (30 min)" — the instructor's name is only worth the space
// when the catalog spans more than one of them.
function filterLabel(offering) {
const duration = offering.duration_minutes ? ` (${offering.duration_minutes} min)` : '';
const instructors = new Set(catalog.map((o) => Number(o.instructor_id)));
const who = instructors.size > 1 && offering.instructor_name
? `${offering.instructor_name}`
: '';
return `${offering.title}${duration}${who}`;
}
// Lesson-type filter. Pointless with a single bookable type, so it is only
// rendered once there is a choice to make.
function filterHtml() {
if (catalog.length < 2) return '';
const choices = catalog.map((o) => `
<label class="us-type-filter-choice">
<input type="checkbox" class="us-type-filter-option" value="${o.id}" ${selectedTypeIds.has(Number(o.id)) ? 'checked' : ''}>
${escHtml(filterLabel(o))}
</label>
`).join('');
return `
<div class="us-type-filter" role="group" aria-label="Filter by lesson type">
<span class="us-type-filter-heading">Lesson type</span>
${choices}
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''}
</div>`;
}
// Agenda-style calendar: available slots grouped by day. // Agenda-style calendar: available slots grouped by day.
function listHtml() { function listHtml(slots) {
return groupByDay(allSlots).map(([key, daySlots]) => ` return groupByDay(slots).map(([key, daySlots]) => `
<div class="us-day"> <div class="us-day">
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3> <h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
${daySlots.map((slot) => ` ${daySlots.map((slot) => `
@@ -122,8 +190,8 @@
} }
// Weekly calendar: seven day columns with a bookable button per slot. // Weekly calendar: seven day columns with a bookable button per slot.
function weekHtml() { function weekHtml(slots) {
const byDay = new Map(groupByDay(allSlots)); const byDay = new Map(groupByDay(slots));
const days = [...Array(7).keys()].map((i) => addDays(weekStart, i)); const days = [...Array(7).keys()].map((i) => addDays(weekStart, i));
const columns = days.map((key) => { const columns = days.map((key) => {
@@ -151,19 +219,51 @@
} }
function render() { function render() {
if (!allSlots.length) { const slots = visibleSlots();
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
if (!slots.length) {
slotList.innerHTML = filterHtml() + (allSlots.length
? '<p>No open times match the selected lesson types.</p>'
: '<p>No available lesson slots at this time.</p>');
wireFilterEvents();
return; return;
} }
// Anchor the week view to the week of the earliest open slot (the API // Anchor the week view to the week of the earliest matching slot (the
// returns slots ordered by start), so the first look is never empty. // API returns slots ordered by start), so the first look is never empty.
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(allSlots[0].start_dt)); if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(slots[0].start_dt));
slotList.innerHTML = toggleHtml() + (view === 'week' ? weekHtml() : listHtml()); slotList.innerHTML = filterHtml() + toggleHtml() + (view === 'week' ? weekHtml(slots) : listHtml(slots));
wireFilterEvents();
wireCalendarEvents(); wireCalendarEvents();
} }
function wireFilterEvents() {
slotList.querySelectorAll('.us-type-filter-option').forEach((input) => {
input.addEventListener('change', () => {
const id = Number(input.value);
if (input.checked) {
selectedTypeIds.add(id);
} else {
selectedTypeIds.delete(id);
}
// The nearest matching time may be weeks away, so re-anchor the
// week view instead of leaving the student on an empty week.
weekStart = null;
render();
});
});
const clear = document.getElementById('us-type-filter-clear');
if (clear) {
clear.addEventListener('click', () => {
selectedTypeIds.clear();
weekStart = null;
render();
});
}
}
function wireCalendarEvents() { function wireCalendarEvents() {
document.getElementById('us-view-list').addEventListener('click', () => { document.getElementById('us-view-list').addEventListener('click', () => {
view = 'list'; view = 'list';
@@ -211,20 +311,6 @@
</div>`; </div>`;
} }
// Active private-lesson offerings per instructor, so revisiting the
// registration form does not refetch the same catalog.
const offeringCache = new Map();
function instructorOfferings(instructorId) {
if (offeringCache.has(instructorId)) {
return Promise.resolve(offeringCache.get(instructorId));
}
return apiFetch(`offerings?instructor_id=${instructorId}&kind=private_lesson`).then((list) => {
offeringCache.set(instructorId, list);
return list;
});
}
// "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)" // "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)"
function offeringLabel(o) { function offeringLabel(o) {
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : ''; const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
@@ -237,13 +323,8 @@
function openRegistration(slot) { function openRegistration(slot) {
clearError(); clearError();
Promise.all([ apiFetch('policies?scope=booking')
instructorOfferings(Number(slot.instructor_id)), .then((policies) => renderRegistration(slot, policies))
apiFetch('policies?scope=booking'),
])
.then(([offerings, policies]) => {
renderRegistration(slot, offerings, policies);
})
.catch((err) => showError(err.message)); .catch((err) => showError(err.message));
} }
@@ -258,6 +339,19 @@
<select id="us-offering" disabled><option>${escHtml(label)}</option></select></label> <select id="us-offering" disabled><option>${escHtml(label)}</option></select></label>
</p>`; </p>`;
} }
// Only one type is left to book this slot as — usually because the
// filter narrowed it down — so it is chosen for the student.
if (choices.length === 1) {
return `
<p class="us-offering">
<label>Lesson type<br>
<select id="us-offering" required>
<option value="${choices[0].id}" selected>${escHtml(offeringLabel(choices[0]))}</option>
</select></label>
</p>`;
}
return ` return `
<p class="us-offering"> <p class="us-offering">
<label>Lesson type<br> <label>Lesson type<br>
@@ -268,14 +362,13 @@
</p>`; </p>`;
} }
function renderRegistration(slot, offerings, policies) { function renderRegistration(slot, policies) {
const tiedId = Number(slot.offering_id) || 0; const tiedId = Number(slot.offering_id) || 0;
const tied = tiedId ? offerings.find((o) => Number(o.id) === tiedId) : null; const tied = tiedId ? catalog.find((o) => Number(o.id) === tiedId) : null;
// Generic slots offer every lesson type that fits the slot's length. // Generic slots offer every lesson type that fits the slot — narrowed to
const choices = tiedId // the filtered types when the student has set a filter.
? [] const choices = tiedId ? [] : slotChoices(slot);
: offerings.filter((o) => !o.duration_minutes || Number(o.duration_minutes) === Number(slot.duration_minutes));
if (!tiedId && !choices.length) { if (!tiedId && !choices.length) {
// The server rejects offering-less bookings, so without a matching // The server rejects offering-less bookings, so without a matching
@@ -309,8 +402,10 @@
</div>`; </div>`;
// The intake questions belong to the selected offering, so they follow // The intake questions belong to the selected offering, so they follow
// the picker instead of being fixed at render time. // the picker instead of being fixed at render time. A tied slot — or a
let selectedId = tiedId; // lone remaining type — is already decided, so its questions load
// straight away.
let selectedId = tiedId || (choices.length === 1 ? Number(choices[0].id) : 0);
let questions = []; let questions = [];
const questionsBox = document.getElementById('us-questions'); const questionsBox = document.getElementById('us-questions');
@@ -467,13 +562,24 @@
confirm.style.display = 'block'; confirm.style.display = 'block';
} }
// The private-lesson catalog drives both the filter and the registration
// form's lesson-type picker, and it does not change while the student
// browses — so it is fetched once and kept.
function loadCatalog() {
if (catalog.length) return Promise.resolve(catalog);
return apiFetch('offerings?kind=private_lesson').then((list) => {
catalog = list;
return catalog;
});
}
function loadSlots() { function loadSlots() {
clearError(); clearError();
slotList.style.display = 'block'; slotList.style.display = 'block';
confirm.style.display = 'none'; confirm.style.display = 'none';
loadMyLessons(); loadMyLessons();
apiFetch('availability') Promise.all([apiFetch('availability'), loadCatalog()])
.then((slots) => { .then(([slots]) => {
allSlots = slots; allSlots = slots;
render(); render();
}) })
+4 -1
View File
@@ -12,6 +12,9 @@
// When the shortcode/block pins a single offering, only that class is // When the shortcode/block pins a single offering, only that class is
// shown, so the page can be embedded alongside a full class description. // shown, so the page can be embedded alongside a full class description.
// The class's own description is then omitted from the card — the page it
// sits on already describes the class — leaving the schedule, price and
// enrolment controls.
const singleOfferingId = Number(app.dataset.offering || 0); const singleOfferingId = Number(app.dataset.offering || 0);
function apiFetch(path, options = {}) { function apiFetch(path, options = {}) {
@@ -148,7 +151,7 @@
${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''} ${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''}
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''} ${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''} ${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${o.description ? `<p>${escHtml(o.description)}</p>` : ''} ${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p> <p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
${!enrolledMap.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>` ? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
+7
View File
@@ -128,6 +128,13 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
- `[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`. - `[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()`). - The invitation-only message is customisable: block attribute `inviteOnlyMessage` (set under the block's **Invitation-only notice** panel) / shortcode attribute `invite_only_message`. Blank falls back to the default wording (`RegistrationPage::inviteOnlyMessage()`).
## Where Students Go Next
The block's **After registration** panel picks the page a student continues to once
registration finishes, and whether they get there by hand or automatically.
- **Sign-in page** (`loginPageId` / `login_page_id`) — the target of the "Sign in to your account" link shown after email confirmation (`?us_confirmed=1|ready`, falling back to the WordPress login screen) and of the "Continue to your account" link an invited student sees on the spot (`?us_registered=invite`; no link at all with no page chosen, since an already-signed-in student has no use for the login screen).
- **Redirect automatically** (`autoRedirect`, block only) — sends the student to that page instead of showing the link, via `BlockRegistrar::maybeAutoRedirect()` on `template_redirect`. It fires only on those two finished states (`RegistrationPage::isRegistrationComplete()`), so the "check your email" step, a validation error, and an `expired` confirmation link are always shown rather than redirected past. With no page chosen nothing happens — there is deliberately no login-screen fallback for the redirect. See `editor-blocks.md`.
## Token Redirect ## Token Redirect
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`) A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
sends any front-end request carrying a `us_invite` token to the configured sends any front-end request carrying a `us_invite` token to the configured
+2
View File
@@ -49,6 +49,8 @@ The front-end booking shortcode renders open slots from `GET /availability`
either as an agenda-style list grouped by day or as a **weekly calendar** with either as an agenda-style list grouped by day or as a **weekly calendar** with
previous/next-week navigation (toggle rendered by `assets/js/booking.js`; the previous/next-week navigation (toggle rendered by `assets/js/booking.js`; the
site's `start_of_week` option is passed through the `usScheduler` JS config). site's `start_of_week` option is passed through the `usScheduler` JS config).
Both views can be narrowed to the slots bookable as chosen private-lesson types
with the lesson-type filter — see `lesson-booking.md`.
## REST API ## REST API
| Method | Endpoint | Permission | | Method | Endpoint | Permission |
+15 -3
View File
@@ -29,8 +29,9 @@ Four blocks have sidebar (inspector) options:
| `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. | | `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. |
| `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. | | `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. |
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. | | `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page the "Sign in to your account" link points to after a student confirms their email. `0` = the WordPress login screen. Shortcode equivalent: `[us_student_register login_page_id="…"]`. | | `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. `0` = browse all classes. Shortcode equivalent: `[us_group_classes offering="…"]`. | | `us-scheduler/student-register` | `autoRedirect` (boolean) | `false` | Send students straight to that page instead of showing the link. Does nothing until a page is chosen — there is no login-screen fallback here. |
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. The class description is then omitted — only the schedule, instructor, price and enrolment controls are shown, so the surrounding page's own copy is not repeated. `0` = browse all classes, descriptions included. Shortcode equivalent: `[us_group_classes offering="…"]`. |
The page selects list all published pages; if a chosen page is later deleted, The page selects list all published pages; if a chosen page is later deleted,
the blocks fall back to their defaults. The group-classes block's class the blocks fall back to their defaults. The group-classes block's class
@@ -48,6 +49,15 @@ queried singular post's content for the block (including inside nested
blocks), and redirects when the block opts in. A block whose target is its blocks), and redirects when the block opts in. A block whose target is its
own page is ignored to avoid a redirect loop. own page is ignored to avoid a redirect loop.
The registration block's auto-redirect additionally only fires on a
**finished** registration — `RegistrationPage::isRegistrationComplete()`: an
invited student who is now logged in (`?us_registered=invite`), or a
self-signup back from the emailed confirmation link (`?us_confirmed=ready|1`).
The intermediate "check your email" step and every failure (a validation
error, `?us_confirmed=expired`) stay on the page so the student reads the
message. That check runs before the content is parsed, so an ordinary page
view does not pay for the extra block scan.
## How it works ## How it works
- **`BlockRegistrar`** (`src/BlockRegistrar.php`) hooks `init` and registers - **`BlockRegistrar`** (`src/BlockRegistrar.php`) hooks `init` and registers
@@ -78,7 +88,9 @@ placeholder content:
- **Booking** — `#us-booking-app` with sample `.us-day` / `.us-slot` rows and - **Booking** — `#us-booking-app` with sample `.us-day` / `.us-slot` rows and
disabled Book buttons. disabled Book buttons.
- **Group classes** — `#us-group-app` with a sample `.us-class` card and a - **Group classes** — `#us-group-app` with a sample `.us-class` card and a
disabled Enrol button. disabled Enrol button. When `offeringId` pins a single class the preview
drops the sample description, matching what the live page renders in that
mode.
- **Login** — the real `templates/frontend/login-page.php` template (it has - **Login** — the real `templates/frontend/login-page.php` template (it has
no request-state dependencies). no request-state dependencies).
- **Registration** — a disabled sample of the `.us-register-form` fields. - **Registration** — a disabled sample of the `.us-register-form` fields.
+1 -1
View File
@@ -176,7 +176,7 @@ class becomes enrollable for them — they choose whether to enrol.
- Model: `Unsupervised\Schedular\GroupClass\Enrollment` - Model: `Unsupervised\Schedular\GroupClass\Enrollment`
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController``renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`) - Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController``renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`)
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint` - REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute) - Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute). In single-class mode `assets/js/group-classes.js` leaves the class description out of the card, since the page it is embedded on already describes the class; the schedule, instructor, schedule note, price and enrolment controls are still shown.
- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`) - Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
> **Payment:** a priced enrolment creates a payment via `Payment\PaymentService` > **Payment:** a priced enrolment creates a payment via `Payment\PaymentService`
+24 -2
View File
@@ -20,8 +20,8 @@ Students register for a private lesson by choosing an offering, picking a time (
| `created_at` | DATETIME | Insertion time | | `created_at` | DATETIME | Insertion time |
## Registration Flow ## Registration Flow
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form). 1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form). A **lesson-type filter** above the calendar narrows the open times to those bookable as the chosen types (see **Lesson-Type Filter**).
2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot. Every booking requires an offering — a generic slot with no fitting offering cannot be booked online. 2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot, narrowed to the filtered types. When exactly one type remains it is pre-selected (its intake questions load immediately). Every booking requires an offering — a generic slot with no fitting offering cannot be booked online.
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term. 3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
4. Student answers the offering's questions (`GET /offerings/{id}/questions`). 4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
5. Student accepts the current published policy versions (`GET /policies`) — required to continue. 5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
@@ -31,6 +31,28 @@ Students register for a private lesson by choosing an offering, picking a time (
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`. 9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request. 10. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
## Lesson-Type Filter
Not every open slot can be booked as every private-lesson type — a slot tied to
an offering takes that offering only, and a generic slot only takes types whose
length fits. The booking calendar therefore carries a lesson-type filter above
the view toggle: a checkbox per active private-lesson type (from
`GET /offerings?kind=private_lesson`, fetched once per page load), showing the
instructor's name alongside the title when the catalog spans more than one
instructor. The filter is hidden when there is only one bookable type.
Ticking one or more types narrows the calendar to the slots bookable as one of
them; no ticks means no filter. Picking a filtered slot narrows the registration
form's **Lesson type** picker the same way, and when exactly one type remains it
is pre-selected and its intake questions load immediately. Changing the filter
re-anchors the week view on the earliest matching slot, so the student never
lands on an empty week. **Show all types** clears the filter.
Bookability is decided client-side by `offeringFitsSlot()` in
`assets/js/booking.js` — the mirror of the rule `POST /bookings` enforces (same
instructor, the tied offering when there is one, otherwise a matching
`duration_minutes`). The filter is a browsing aid only: the server re-checks
every booking regardless.
## Cancellation ## Cancellation
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent). Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
Cancelling marks the lesson `cancelled`, frees the availability slot for Cancelling marks the lesson `cancelled`, frees the availability slot for
+1 -1
View File
@@ -107,7 +107,7 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
## REST API ## REST API
| Method | Endpoint | Permission | | Method | Endpoint | Permission |
|----------|---------------------------------------------|----------------------------------| |----------|---------------------------------------------|----------------------------------|
| `GET` | `/wp-json/us-scheduler/v1/offerings` | Public (active offerings only) | | `GET` | `/wp-json/us-scheduler/v1/offerings` | `book_lesson` or `manage_offerings` (active offerings only) |
| `POST` | `/wp-json/us-scheduler/v1/offerings` | `manage_offerings` | | `POST` | `/wp-json/us-scheduler/v1/offerings` | `manage_offerings` |
| `PATCH` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner | | `PATCH` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner |
| `DELETE` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner | | `DELETE` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner |
+66 -8
View File
@@ -66,9 +66,20 @@ class RegistrationPage {
if ( is_user_logged_in() ) { if ( is_user_logged_in() ) {
if ( self::RESULT_INVITE === $registered ) { if ( self::RESULT_INVITE === $registered ) {
// An invited student is done the moment they land here logged in,
// so this is where their "continue" link belongs. The sign-in-page
// fallback is deliberately not used: pointing someone who is
// already signed in at the login screen helps nobody.
$continue = $this->continueUrl( $this->successPageId( $atts ) );
$link = null === $continue
? ''
: '<p><a href="' . esc_url( $continue ) . '">'
. esc_html__( 'Continue to your account', 'unsupervised-schedular' )
. '</a></p>';
return '<div class="us-register-form"><p class="us-success">' 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' ) . esc_html__( 'Your account has been created and you are now logged in.', 'unsupervised-schedular' )
. '</p></div>'; . '</p>' . $link . '</div>';
} }
return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>'; return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>';
@@ -98,7 +109,7 @@ class RegistrationPage {
$confirmResult = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) ); $confirmResult = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
// Where the post-confirmation prompt sends students to sign in. // Where the post-confirmation prompt sends students to sign in.
$loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) ); $loginUrl = $this->loginUrl( $this->successPageId( $atts ) );
$policyForms = $this->signupPolicies(); $policyForms = $this->signupPolicies();
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true ); $accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
@@ -319,21 +330,68 @@ class RegistrationPage {
return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM; return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM;
} }
/**
* The page id chosen for the post-registration destination, from either the
* block (`loginPageId`) or shortcode (`login_page_id`) attribute.
*
* @param array<int|string, mixed> $atts
*/
private function successPageId( array $atts ): int {
return Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
}
/** /**
* URL the post-confirmation sign-in link points to: the chosen login page * URL the post-confirmation sign-in link points to: the chosen login page
* when one is configured (and still exists), otherwise the WordPress login * when one is configured (and still exists), otherwise the WordPress login
* screen. * screen.
*/ */
private function loginUrl( int $loginPageId ): string { private function loginUrl( int $loginPageId ): string {
if ( $loginPageId > 0 ) { return $this->continueUrl( $loginPageId ) ?? wp_login_url();
$url = get_permalink( $loginPageId ); }
if ( is_string( $url ) ) { /**
return $url; * The chosen post-registration page's URL, or null when none is configured
} * (or it has since been deleted). Unlike {@see loginUrl()} this has no
* WordPress-login-screen fallback, so callers that need a page the student
* was actually sent to — the invited-student link and the block's
* auto-redirect — can tell "not configured" from "configured".
*/
public function continueUrl( int $pageId ): ?string {
if ( $pageId <= 0 ) {
return null;
} }
return wp_login_url(); $url = get_permalink( $pageId );
return is_string( $url ) ? $url : null;
}
/**
* Whether this request is a *finished* registration — the states the
* block's auto-redirect may act on:
*
* - an invited student who just signed up and is now logged in, and
* - a self-signup returning from the emailed confirmation link, whether
* their account is ready (`ready`) or awaiting studio approval (`1`).
*
* Deliberately excluded: the intermediate "check your email" step (the
* student would never see the instruction) and every failure — a validation
* error or an expired confirmation link (`expired`) — so the message always
* gets shown. The `us_confirmed` values are set by
* {@see EmailConfirmationHandler::maybeConfirm()}.
*/
public function isRegistrationComplete(): bool {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
if ( self::RESULT_INVITE === $registered ) {
return is_user_logged_in();
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag set by EmailConfirmationHandler's redirect.
$confirmed = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
return in_array( $confirmed, [ '1', 'ready' ], true );
} }
/** /**
+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;
}
}
+20 -4
View File
@@ -58,13 +58,29 @@ class BlockPreview {
); );
} }
public static function groupClasses(): string { /**
* Sample group-class card.
*
* @param bool $singleClass Whether the block is pinned to one class, in
* which case the live page omits the class
* description and the preview does too.
*/
public static function groupClasses( bool $singleClass = false ): string {
$note = $singleClass
? __( 'Editor preview — the published page shows the chosen class with its live schedule and enrolment status.', 'unsupervised-schedular' )
: __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' );
$description = $singleClass
? ''
: '<p>' . esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ) . '</p>';
return sprintf( return sprintf(
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p>%s</p><p>%s</p><p>25.00 CAD</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>', '<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p class="us-class-when">%s</p>%s<p>25.00 CAD</p><p class="us-enrol-deadline">%s</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
self::note( __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' ) ), self::note( $note ),
esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ), esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ),
esc_html__( 'Saturdays 10:00 AM11:00 AM', 'unsupervised-schedular' ), esc_html__( 'Saturdays 10:00 AM11:00 AM', 'unsupervised-schedular' ),
esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ), $description,
esc_html__( 'Enrol by Sep 6, 2026', 'unsupervised-schedular' ),
esc_html__( 'Enrol', 'unsupervised-schedular' ) esc_html__( 'Enrol', 'unsupervised-schedular' )
); );
} }
+54 -5
View File
@@ -109,6 +109,7 @@ class BlockRegistrar {
'type' => 'number', 'type' => 'number',
'default' => 0, 'default' => 0,
], ],
'autoRedirect' => $redirectToggle,
'inviteOnlyMessage' => [ 'inviteOnlyMessage' => [
'type' => 'string', 'type' => 'string',
'default' => '', 'default' => '',
@@ -160,16 +161,21 @@ class BlockRegistrar {
* @param array<string, mixed> $attributes Block attributes. * @param array<string, mixed> $attributes Block attributes.
*/ */
public function renderGroupClasses( array $attributes = [] ): string { public function renderGroupClasses( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::groupClasses() : $this->groupClassPage->render( $attributes ); if ( ! $this->isEditorPreview() ) {
return $this->groupClassPage->render( $attributes );
}
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
} }
/** /**
* Server-side auto-redirect for blocks that opt in via their autoRedirect * Server-side auto-redirect for blocks that opt in via their autoRedirect
* attribute: logged-out visitors on a page containing the booking block * attribute: logged-out visitors on a page containing the booking block
* are sent to its login page, and logged-in visitors on a page containing * are sent to its login page, logged-in visitors on a page containing the
* the student-login block are sent to its booking page. Hooked on * student-login block are sent to its booking page, and a student who has
* `template_redirect` because block rendering happens after output has * just finished registering is sent to the register block's chosen page.
* started, too late to send a Location header. * Hooked on `template_redirect` because block rendering happens after
* output has started, too late to send a Location header.
*/ */
public function maybeAutoRedirect(): void { public function maybeAutoRedirect(): void {
if ( is_admin() || ! is_singular() ) { if ( is_admin() || ! is_singular() ) {
@@ -181,6 +187,10 @@ class BlockRegistrar {
return; return;
} }
if ( $this->maybeRedirectAfterRegistration( $post ) ) {
return;
}
if ( is_user_logged_in() ) { if ( is_user_logged_in() ) {
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-login' ); $attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-login' );
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) { if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
@@ -213,6 +223,45 @@ class BlockRegistrar {
$this->redirect( $this->bookingPage->loginUrl( $loginPageId ) ); $this->redirect( $this->bookingPage->loginUrl( $loginPageId ) );
} }
/**
* Sends a student whose registration has just completed to the register
* block's chosen page, when the block opts in. Only the finished states
* qualify (see {@see RegistrationPage::isRegistrationComplete()}): a
* failure or the "check your email" step stays put so its message is read.
* Unlike the other blocks there is no login-screen fallback — with no page
* chosen there is nowhere to send them, so the link is shown instead.
*
* Returns whether the redirect was issued (it only ever returns in tests;
* {@see redirect()} exits in production).
*/
private function maybeRedirectAfterRegistration( \WP_Post $post ): bool {
// Checked before parsing the content because it is a couple of query
// args, whereas every front-end request would otherwise pay for a
// third block scan.
if ( ! $this->registrationPage->isRegistrationComplete() ) {
return false;
}
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-register' );
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
return false;
}
$pageId = Val::int( $attrs['loginPageId'] ?? 0 );
if ( $pageId === $post->ID ) {
return false; // Redirecting the page to itself would loop.
}
$url = $this->registrationPage->continueUrl( $pageId );
if ( null === $url ) {
return false;
}
$this->redirect( $url );
return true;
}
/** /**
* Attributes of the first occurrence of the named block in the content, * Attributes of the first occurrence of the named block in the content,
* searching inner blocks so blocks nested inside groups or columns are * searching inner blocks so blocks nested inside groups or columns are
+9
View File
@@ -54,6 +54,15 @@ class Offering {
*/ */
public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ]; 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 function __construct(
public readonly int $instructorId, public readonly int $instructorId,
public readonly string $kind, public readonly string $kind,
+30 -6
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering; namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val; use Unsupervised\Schedular\Val;
@@ -11,6 +12,7 @@ class OfferingController {
public function __construct( public function __construct(
private OfferingRepository $repository, private OfferingRepository $repository,
private ClassSlotReconciler $reconciler, private ClassSlotReconciler $reconciler,
private AccessSettings $access = new AccessSettings(),
) {} ) {}
public function renderPage(): void { public function renderPage(): void {
@@ -137,17 +139,28 @@ 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}> * @return list<array{id: int, name: string}>
*/ */
private function instructorOptions(): array { private function instructorOptions(): array {
$roles = [ RoleManager::INSTRUCTOR ];
if ( $this->access->adminsAreInstructors() ) {
$roles[] = 'administrator';
}
$users = array_filter( $users = array_filter(
get_users( get_users(
[ [
'role' => RoleManager::INSTRUCTOR, 'role__in' => $roles,
'orderby' => 'display_name', 'orderby' => 'display_name',
'order' => 'ASC', 'order' => 'ASC',
] ]
), ),
static fn( mixed $u ): bool => $u instanceof \WP_User static fn( mixed $u ): bool => $u instanceof \WP_User
@@ -184,6 +197,17 @@ class OfferingController {
return null; 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 ) ) ); $billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) );
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) { if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
$billingMode = Offering::BILLING_ONE_TIME; $billingMode = Offering::BILLING_ONE_TIME;
@@ -234,8 +258,8 @@ class OfferingController {
classTime: $classTime, classTime: $classTime,
enrollmentDeadline: $enrollmentDeadline, enrollmentDeadline: $enrollmentDeadline,
withdrawalDeadline: $withdrawalDeadline, withdrawalDeadline: $withdrawalDeadline,
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ), scheduleNote: $scheduleNote,
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ), etransferEmail: $etransferEmail,
cancellationCutoffHours: $cutoffHours, cancellationCutoffHours: $cutoffHours,
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC, accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
isActive: isset( $_POST['is_active'] ), isActive: isset( $_POST['is_active'] ),
+60 -11
View File
@@ -28,7 +28,7 @@ class OfferingEndpoint {
[ [
'methods' => \WP_REST_Server::READABLE, 'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'index' ], 'callback' => [ $this, 'index' ],
'permission_callback' => [ $this, 'canBook' ], 'permission_callback' => [ $this, 'canRead' ],
'args' => [ 'args' => [
'instructor_id' => [ 'instructor_id' => [
'type' => 'integer', 'type' => 'integer',
@@ -148,6 +148,14 @@ class OfferingEndpoint {
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) ); 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( $offering = new Offering(
instructorId: get_current_user_id(), instructorId: get_current_user_id(),
kind: $kind, kind: $kind,
@@ -162,8 +170,8 @@ class OfferingEndpoint {
termStart: $this->nullableText( $request->get_param( 'term_start' ) ), termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ), termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ), enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ),
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ), scheduleNote: $scheduleNote,
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ), etransferEmail: $etransferEmail,
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ), cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ), 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' ), 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' ) ); 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( $offering = new Offering(
instructorId: $existing->instructorId, instructorId: $existing->instructorId,
kind: $kind, 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, 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, currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::string( $request->get_param( 'currency' ) ) ) : $existing->currency,
billingMode: $billingMode, billingMode: $billingMode,
@@ -210,8 +227,8 @@ class OfferingEndpoint {
termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart, 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, 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, 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, scheduleNote: $scheduleNote,
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail, etransferEmail: $etransferEmail,
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours, 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, 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, isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
@@ -245,12 +262,16 @@ class OfferingEndpoint {
} }
/** /**
* Reading the offerings catalogue is only needed by the logged-in student * Reading the offerings catalogue has no anonymous consumer, so it stays
* booking flow, so it requires the same capability as booking — there is no * behind a login. Students reach it through the booking flow, and studio
* anonymous consumer. * admins and instructors reach it from the block editor's group-class
* pickers — an administrator holds `manage_offerings` but not
* `book_lesson`, so both capabilities open the listing.
*/ */
public function canBook(): bool { public function canRead(): bool {
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON ); return is_user_logged_in()
&& ( current_user_can( RoleManager::CAP_BOOK_LESSON )
|| current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) );
} }
/** /**
@@ -266,6 +287,34 @@ class OfferingEndpoint {
return new \WP_Error( 'invalid_offering', $message, [ 'status' => 400 ] ); 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 { private function price( mixed $value ): float {
return max( 0.0, Val::float( $value ) ); return max( 0.0, Val::float( $value ) );
} }
+2
View File
@@ -10,6 +10,7 @@ use Unsupervised\Schedular\Auth\RegistrationLoginGate;
use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\StudentAdminGuard;
use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\BookingRepository;
@@ -96,6 +97,7 @@ class Plugin {
( new UpdateChecker() )->register(); ( new UpdateChecker() )->register();
( new RoleManager() )->register(); ( new RoleManager() )->register();
( new RegistrationLoginGate() )->register(); ( new RegistrationLoginGate() )->register();
( new StudentAdminGuard() )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->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 AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register(); ( new 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 ]; 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 function __construct(
public readonly string $title, public readonly string $title,
public readonly string $slug, public readonly string $slug,
+3 -1
View File
@@ -47,7 +47,9 @@ class PolicyController {
$scope = Policy::SCOPE_BOOKING; $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 ); $this->service->createPolicy( $title, $slug, $scope );
} }
+18
View File
@@ -118,12 +118,30 @@ class PolicyEndpoint {
if ( '' === $title ) { if ( '' === $title ) {
return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) ); 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' ) ) ); $slugParam = sanitize_text_field( Val::string( $request->get_param( 'slug' ) ) );
$slug = sanitize_title( '' !== $slugParam ? $slugParam : $title ); $slug = sanitize_title( '' !== $slugParam ? $slugParam : $title );
if ( '' === $slug ) { if ( '' === $slug ) {
return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) ); 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 ) ) { if ( null !== $this->policies->findBySlug( $slug ) ) {
return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); 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_SELECT = 'select';
public const FIELD_CHECKBOX = 'checkbox'; 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. */ /** Question is scoped to a single offering, asked at booking/enrolment time. */
public const SCOPE_OFFERING = 'offering'; 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'] ?? '' ) ) ); $label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) );
$fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) ); $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; return;
} }
+24 -1
View File
@@ -79,6 +79,9 @@ class QuestionEndpoint {
if ( '' === $label ) { if ( '' === $label ) {
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) ); 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 ); $fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) { if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
@@ -118,9 +121,17 @@ class QuestionEndpoint {
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) ); 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( $question = new Question(
offeringId: $existing->offeringId, offeringId: $existing->offeringId,
label: $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label, label: $label,
fieldType: $fieldType, fieldType: $fieldType,
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options, 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, 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 { private function invalid( string $message ): \WP_Error {
return new \WP_Error( 'invalid_question', $message, [ 'status' => 400 ] ); 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
);
}
} }
+3 -3
View File
@@ -45,7 +45,7 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<table class="form-table"> <table class="form-table">
<tr> <tr>
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th> <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>
<tr> <tr>
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th> <th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
@@ -140,11 +140,11 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
</tr> </tr>
<tr class="us-group-only"> <tr class="us-group-only">
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th> <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>
<tr> <tr>
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th> <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>
<tr> <tr>
<th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th> <th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th>
+2 -2
View File
@@ -24,12 +24,12 @@ if (! defined('ABSPATH')) {
<table class="form-table"> <table class="form-table">
<tr> <tr>
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th> <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>
<tr> <tr>
<th><label for="slug"><?php esc_html_e('Slug', 'unsupervised-schedular'); ?></label></th> <th><label for="slug"><?php esc_html_e('Slug', 'unsupervised-schedular'); ?></label></th>
<td> <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> </td>
</tr> </tr>
<tr> <tr>
+1 -1
View File
@@ -53,7 +53,7 @@ if (! defined('ABSPATH')) {
<table class="form-table"> <table class="form-table">
<tr> <tr>
<th><label for="label"><?php esc_html_e('Question', 'unsupervised-schedular'); ?></label></th> <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>
<tr> <tr>
<th><label for="field_type"><?php esc_html_e('Field type', 'unsupervised-schedular'); ?></label></th> <th><label for="field_type"><?php esc_html_e('Field type', 'unsupervised-schedular'); ?></label></th>
+2 -4
View File
@@ -14,7 +14,7 @@ if (! defined('ABSPATH')) {
* @var bool $canRegister * @var bool $canRegister
* @var string $inviteOnlyMessage Text shown when registration is closed and no valid invite is present. * @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 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 $successType '' | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm). The invited-student success is rendered by RegistrationPage::render() itself, which returns before this template for logged-in visitors.
* @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'. * @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'.
* @var string $loginUrl Where the post-confirmation sign-in link points. * @var string $loginUrl Where the post-confirmation sign-in link points.
* @var string $error * @var string $error
@@ -57,9 +57,7 @@ $renderQuestionField = static function (Question $question): void {
}; };
?> ?>
<div class="us-register-form"> <div class="us-register-form">
<?php if ($successType === 'invite') : ?> <?php if ($successType === 'confirm') : ?>
<p class="us-success"><?php esc_html_e('Your account has been created and you are now logged in.', 'unsupervised-schedular'); ?></p>
<?php elseif ($successType === 'confirm') : ?>
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, a studio admin will review and approve your account.', 'unsupervised-schedular'); ?></p> <p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, a studio admin will review and approve your account.', 'unsupervised-schedular'); ?></p>
<?php elseif ($successType === 'confirm_group') : ?> <?php elseif ($successType === 'confirm_group') : ?>
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, your account is ready to use.', 'unsupervised-schedular'); ?></p> <p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, your account is ready to use.', 'unsupervised-schedular'); ?></p>
+48
View File
@@ -484,6 +484,54 @@ class RegistrationPageTest extends TestCase
self::assertStringContainsString('us-success', $html); self::assertStringContainsString('us-success', $html);
self::assertStringContainsString('now logged in', $html); self::assertStringContainsString('now logged in', $html);
// No page chosen: the sign-in-screen fallback is useless to someone who
// is already signed in, so no link is offered at all.
self::assertStringNotContainsString('<a href', $html);
}
public function testInviteSuccessLinksToTheChosenPage(): void
{
$_GET = [ 'us_registered' => 'invite' ];
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\expect('get_permalink')->once()->with(4)->andReturn('http://home.test/welcome/');
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
self::assertStringContainsString('now logged in', $html);
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
}
public function testContinueUrlIsNullWithoutAResolvablePage(): void
{
Functions\when('get_permalink')->justReturn(false);
self::assertNull($this->ctx['page']->continueUrl(0));
self::assertNull($this->ctx['page']->continueUrl(4));
}
public function testIsRegistrationCompleteOnlyForFinishedStates(): void
{
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
// [ query args, logged in, finished ]
$cases = [
'invited student, now logged in' => [['us_registered' => 'invite'], true, true],
'invited student, not logged in' => [['us_registered' => 'invite'], false, false],
'email confirmed, ready' => [['us_confirmed' => 'ready'], false, true],
'email confirmed, pending review' => [['us_confirmed' => '1'], false, true],
'confirmation link expired' => [['us_confirmed' => 'expired'], false, false],
'awaiting email confirmation' => [['us_registered' => 'confirm'], false, false],
'group signup awaiting confirm' => [['us_registered' => 'confirm_group'], false, false],
'plain page view' => [[], false, false],
];
foreach ($cases as $label => [$get, $loggedIn, $expected]) {
$_GET = $get;
Functions\when('is_user_logged_in')->justReturn($loggedIn);
self::assertSame($expected, $this->ctx['page']->isRegistrationComplete(), $label);
}
} }
public function testInviteOnlyMessageCanBeCustomised(): void public function testInviteOnlyMessageCanBeCustomised(): void
+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));
}
}
+11
View File
@@ -29,6 +29,17 @@ class BlockPreviewTest extends TestCase
self::assertStringContainsString('class="us-class"', $html); self::assertStringContainsString('class="us-class"', $html);
self::assertStringContainsString('class="us-enrol-btn" disabled', $html); self::assertStringContainsString('class="us-enrol-btn" disabled', $html);
self::assertStringContainsString('us-editor-note', $html); self::assertStringContainsString('us-editor-note', $html);
self::assertStringContainsString('A sample class shown so the page can be styled.', $html);
}
public function testSingleClassGroupPreviewDropsTheDescriptionButKeepsScheduleAndEnrolment(): void
{
$html = BlockPreview::groupClasses(true);
self::assertStringNotContainsString('A sample class shown so the page can be styled.', $html);
self::assertStringContainsString('class="us-class-when"', $html);
self::assertStringContainsString('class="us-enrol-deadline"', $html);
self::assertStringContainsString('class="us-enrol-btn" disabled', $html);
} }
public function testLoginPreviewIncludesTheRealLoginTemplate(): void public function testLoginPreviewIncludesTheRealLoginTemplate(): void
+159 -1
View File
@@ -52,6 +52,12 @@ class BlockRegistrarTest extends TestCase
$this->registrationPage = Mockery::mock(RegistrationPage::class); $this->registrationPage = Mockery::mock(RegistrationPage::class);
$this->groupClassPage = Mockery::mock(GroupClassPage::class); $this->groupClassPage = Mockery::mock(GroupClassPage::class);
// Most requests are not a just-finished registration; the tests that
// exercise that path override this.
$this->registrationPage->shouldReceive('isRegistrationComplete')
->andReturn(false)
->byDefault();
$this->registrar = new TestableBlockRegistrar( $this->registrar = new TestableBlockRegistrar(
$this->bookingPage, $this->bookingPage,
$this->loginPage, $this->loginPage,
@@ -126,7 +132,7 @@ class BlockRegistrarTest extends TestCase
array_keys($registered['us-scheduler/student-login']['attributes']) array_keys($registered['us-scheduler/student-login']['attributes'])
); );
self::assertSame( self::assertSame(
['loginPageId', 'inviteOnlyMessage'], ['loginPageId', 'autoRedirect', 'inviteOnlyMessage'],
array_keys($registered['us-scheduler/student-register']['attributes']) array_keys($registered['us-scheduler/student-register']['attributes'])
); );
self::assertSame( self::assertSame(
@@ -179,6 +185,19 @@ class BlockRegistrarTest extends TestCase
self::assertStringContainsString('us-group-app', $this->registrar->renderGroupClasses()); self::assertStringContainsString('us-group-app', $this->registrar->renderGroupClasses());
} }
public function testEditorPreviewOfAPinnedGroupClassOmitsTheDescription(): void
{
$this->registrar->preview = true;
$this->groupClassPage->shouldNotReceive('render');
$all = $this->registrar->renderGroupClasses();
$single = $this->registrar->renderGroupClasses(['offeringId' => 12]);
self::assertStringContainsString('A sample class shown so the page can be styled.', $all);
self::assertStringNotContainsString('A sample class shown so the page can be styled.', $single);
self::assertStringContainsString('us-enrol-deadline', $single);
}
public function testIsEditorPreviewIsFalseOutsideRestRequests(): void public function testIsEditorPreviewIsFalseOutsideRestRequests(): void
{ {
// REST_REQUEST is undefined in the test process, so the real // REST_REQUEST is undefined in the test process, so the real
@@ -384,6 +403,145 @@ class BlockRegistrarTest extends TestCase
self::assertSame([], $this->registrar->redirects); self::assertSame([], $this->registrar->redirects);
} }
public function testAutoRedirectSendsAFinishedRegistrationToTheChosenPage(): void
{
$this->stubSingularRequest(
30,
[
[
'blockName' => 'us-scheduler/student-register',
'attrs' => ['autoRedirect' => true, 'loginPageId' => 4],
'innerBlocks' => [],
],
],
false
);
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
$this->registrationPage->shouldReceive('continueUrl')
->once()->with(4)->andReturn('https://example.com/welcome/');
$this->registrar->maybeAutoRedirect();
self::assertSame(['https://example.com/welcome/'], $this->registrar->redirects);
}
public function testAutoRedirectSendsAJustLoggedInInvitedStudentToTheChosenPage(): void
{
// The invited-student branch completes logged in, so the logged-in
// student-login branch must not get first claim on the request.
$this->stubSingularRequest(
30,
[
[
'blockName' => 'us-scheduler/student-register',
'attrs' => ['autoRedirect' => true, 'loginPageId' => 4],
'innerBlocks' => [],
],
],
true
);
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
$this->registrationPage->shouldReceive('continueUrl')
->once()->with(4)->andReturn('https://example.com/welcome/');
$this->registrar->maybeAutoRedirect();
self::assertSame(['https://example.com/welcome/'], $this->registrar->redirects);
}
public function testNoRedirectWhenTheRegistrationIsNotFinished(): void
{
// e.g. the "check your email" step, or a validation error — the
// message has to be read, so the block never redirects past it.
$this->stubSingularRequest(
30,
[
[
'blockName' => 'us-scheduler/student-register',
'attrs' => ['autoRedirect' => true, 'loginPageId' => 4],
'innerBlocks' => [],
],
],
false
);
$this->registrationPage->shouldReceive('continueUrl')->never();
$this->registrar->maybeAutoRedirect();
self::assertSame([], $this->registrar->redirects);
}
public function testNoRedirectWhenTheRegisterBlockDoesNotOptIn(): void
{
$this->stubSingularRequest(
30,
[
[
'blockName' => 'us-scheduler/student-register',
'attrs' => ['loginPageId' => 4],
'innerBlocks' => [],
],
],
false
);
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
$this->registrationPage->shouldReceive('continueUrl')->never();
$this->registrar->maybeAutoRedirect();
self::assertSame([], $this->registrar->redirects);
}
public function testNoRedirectWhenTheRegisterBlockHasNoPageChosen(): void
{
$this->stubSingularRequest(
30,
[
[
'blockName' => 'us-scheduler/student-register',
'attrs' => ['autoRedirect' => true],
'innerBlocks' => [],
],
],
false
);
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
// No page chosen: there is no login-screen fallback to redirect to,
// so the student keeps the on-page confirmation instead.
$this->registrationPage->shouldReceive('continueUrl')->once()->with(0)->andReturnNull();
$this->registrar->maybeAutoRedirect();
self::assertSame([], $this->registrar->redirects);
}
public function testNoRedirectWhenTheRegisterBlockPointsAtItsOwnPage(): void
{
$this->stubSingularRequest(
4,
[
[
'blockName' => 'us-scheduler/student-register',
'attrs' => ['autoRedirect' => true, 'loginPageId' => 4],
'innerBlocks' => [],
],
],
false
);
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
$this->registrationPage->shouldReceive('continueUrl')->never();
$this->registrar->maybeAutoRedirect();
self::assertSame([], $this->registrar->redirects);
}
public function testNoRedirectOutsideSingularFrontEndRequests(): void public function testNoRedirectOutsideSingularFrontEndRequests(): void
{ {
Functions\when('is_admin')->justReturn(false); Functions\when('is_admin')->justReturn(false);
@@ -32,6 +32,8 @@ class OfferingControllerTest extends TestCase
Functions\when('current_user_can')->justReturn(true); Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3); Functions\when('get_current_user_id')->justReturn(3);
Functions\when('get_users')->justReturn([]); 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('check_admin_referer')->justReturn(true);
Functions\when('admin_url')->justReturn('admin.php?page=us-offerings'); Functions\when('admin_url')->justReturn('admin.php?page=us-offerings');
Functions\when('add_query_arg')->alias( Functions\when('add_query_arg')->alias(
@@ -450,6 +452,51 @@ class OfferingControllerTest extends TestCase
self::assertStringNotContainsString('Edit Offering', $html); 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 private function render(): string
{ {
ob_start(); ob_start();
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Offering;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingEndpoint; use Unsupervised\Schedular\Offering\OfferingEndpoint;
@@ -118,4 +119,73 @@ class OfferingEndpointTest extends TestCase
self::assertArrayNotHasKey('etransfer_email', $data[0]); self::assertArrayNotHasKey('etransfer_email', $data[0]);
} }
public function testCanReadAllowsStudentsWhoMayBook(): void
{
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('current_user_can')->alias(
static fn (string $cap): bool => RoleManager::CAP_BOOK_LESSON === $cap
);
self::assertTrue($this->endpoint->canRead());
}
public function testCanReadAllowsOfferingManagersWhoCannotBook(): void
{
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('current_user_can')->alias(
static fn (string $cap): bool => RoleManager::CAP_MANAGE_OFFERINGS === $cap
);
self::assertTrue($this->endpoint->canRead());
}
public function testCanReadRejectsLoggedInUserWithNeitherCapability(): void
{
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('current_user_can')->justReturn(false);
self::assertFalse($this->endpoint->canRead());
}
public function testCanReadRejectsLoggedOutVisitors(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('current_user_can')->justReturn(true);
self::assertFalse($this->endpoint->canRead());
}
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']);
}
} }
+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());
}
}
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler * Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler * Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress. * Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.2.0 * Version: 1.2.2
* Requires at least: 6.2 * Requires at least: 6.2
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: Unsupervised * Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
define('USC_VERSION', '1.2.0'); define('USC_VERSION', '1.2.2');
define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));