17 Commits
Author SHA1 Message Date
thatguygriff 013558019e Merge pull request 'Group-class scheduling, instructor assignment, and details/invite management' (#99) from feature/group-class-scheduling into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 49s
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / Coding Standards (push) Successful in 2m54s
CI / PHPStan (push) Successful in 2m54s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Build Plugin Zip (push) Successful in 2m46s
Release / Build and Publish Release (push) Successful in 2m47s
Release / Open next-version bump PR (push) Successful in 4s
Reviewed-on: #99
2026-07-23 20:56:59 +00:00
thatguygriffandClaude Opus 4.8 87cfe921a9 Show instructor real name or nickname in group-class views, not the login
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m47s
CI / Coding Standards (pull_request) Successful in 2m51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Add Auth\UserName::format(), which prefers a user's first + last name, then
their nickname, avoiding display_name (which can be the login/username).
Route the instructor name through it in both the front-end offerings response
(instructor_name) and the back-end group-class summary and details views.

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

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

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

Back-office group-class views redesigned:

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

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

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

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

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

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

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

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

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

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

Closes #71

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

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

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

Closes #93

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

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

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

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

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

Closes #90

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 09:59:59 -03:00
72 changed files with 4536 additions and 203 deletions
+88 -2
View File
@@ -46,6 +46,22 @@ jobs:
- name: Build plugin zip
run: composer build
# Pull the section for this version out of CHANGELOG.md so it can become
# the release body. Matches "## [x.y.z]" and prints every line up to the
# next "## " heading. A missing section is a warning, not a failure — the
# release still publishes with empty notes.
- name: Extract changelog notes
run: |
version="${{ steps.meta.outputs.version }}"
awk -v ver="$version" '
$0 ~ "^## \\[" ver "\\]" { found = 1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md | sed -e '/./,$!d' | tac | sed -e '/./,$!d' | tac > release-notes.md
if [ ! -s release-notes.md ]; then
echo "::warning::No CHANGELOG.md section found for version ${version}"
fi
- name: Publish release with zip asset
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -65,11 +81,19 @@ jobs:
"${api}/releases/tags/${GITHUB_REF_NAME}" | jq -r '.id // empty' || true)"
if [ -z "$release_id" ]; then
body="$(jq -Rs --arg tag "${GITHUB_REF_NAME}" --argjson pre "${prerelease}" \
'{tag_name:$tag, name:$tag, prerelease:$pre, body:.}' release-notes.md)"
release_id="$(curl -fsS -X POST "${api}/releases" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"prerelease\":${prerelease}}" \
| jq -r '.id')"
-d "${body}" | jq -r '.id')"
else
# Release pre-created via the UI: fill in the notes from the changelog.
body="$(jq -Rs '{body:.}' release-notes.md)"
curl -fsS -X PATCH "${api}/releases/${release_id}" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "${body}" > /dev/null
fi
echo "Attaching ${zip} to release ${release_id}"
@@ -77,3 +101,65 @@ jobs:
"${api}/releases/${release_id}/assets?name=unsupervised-schedular-${version}.zip" \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${zip}" > /dev/null
# After a stable release, move main forward: bump the plugin to the next patch
# version and open a fresh changelog section for it, via a PR. Skipped for
# pre-releases (tags containing a hyphen, e.g. v1.2.0-rc.1) — those don't
# advance the mainline version.
bump-version:
name: Open next-version bump PR
needs: release
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Compute next patch version
id: next
run: |
current="$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*([^[:space:]]+).*/\1/p' unsupervised-schedular.php | head -1)"
base="${current%%-*}" # drop any pre-release suffix
major="${base%%.*}"
rest="${base#*.}"
minor="${rest%%.*}"
patch="${rest#*.}"
next="${major}.${minor}.$((patch + 1))"
echo "next=${next}" >> "$GITHUB_OUTPUT"
- name: Apply version bump and open changelog section
run: |
next="${{ steps.next.outputs.next }}"
# Plugin header + USC_VERSION constant must stay in lockstep.
sed -i -E "s/^([[:space:]]*\*?[[:space:]]*Version:[[:space:]]*).*/\1${next}/" unsupervised-schedular.php
sed -i -E "s/(define\('USC_VERSION', ')[^']+('\);)/\1${next}\2/" unsupervised-schedular.php
# Insert an empty section for the new version above the current top one
# (the first "## [" heading in the file).
awk -v ver="$next" '
!done && /^## \[/ { print "## [" ver "]"; print ""; done = 1 }
{ print }
' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
- name: Open pull request
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
next="${{ steps.next.outputs.next }}"
branch="release/bump-${next}"
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
git config user.name 'Release Bot'
git config user.email '[email protected]'
git checkout -b "${branch}"
git commit -am "Bump version to ${next} and open changelog section"
git push origin "${branch}"
curl -fsS -X POST "${api}/pulls" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg head "${branch}" --arg title "Bump version to ${next}" \
'{head:$head, base:"main", title:$title, body:"Automated post-release bump to the next patch version. Record changes for this version under its changelog heading."}')" \
> /dev/null
+33
View File
@@ -0,0 +1,33 @@
# Changelog
All notable changes to Unsupervised Scheduler are documented in this file, one
section per version, newest first. The top section is always the version the code
on `main` currently carries (the `Version:` header in `unsupervised-schedular.php`).
Whether a version is "released" is purely a matter of whether its tag exists — the
changelog itself does not track that.
When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
`## [x.y.z]` section verbatim as the Gitea release notes, then opens a PR that bumps
the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work.
## [1.1.0]
### Added
- Invite-only group classes, so a class can be restricted to students who hold an invite.
- Instructor group-class roster view under **My Lessons**.
- Cancellation cutoff that limits how close to a lesson a student can cancel.
- Studio-defined account-registration questions collected during student sign-up.
- Group classes now carry a specific class time (alongside the date and duration), and studio admins can assign the teaching instructor. Assigning an instructor clears their open booking slots at the class time and flags any already-booked lesson that clashes.
- Students see who teaches each group class and when it meets on the enrolment page. Instructor names in the group-class views (front and back end) show the instructor's real name (first + last) or nickname, never their login/username.
### Changed
- Plugin metadata links now point at Unsupervised and the Gitea repository.
- The instructor **My Group Classes** view is now a summary of classes with enrolment counts; each class links through to a per-class **details page** (class schedule, roster, and — for invite-only classes — the controls to add or invite students), rather than listing every student inline. Managing who is in an invite-only class is now done from that details page. The studio-admin **Group Classes** page is likewise a per-class summary that links through to the same details page, so a studio admin — including an owner-operator who also teaches — can view any class's roster and manage its invite-only membership.
## [1.0.0]
First stable release: instructor/student lesson scheduling for WordPress, including
availability management, one-on-one and group-class booking, Stripe payments, student
self-registration with email confirmation and admin approval, group invite links, and
self-update from tagged Gitea releases.
+16 -1
View File
@@ -89,6 +89,20 @@
return `${formatDate(o.term_start)} ${formatDate(o.term_end)} (${sessions} weekly sessions)`;
}
// Format a stored H:i(:s) class time as a friendly local-clock label.
function timeLabel(o) {
if (!o.class_time) return '';
const [h, m] = o.class_time.split(':').map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
// The "when" line combines the date (or date range) with the class time.
function whenLabel(o) {
return [termLabel(o), timeLabel(o)].filter(Boolean).join(' · ');
}
function renderClasses(offerings, enrolledOfferingIds) {
let groups = offerings.filter((o) => o.kind === 'group_class');
if (singleOfferingId) {
@@ -104,7 +118,8 @@
list.innerHTML = groups.map((o) => `
<div class="us-class">
<h3>${escHtml(o.title)}</h3>
${termLabel(o) ? `<p>${escHtml(termLabel(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.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
+57
View File
@@ -0,0 +1,57 @@
/**
* Progressive enhancement for the two-step student registration form.
*
* When account-signup questions are configured the form renders two panels
* (`[data-step="1"]` account details, `[data-step="2"]` the questions) inside a
* single form marked `data-steps="1"`. This script hides step two behind a
* "Next" button that only advances once step one passes native validation.
* Without JS both panels stay visible and the single submit still works.
*/
(function () {
'use strict';
function enhance(form) {
var step1 = form.querySelector('[data-step="1"]');
var step2 = form.querySelector('[data-step="2"]');
var next = form.querySelector('.us-reg-next');
var back = form.querySelector('.us-reg-back');
if (!step1 || !step2 || !next) {
return;
}
function show(step) {
step1.hidden = step !== 1;
step2.hidden = step !== 2;
}
show(1);
next.addEventListener('click', function () {
var fields = step1.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) {
if (!fields[i].checkValidity()) {
fields[i].reportValidity();
return;
}
}
show(2);
});
if (back) {
back.addEventListener('click', function () {
show(1);
});
}
}
document.addEventListener('DOMContentLoaded', function () {
var forms = document.querySelectorAll('.us-register-form form[data-steps="1"]');
for (var i = 0; i < forms.length; i++) {
enhance(forms[i]);
}
});
})();
+11 -1
View File
@@ -69,6 +69,7 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
| `token` | VARCHAR(64) | SHA-256 hash of the token embedded in the registration link (raw token is never stored) |
| `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) |
| `kind` | VARCHAR(10) | `personal` (single-use, per email) or `group` (multi-use link) |
| `offering_id` | BIGINT UNSIGNED | Set when a personal invite is tied to an invite-only group class (see `group-classes.md`); NULL otherwise |
| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` (group links stay `pending` until revoked/expired) |
| `invited_by` | BIGINT UNSIGNED | WordPress user ID of the studio admin who invited |
| `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending / for group links |
@@ -76,6 +77,15 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
| `accepted_at` | DATETIME | When accepted; NULL while pending / for group links |
| `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) |
## Registration Questions (signup step two)
When the studio has configured **account-scope** registration questions
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), the
registration form becomes two steps: name/email/password/policies first, then the required
questions. This applies to **every** signup path (invite, group link, self-approval).
Required answers are validated before the account is created, and are stored against the new
user (`us_question_answers`, `registration_type = 'account'`). A studio admin reviews them
under **Registration Information** on the student's admin screen.
## Policy Acceptance Scope
Policies declare **when** they must be accepted via `us_policies.acceptance_scope`:
`signup`, `booking`, or `both` (see `policies.md`). The registration form requires
@@ -87,7 +97,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite.
2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash.
3. The form shows the invited email **pre-filled and read-only** (the server always uses the invite's address on submit, so a tampered value is ignored) and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. A token that is no longer redeemable (expired / accepted / revoked) renders the normal editable email field instead when open registration is on.
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in.
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`.
## Flow (self-approval mode)
1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`).
+69
View File
@@ -0,0 +1,69 @@
# Feature: Cancellation Cutoff
## Overview
Students may cancel their own lessons online — but not indefinitely close to the
start time. A **cancellation cutoff** closes student-initiated cancellation once
a lesson begins within a configured window. Instructors and studio admins are
never subject to the cutoff: they can cancel a lesson at any time through the
lesson-status and student-management flows.
The window is resolved per lesson:
1. If the lesson's **offering** sets its own cutoff, that value is used.
2. Otherwise the **studio default** applies.
Both values are expressed and computed in **hours**. The studio default is
entered and displayed to the admin in **days** for convenience; a per-offering
override is entered directly in hours (a finer-grained "time").
## Data Model
### Option `us_cancellation_cutoff_hours`
Studio-wide default cutoff, stored as an integer number of **hours**. Defaults to
`24` (one day) when unset. `0` means students may cancel at any time.
### Column `{prefix}us_offerings.cancellation_cutoff_hours`
Nullable `SMALLINT UNSIGNED`. `NULL` means "inherit the studio default"; any set
value (including `0` — cancel any time) overrides it for that offering.
## Resolution & Enforcement
`Booking\CancellationPolicy` owns the logic:
- `cutoffHours(?int $offeringCutoffHours): int` — the offering's override when it
is a non-negative value, otherwise the studio default.
- `studentMayCancel(string $slotStartDt, ?int $offeringCutoffHours, ?string $now): bool`
— false once `now` is within the effective cutoff of the slot start. A zero
cutoff always allows cancellation; unparseable datetimes fail open so a student
is never trapped by bad data. Comparisons use WordPress-local time
(`current_time('mysql')`), matching how upcoming lessons are computed.
- `describeCutoff(int $hours): string` — humanises a cutoff for messages
(whole days as days, otherwise hours).
`Booking\BookingEndpoint::cancel()` (the student endpoint,
`POST /bookings/{id}/cancel`) consults the policy before cancelling and returns a
`cancellation_closed` (HTTP 403) error explaining the window when it is too late.
The instructor status endpoint (`PATCH /bookings/{id}/status`) and
`Auth\StudentActions::cancelLesson()` (studio-admin student view) bypass the
policy entirely.
## Admin Interface
- **Studio Settings → Cancellations**: "Cancellation cutoff (days)" — the studio
default, entered/displayed in days, stored in hours.
- **Offerings** add/edit form: "Cancellation cutoff (hours)" — an optional
per-offering override; blank inherits the studio default, `0` allows anytime
cancellation.
## Implementation
- Service: `Unsupervised\Schedular\Booking\CancellationPolicy`
- Studio default: `Unsupervised\Schedular\Payment\StudioSettings::cancellationCutoffHours()`
(option `us_cancellation_cutoff_hours`)
- Per-offering value: `Unsupervised\Schedular\Offering\Offering::$cancellationCutoffHours`
- Enforcement: `Unsupervised\Schedular\Booking\BookingEndpoint::cancel()`
- Wiring: `RestRegistrar` constructs `new CancellationPolicy( new StudioSettings() )`
## Tests
- `tests/Unit/Booking/CancellationPolicyTest.php`
- `tests/Unit/Booking/BookingEndpointTest.php` (cutoff cases in `cancel()`)
- `tests/Unit/Payment/StudioSettingsTest.php` (default getter)
- `tests/Unit/Offering/OfferingTest.php`, `tests/Unit/Offering/OfferingRepositoryTest.php`
(new column round-trips)
+87 -12
View File
@@ -3,6 +3,8 @@
## Overview
Students enrol in a group class — an offering of kind `group_class` — as a commitment for the year. Enrolment is capacity-enforced and billed full-term upfront. Registration reuses the same flow as private lessons (intake questions + policy acceptance + payment).
A group class can be marked **invite-only** (`us_offerings.access_mode = invite_only`, see `offerings.md`). Invite-only classes are hidden from the public catalog — they never appear in the student booking/group-class list — and can only be enrolled in by students the instructor has let in. See **Invite-only access** below.
## Data Model — `{prefix}us_group_enrollments`
| Column | Type | Notes |
@@ -15,11 +17,19 @@ Students enrol in a group class — an offering of kind `group_class` — as a c
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
| `enrolled_at` | DATETIME | Insertion time |
## Class Dates
A group class offering carries `term_start`/`term_end` (see `offerings.md`):
one-off classes end the day they start; weekly classes run a set number of
sessions. The class card on the enrolment page shows the date or date range
with the session count.
## Class Dates, Time, and Instructor
A group class offering carries `term_start`/`term_end` plus a `class_time` and an
owning `instructor_id` (see `offerings.md`): one-off classes end the day they
start; weekly classes run a set number of sessions, all at `class_time`. The class
card on the enrolment page shows **when** the class meets (the date or date range
plus the start time) and **who** teaches it (the assigned instructor's name,
surfaced as `instructor_name` on the `GET /offerings` response). Instructor names
in the group-class views (front and back end) use the instructor's real name
(first + last) or nickname, never their login — see `Auth\UserName::format()`.
Assigning an instructor to a scheduled class removes that instructor's open
booking slots at the class time and flags any already-booked lesson that clashes;
see **Instructor assignment** in `offerings.md`.
## Enrolment Flow
The class list is loaded together with the student's own enrolments
@@ -53,14 +63,77 @@ payment step).
`GET /enrollments` returns the caller's own enrolments, or all enrolments for the
instructor's group classes if the caller has `view_own_lessons` on those offerings.
`GET /offerings` (the catalog that feeds the group-class list) returns public
offerings **plus** any invite-only offerings the caller has an access grant for, so a
granted student sees the private class alongside public ones. Ungranted students never
receive it. Enrolling in an invite-only class requires a grant: `POST /enrollments`
rejects an ungranted student with `403 invite_required`, and a successful enrolment
flips their grant from `invited` to `enrolled`.
## Invite-only access
Access to an invite-only class is recorded in `{prefix}us_group_access` — a grant per
person, separate from the enrolment itself. The instructor manages access from
**My Lessons → My Group Classes**, which renders three controls under each invite-only
class:
1. **Add students directly** — the selected registered students are enrolled immediately
(`status = active`) with a **pending payment** at the class price (comp students are
settled at once by `PaymentService`). No access grant is needed — this writes straight
to `us_group_enrollments` + `us_payments`.
2. **Make available** — the selected registered students get an `invited` grant so the
class appears in their own group-class list; they then self-enrol through the normal
paid flow. Each is emailed a "you've been added" notice.
3. **Invite by email** — for an address with no account yet: a tokenised personal invite
(`us_invites`, carrying `offering_id`) is created and the registration link emailed,
alongside an `invited` grant keyed by `email` + `invite_id`. If the address already has
a **pending** invite, the grant is attached to that invite and **no second link is
sent**. An address that already has an account is treated as **Make available** instead.
When an email-invited person completes registration, `RegistrationPage` links their new
account to the grant (`GroupAccessRepository::linkStudentByEmail`), so the invite-only
class becomes enrollable for them — they choose whether to enrol.
### Data Model — `{prefix}us_group_access`
| Column | Type | Notes |
|---------------|-----------------|-----------------------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` (an invite-only group class) |
| `student_id` | BIGINT UNSIGNED | WordPress user ID; NULL until an email invitee registers |
| `email` | VARCHAR(191) | Email-invite grants only; used to link the account once it registers |
| `invite_id` | BIGINT UNSIGNED | FK → `us_invites.id` for email-invite grants; NULL otherwise |
| `status` | VARCHAR(20) | `invited` / `enrolled` / `revoked` |
| `invited_by` | BIGINT UNSIGNED | Instructor who granted access |
| `created_at` | DATETIME | Insertion time |
## Admin Interface
- **Group Classes** (`manage_options` / studio admin): all enrolments across instructors
- Instructors see enrolments for their own group classes under **My Lessons**
- **Group Classes** (`view_all_lessons` / studio admin): a per-class summary across
instructors — each class with its instructor, when it meets, and its active-enrolment
count against capacity (not a flat list of individual student enrolments). Selecting a
class (`?class_id=<id>`) opens the same per-class **details page** described below, so a
studio admin — including an owner-operator who also teaches, for whom the instructor
**My Group Classes** menu is hidden — can view any class's roster and manage invite-only
membership from here. Invite actions are permitted for the class's own instructor or any
`view_all_lessons` studio admin.
- **My Lessons → My Group Classes** (`view_own_lessons` / instructor): a summary of the
instructor's own group classes — each with when it meets and its active-enrolment count
against capacity, plus a **View details** link (**View & invite** for invite-only
classes). Selecting a class (`?class_id=<id>`, scoped to the owning instructor) opens its
**details page**: a class-details panel (when, instructor, enrolled/capacity, duration,
price, schedule note, description, status), the roster of enrolled students with enrolment
and payment status, and — for invite-only classes — an **Invite & enrol students** section
listing who has been invited but not yet enrolled alongside the add/make-available/
invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to the owning
instructor). Managing who is in an invite-only class is therefore done entirely from this
page. The summary (`templates/admin/my-group-classes.php`) and the details page
(`templates/admin/my-group-class-detail.php`) are separate templates.
## Implementation
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
- Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`)
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_lessons`)
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` `renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`)
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute)
- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
@@ -68,12 +141,14 @@ instructor's group classes if the caller has `view_own_lessons` on those offerin
> **Payment:** a priced enrolment creates a payment via `Payment\PaymentService`
> (`registration_type = enrollment`) and links it as `payment_id`; unpriced
> enrolments return `payment: null` and skip the payment step. See `payments.md`
> for the card/e-transfer/comp flows. Instructor-specific enrolment views (the
> spec's "under My Lessons") are a follow-up (#71) — this iteration ships the
> studio-admin **Group Classes** page (`view_all_lessons`) plus
> per-student/per-instructor REST queries.
> for the card/e-transfer/comp flows.
## Tests
- `tests/Unit/GroupClass/GroupClassControllerTest.php` (roster + add/make-available/invite actions)
- `tests/Unit/GroupClass/EnrollmentTest.php`
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
- `tests/Unit/GroupClass/EnrollmentEndpointTest.php` (invite-only gating)
- `tests/Unit/GroupClass/GroupAccessTest.php`
- `tests/Unit/GroupClass/GroupAccessRepositoryTest.php`
- `tests/Unit/GroupClass/GroupClassPageTest.php`
- `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes)
+30 -2
View File
@@ -20,7 +20,10 @@ An offering is anything a student can register for: a private-lesson type (30 or
| `capacity` | SMALLINT | Group only — max enrolments; NULL for private |
| `term_start` | DATE | Group / term offerings — first day; NULL otherwise |
| `term_end` | DATE | Group / term offerings — last day; NULL otherwise |
| `class_time` | TIME | Group only — time of day each session starts; NULL otherwise |
| `schedule_note` | VARCHAR(191) | Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"|
| `cancellation_cutoff_hours` | SMALLINT UNSIGNED | Optional per-offering cancellation cutoff in hours; NULL inherits the studio default (see `cancellation-cutoff.md`) |
| `access_mode` | VARCHAR(20) | `public` (listed in the catalog) or `invite_only` (group classes hidden from the catalog — see `group-classes.md`) |
| `is_active` | TINYINT(1) | 0 = hidden from registration, 1 = bookable |
| `created_at` | DATETIME | Insertion time |
@@ -38,6 +41,28 @@ sessions** (`term_end = term_start + (N1) weeks`, computed by
student-facing class card shows the date (one-off) or the date range with the
weekly session count.
## Class Time and Sessions
A group class also carries `class_time` — the time of day each session starts —
validated by `Offering::normalizeTime()` (strict `H:i`/`H:i:s`; garbage leaves it
NULL). `class_time` + `term_start`/`term_end` + `duration_minutes` together define
the concrete session windows: `Offering::sessionWindows()` returns one
`{start, end}` per session (weekly across the term, or a single window for a
one-off), and returns an empty list unless date, time, and a positive duration are
all set. These windows drive availability reconciliation (see **Instructor
assignment** below and `group-classes.md`).
## Instructor assignment
Every offering has an owning `instructor_id`. A studio admin
(`manage_instructors`) sees an **Instructor** picker on the offering form and may
assign a group class to any instructor; a plain instructor never sees the picker
and always owns the classes they create (the posted value is ignored for them, and
updates never reassign the owner otherwise). When a group class is saved with an
assigned instructor and a full schedule, `Offering\ClassSlotReconciler` clears that
instructor's **open** availability slots overlapping each session so students can't
book them, and reports any **already-booked** lesson that clashes as a conflict for
the studio to resolve by hand (a booked lesson is never deleted). The result is
surfaced as an admin notice after saving.
## Admin Interface
Studio admin and instructors manage offerings under **Offerings** in wp-admin.
- Studio admin (`manage_offerings`) manages offerings for any instructor.
@@ -59,11 +84,14 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
## Implementation
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository`
- Model: `Unsupervised\Schedular\Offering\Offering`
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`)
- Admin controller: `Unsupervised\Schedular\Offering\OfferingController`
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint`
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` (public listing includes `instructor_name`)
- Availability reconciliation: `Unsupervised\Schedular\Offering\ClassSlotReconciler` (uses `Availability\AvailabilityRepository::findOverlapping`)
## Tests
- `tests/Unit/Offering/OfferingControllerTest.php`
- `tests/Unit/Offering/OfferingRepositoryTest.php`
- `tests/Unit/Offering/OfferingTest.php`
- `tests/Unit/Offering/OfferingEndpointTest.php`
- `tests/Unit/Offering/ClassSlotReconcilerTest.php`
+42 -15
View File
@@ -1,19 +1,30 @@
# Feature: Registration Questions
## Overview
Each offering can carry a set of intake questions the registrant must answer when booking. Questions are authored per offering by the studio admin or the owning instructor, and answers are stored against the resulting lesson or group enrolment.
Questions come in two **scopes**:
- **Offering scope** (`scope = 'offering'`) — intake questions a registrant answers when
booking a specific offering; authored per offering by the studio admin or the owning
instructor, and stored against the resulting lesson or group enrolment.
- **Account scope** (`scope = 'account'`) — studio-wide questions every new student answers
**once at account signup**, as a required second step after choosing their name and
password. Authored by the studio admin only, and stored against the new user account.
Both scopes share the `us_questions` / `us_question_answers` tables, the same field types,
and the same authoring page (**Offerings → Questions**).
## Data Model — `{prefix}us_questions`
| Column | Type | Notes |
|---------------|------------------|-------------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` — questions are scoped per offering |
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` for offering-scoped questions; NULL for account-scoped |
| `scope` | VARCHAR(20) | `offering` (default) or `account` |
| `label` | VARCHAR(255) | The question text shown to the registrant |
| `field_type` | VARCHAR(20) | `text` / `textarea` / `select` / `checkbox` |
| `options` | TEXT | JSON array of choices (for `select`); NULL otherwise |
| `is_required` | TINYINT(1) | 1 = registrant must answer to continue |
| `sort_order` | INT | Display order within the offering |
| `sort_order` | INT | Display order within the scope |
| `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form |
| `created_at` | DATETIME | Insertion time |
@@ -23,27 +34,37 @@ Each offering can carry a set of intake questions the registrant must answer whe
|---------------------|------------------|--------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `question_id` | BIGINT UNSIGNED | FK → `us_questions.id` |
| `registration_type` | VARCHAR(20) | `lesson` or `enrollment` |
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.id` |
| `registration_type` | VARCHAR(20) | `lesson`, `enrollment`, or `account` |
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id`, `us_group_enrollments.id`, or the user ID (account scope) |
| `student_id` | BIGINT UNSIGNED | WordPress user ID (denormalised for fast lookup) |
| `answer_value` | TEXT | The submitted answer (checkbox stored as `0`/`1`) |
| `created_at` | DATETIME | Insertion time |
The `registration_type` + `registration_id` pair is a polymorphic reference shared
with `us_policy_acceptances` (see `policies.md`), letting answers attach to either a
private lesson or a group enrolment.
with `us_policy_acceptances` (see `policies.md`), letting answers attach to a private
lesson, a group enrolment, or an account signup (`account` + the user ID).
## Flow
1. On the registration form, the front-end calls `GET /offerings/{id}/questions`.
## Offering-scope Flow
1. On the booking form, the front-end calls `GET /offerings/{id}/questions`.
2. Required questions block submission until answered.
3. Answers are sent in the `answers[]` array on `POST /bookings` or `POST /enrollments` and written to `us_question_answers` alongside the new registration row.
## Account-scope Flow (signup step two)
1. The `[us_student_register]` page (`Auth\RegistrationPage`) loads active account-scope questions via `QuestionRepository::findByScope('account')`.
2. The form renders as two steps: step one is email/name/password/policies, step two is the questions. `assets/js/register.js` reveals step two behind a "Next" button (progressive enhancement — without JS both steps show and the single submit still works). This applies to **every** signup path (invite, group link, self-approval).
3. On submit, required answers are validated **before** the user is created (a missing answer returns an error and creates no account); after creation each answered question is written to `us_question_answers` with `registration_type = 'account'`, `registration_id = student_id = <new user ID>`.
4. A studio admin reviews the answers on the student's admin screen under **Registration Information** (`Auth\StudentHistory::registrationInfo()` lists every account question paired with the student's answer, "—" when unanswered). These rows are excluded from the offering-scope "Intake answers" table.
## Admin Interface
Questions are edited from each offering's screen (**Offerings → Questions**).
- Studio admin (`manage_questions`) edits questions on any offering.
- Instructor (`manage_questions`) edits questions only on their own offerings.
Both scopes are edited from **Offerings → Questions** (`Registration\QuestionController`):
- Pick an offering to edit its questions, or **"Account signup (all registrations)"** for the account-scope questions.
- Studio admin (`manage_questions` + `manage_instructors`) edits any offering's questions and the account-scope questions.
- Instructor (`manage_questions`) edits questions only on their own offerings; the account-scope option is hidden.
## REST API
Only offering-scope questions are exposed over REST. Account-scope questions are managed
through the server-rendered admin page and read directly by `RegistrationPage`.
| Method | Endpoint | Permission |
|----------|---------------------------------------------------|----------------------|
| `GET` | `/wp-json/us-scheduler/v1/offerings/{id}/questions`| Public |
@@ -52,12 +73,18 @@ Questions are edited from each offering's screen (**Offerings → Questions**).
| `DELETE` | `/wp-json/us-scheduler/v1/questions/{id}` | `manage_questions` + owner |
## Implementation
- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository`, `Unsupervised\Schedular\Registration\AnswerRepository`
- Models: `Unsupervised\Schedular\Registration\Question`, `Unsupervised\Schedular\Registration\Answer`
- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository` (`findByOffering`, `findByScope`), `Unsupervised\Schedular\Registration\AnswerRepository`
- Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`)
- Admin controller: `Unsupervised\Schedular\Registration\QuestionController`
- REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint`
- REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint` (offering scope only)
- Signup step two: `Unsupervised\Schedular\Auth\RegistrationPage`, `templates/frontend/register-page.php`, `assets/js/register.js`
- Admin review: `Unsupervised\Schedular\Auth\StudentHistory::registrationInfo()`, `templates/admin/student-detail.php`
- Schema: `us_questions.scope` + nullable `us_questions.offering_id` (requires a plugin version bump so `dbDelta` runs)
## Tests
- `tests/Unit/Registration/QuestionRepositoryTest.php`
- `tests/Unit/Registration/AnswerRepositoryTest.php`
- `tests/Unit/Registration/QuestionTest.php`
- `tests/Unit/Registration/AnswerTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php`
- `tests/Unit/Auth/StudentHistoryTest.php`
+16 -4
View File
@@ -18,7 +18,9 @@ use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
@@ -53,15 +55,15 @@ class AdminMenu {
private PaymentController $paymentController;
private PaymentReportController $paymentReportController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments, $availability );
$this->offeringController = new OfferingController( $offerings );
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
$this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites );
$this->registrationApprovalController = new RegistrationApprovalController( new RegistrationMailer() );
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) );
$this->instructorController = new InstructorController();
$this->settings = $settings;
@@ -247,6 +249,16 @@ class AdminMenu {
'dashicons-welcome-learn-more',
42
);
// Instructor: their own group classes with per-class rosters.
add_submenu_page(
'us-my-lessons',
__( 'My Group Classes', 'unsupervised-schedular' ),
__( 'My Group Classes', 'unsupervised-schedular' ),
RoleManager::CAP_VIEW_LESSONS,
'us-my-group-classes',
[ $this->groupClassController, 'renderInstructorPage' ]
);
}
}
+3
View File
@@ -51,6 +51,7 @@ class Invite {
public readonly ?string $createdAt = null,
public readonly string $kind = self::KIND_PERSONAL,
public readonly ?string $expiresAt = null,
public readonly ?int $offeringId = null,
public readonly ?int $id = null,
) {}
@@ -66,6 +67,7 @@ class Invite {
createdAt: Val::stringOrNull( $row->created_at ?? null ),
kind: '' !== Val::string( $row->kind ?? '' ) ? Val::string( $row->kind ) : self::KIND_PERSONAL,
expiresAt: Val::stringOrNull( $row->expires_at ?? null ),
offeringId: Val::intOrNull( $row->offering_id ?? null ),
id: Val::int( $row->id ),
);
}
@@ -132,6 +134,7 @@ class Invite {
'accepted_user_id' => $this->acceptedUserId,
'accepted_at' => $this->acceptedAt,
'expires_at' => $this->expiresAt,
'offering_id' => $this->offeringId,
];
}
}
+2 -1
View File
@@ -23,6 +23,7 @@ class InviteRepository {
'token' => $invite->token,
'role' => $invite->role,
'kind' => $invite->kind,
'offering_id' => $invite->offeringId,
'status' => $invite->status,
'invited_by' => $invite->invitedBy,
'accepted_user_id' => $invite->acceptedUserId,
@@ -30,7 +31,7 @@ class InviteRepository {
'accepted_at' => $invite->acceptedAt,
'expires_at' => $invite->expiresAt,
],
[ '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' ]
[ '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s' ]
);
return false === $result ? 0 : $this->db->insert_id;
+50
View File
@@ -105,6 +105,56 @@ class RegistrationMailer {
return (bool) wp_mail( $email, $subject, $body );
}
/**
* Tell a registered student they have been given access to an invite-only
* group class and can now enrol. Returns false when there is no recipient.
*/
public function sendClassAccessGranted( \WP_User $user, string $className ): bool {
if ( '' === (string) $user->user_email ) {
return false;
}
$subject = sprintf(
/* translators: %s: class title */
__( 'You have been invited to %s', 'unsupervised-schedular' ),
$className
);
$body = sprintf(
/* translators: 1: class title, 2: site name, 3: login URL */
__( "You have been given access to the group class \"%1\$s\" at %2\$s.\n\nLog in and open the group classes page to enrol:\n%3\$s", 'unsupervised-schedular' ),
$className,
$this->siteName(),
wp_login_url()
);
return (bool) wp_mail( $user->user_email, $subject, $body );
}
/**
* Email a tokenised registration link to someone invited to a group class who
* does not yet have an account. Returns false when there is no recipient.
*/
public function sendClassInvite( string $email, string $link, string $className ): bool {
if ( '' === $email ) {
return false;
}
$subject = sprintf(
/* translators: %s: class title */
__( 'You are invited to join %s', 'unsupervised-schedular' ),
$className
);
$body = sprintf(
/* translators: 1: class title, 2: site name, 3: registration URL */
__( "You have been invited to the group class \"%1\$s\" at %2\$s.\n\nCreate your account using this link, then choose to enrol in the class:\n%3\$s", 'unsupervised-schedular' ),
$className,
$this->siteName(),
$link
);
return (bool) wp_mail( $email, $subject, $body );
}
private function siteName(): string {
$name = (string) get_bloginfo( 'name' );
+77
View File
@@ -3,12 +3,17 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Val;
class RegistrationPage {
@@ -32,6 +37,9 @@ class RegistrationPage {
private AcceptanceRepository $acceptances,
private StudioSettings $settings,
private RegistrationMailer $mailer,
private QuestionRepository $questions,
private AnswerRepository $answers,
private GroupAccessRepository $access,
) {}
/**
@@ -77,8 +85,14 @@ class RegistrationPage {
$loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
$policyForms = $this->signupPolicies();
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$canRegister = $open || $inviteValid;
// The two-step script only matters when there is a second step to reveal.
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
wp_enqueue_script( 'us-scheduler-register' );
}
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
return (string) ob_get_clean();
@@ -156,6 +170,17 @@ class RegistrationPage {
}
}
// Account-signup questions (step two) — validate before creating the user so
// a missing required answer never leaves a half-registered account behind.
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$answers = $this->submittedAnswers();
foreach ( $accountQuestions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
}
}
if ( email_exists( $email ) ) {
return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
}
@@ -175,10 +200,16 @@ class RegistrationPage {
}
$this->recordAcceptances( $policyForms, (int) $userId );
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
if ( $inviteValid && ! $invite->isGroup() ) {
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
// A personal invite may carry a group-class grant (invited by email);
// point any grants for this address at the new account so the class
// becomes enrollable for them.
$this->access->linkStudentByEmail( $email, (int) $userId );
wp_set_current_user( (int) $userId );
wp_set_auth_cookie( (int) $userId );
@@ -228,6 +259,52 @@ class RegistrationPage {
return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base );
}
/**
* The account-question answers submitted with the form, keyed by question id.
*
* @return array<int, string>
*/
private function submittedAnswers(): array {
// The submit nonce is verified by the caller (render) before this runs.
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized in the loop below.
$raw = $_POST['us_answers'] ?? [];
if ( ! is_array( $raw ) ) {
return [];
}
$out = [];
foreach ( $raw as $questionId => $value ) {
$out[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
}
return $out;
}
/**
* Persist the submitted answers for each active account-signup question.
*
* @param list<Question> $questions
* @param array<int, string> $answers question_id => submitted value
*/
private function recordAnswers( array $questions, array $answers, int $userId ): void {
foreach ( $questions as $question ) {
$value = trim( (string) ( $answers[ (int) $question->id ] ?? '' ) );
if ( '' === $value ) {
continue;
}
$this->answers->insert(
new Answer(
questionId: (int) $question->id,
registrationType: Answer::REG_ACCOUNT,
registrationId: $userId,
studentId: $userId,
answerValue: $value,
)
);
}
}
/**
* Record account-time acceptances for each signup policy version.
*
+1
View File
@@ -146,6 +146,7 @@ class StudentController {
);
$acceptances = $this->history->policyAcceptances( (int) $student->ID );
$registrationInfo = $this->history->registrationInfo( (int) $student->ID );
$intake = $this->history->intakeAnswers( (int) $student->ID );
$payments = $canBilling ? $this->history->payments( (int) $student->ID ) : [];
+40 -3
View File
@@ -11,6 +11,7 @@ use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
/**
@@ -51,12 +52,20 @@ class StudentHistory {
}
/**
* Every intake answer the student has submitted, newest registration first.
* Booking/enrolment intake answers the student has submitted, newest first.
* Account-signup answers are excluded — those are shown on their own under
* {@see registrationInfo()}.
*
* @return list<array{question: string, answer: string, context: string}>
*/
public function intakeAnswers( int $studentId ): array {
return array_map(
$bookingAnswers = array_filter(
$this->answers->findByStudent( $studentId ),
static fn( Answer $answer ): bool => Answer::REG_ACCOUNT !== $answer->registrationType
);
return array_values(
array_map(
function ( Answer $answer ): array {
$question = $this->questions->findById( $answer->questionId );
@@ -66,7 +75,35 @@ class StudentHistory {
'context' => $this->contextLabel( $answer->registrationType, $answer->registrationId ),
];
},
$this->answers->findByStudent( $studentId )
$bookingAnswers
)
);
}
/**
* The student's answers to the studio-wide account-signup questions: every
* configured account question paired with the student's answer ("—" when
* unanswered, e.g. a question added after they registered).
*
* @return list<array{question: string, answer: string, required: bool}>
*/
public function registrationInfo( int $studentId ): array {
$byQuestion = [];
foreach ( $this->answers->findByRegistration( Answer::REG_ACCOUNT, $studentId ) as $answer ) {
$byQuestion[ $answer->questionId ] = $answer->answerValue ?? '';
}
return array_map(
static function ( Question $question ) use ( $byQuestion ): array {
$value = $byQuestion[ (int) $question->id ] ?? '';
return [
'question' => $question->label,
'answer' => '' === $value ? '—' : $value,
'required' => $question->isRequired,
];
},
$this->questions->findByScope( Question::SCOPE_ACCOUNT )
);
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* Resolves a person's public-facing name for display. Prefers their real name
* (first + last), then their nickname — deliberately avoiding the account's
* login/username, which `display_name` can otherwise expose.
*/
class UserName {
/**
* The display name for a user: "First Last" when a real name is set,
* otherwise the WordPress nickname. Falls back to the numeric id (or an empty
* string when none is given) when the user cannot be loaded or has no name.
*/
public static function format( ?\WP_User $user, int $fallbackId = 0 ): string {
if ( ! $user instanceof \WP_User ) {
return $fallbackId > 0 ? (string) $fallbackId : '';
}
$full = trim( $user->first_name . ' ' . $user->last_name );
if ( '' !== $full ) {
return $full;
}
$nickname = trim( $user->nickname );
if ( '' !== $nickname ) {
return $nickname;
}
return $fallbackId > 0 ? (string) $fallbackId : '';
}
}
@@ -181,6 +181,28 @@ class AvailabilityRepository {
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
/**
* An instructor's slots (booked and unbooked) that overlap a time window —
* they share any time with the half-open interval [$start, $end). Used when a
* group class is scheduled to find the private-booking slots that collide with
* it, so open ones can be cleared and booked ones flagged as conflicts.
*
* @return list<AvailabilitySlot>
*/
public function findOverlapping( int $instructorId, string $start, string $end ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE instructor_id = %d AND start_dt < %s AND end_dt > %s ORDER BY start_dt ASC',
$this->table,
$instructorId,
$end,
$start
)
);
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
public function findById( int $id ): ?AvailabilitySlot {
$row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
+18
View File
@@ -27,6 +27,7 @@ class BookingEndpoint {
private OfferingRepository $offerings,
private RegistrationGate $gate,
private PaymentService $payments,
private CancellationPolicy $cancellationPolicy,
) {}
/**
@@ -320,6 +321,23 @@ class BookingEndpoint {
}
if ( Lesson::STATUS_CANCELLED !== $lesson->status ) {
$slot = $this->availability->findById( $lesson->slotId );
if ( null !== $slot ) {
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
$overrideHours = $offering?->cancellationCutoffHours;
if ( ! $this->cancellationPolicy->studentMayCancel( $slot->startDt, $overrideHours ) ) {
return new \WP_Error(
'cancellation_closed',
sprintf(
/* translators: %s: humanised cutoff window, e.g. "2 days" or "12 hours". */
__( 'This lesson can no longer be cancelled online — cancellations close %s before the lesson starts. Please contact the studio.', 'unsupervised-schedular' ),
$this->cancellationPolicy->describeCutoff( $this->cancellationPolicy->cutoffHours( $overrideHours ) )
),
[ 'status' => 403 ]
);
}
}
$this->bookings->updateStatus( $id, Lesson::STATUS_CANCELLED );
$this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId );
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Payment\StudioSettings;
/**
* Decides whether a student may still cancel a lesson. Cancellation closes once
* the lesson starts within the effective cutoff window; instructors and studio
* admins bypass this entirely and cancel through other paths.
*
* The window is resolved per lesson: the offering's own cutoff when it sets one,
* otherwise the studio default. Both are expressed in hours.
*/
class CancellationPolicy {
public function __construct( private StudioSettings $settings ) {}
/**
* The effective cutoff in hours for a lesson: the offering's override when
* set (a non-negative value), otherwise the studio default.
*/
public function cutoffHours( ?int $offeringCutoffHours ): int {
if ( null !== $offeringCutoffHours && $offeringCutoffHours >= 0 ) {
return $offeringCutoffHours;
}
return $this->settings->cancellationCutoffHours();
}
/**
* Whether a student may still cancel a lesson starting at $slotStartDt
* (WordPress-local `Y-m-d H:i:s`), given the offering's optional cutoff
* override. A zero cutoff always allows cancellation; unparseable input
* fails open so a student is never trapped by bad data. Pass $now to make
* the comparison deterministic in tests.
*/
public function studentMayCancel( string $slotStartDt, ?int $offeringCutoffHours, ?string $now = null ): bool {
$hours = $this->cutoffHours( $offeringCutoffHours );
if ( $hours <= 0 ) {
return true;
}
$start = strtotime( $slotStartDt );
$current = strtotime( $now ?? current_time( 'mysql' ) );
if ( false === $start || false === $current ) {
return true;
}
return ( $start - $current ) >= $hours * 3600;
}
/**
* A human-readable description of a cutoff for student-facing messages:
* whole days as days, anything else as hours.
*/
public function describeCutoff( int $hours ): string {
if ( $hours > 0 && 0 === $hours % 24 ) {
$days = $hours / 24;
/* translators: %d: number of days. */
return sprintf( _n( '%d day', '%d days', $days, 'unsupervised-schedular' ), $days );
}
/* translators: %d: number of hours. */
return sprintf( _n( '%d hour', '%d hours', $hours, 'unsupervised-schedular' ), $hours );
}
}
+13
View File
@@ -19,6 +19,7 @@ class EnrollmentEndpoint {
private OfferingRepository $offerings,
private RegistrationGate $gate,
private PaymentService $payments,
private GroupAccessRepository $access,
) {}
/**
@@ -88,6 +89,12 @@ class EnrollmentEndpoint {
return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
// Invite-only classes can only be enrolled in by students who were granted
// access (or added directly); everyone else never sees the class at all.
if ( $offering->isInviteOnly() && ! $this->access->hasGrant( $offeringId, $studentId ) ) {
return new \WP_Error( 'invite_required', __( 'This class is by invitation only.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
@@ -110,6 +117,12 @@ class EnrollmentEndpoint {
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
// Mark the access grant used so instructor rosters distinguish invited
// students from enrolled ones (a no-op for public classes).
if ( $offering->isInviteOnly() ) {
$this->access->markEnrolled( $offeringId, $studentId );
}
$payment = null;
if ( $offering->price > 0.0 ) {
$payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Val;
/**
* A grant of access to an invite-only group class. Registered students who have
* been "made available" a class hold an `invited` grant (`student_id` set);
* email-invited people who do not yet have an account hold a grant keyed by
* `email` and linked to a `us_invites` row, which is pointed at the new
* `student_id` once they register. A grant flips to `enrolled` when the student
* enrols through the normal flow.
*/
class GroupAccess {
public const STATUS_INVITED = 'invited';
public const STATUS_ENROLLED = 'enrolled';
public const STATUS_REVOKED = 'revoked';
/**
* All valid grant statuses.
*
* @var list<string>
*/
public const VALID_STATUSES = [ self::STATUS_INVITED, self::STATUS_ENROLLED, self::STATUS_REVOKED ];
public function __construct(
public readonly int $offeringId,
public readonly ?int $studentId = null,
public readonly string $email = '',
public readonly ?int $inviteId = null,
public readonly string $status = self::STATUS_INVITED,
public readonly ?int $invitedBy = null,
public readonly ?int $id = null,
) {}
public static function fromRow( \stdClass $row ): self {
return new self(
offeringId: Val::int( $row->offering_id ),
studentId: Val::intOrNull( $row->student_id ),
email: Val::string( $row->email ?? '' ),
inviteId: Val::intOrNull( $row->invite_id ?? null ),
status: Val::string( $row->status ),
invitedBy: Val::intOrNull( $row->invited_by ?? null ),
id: Val::int( $row->id ),
);
}
/**
* Returns a plain array representation of the grant.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'offering_id' => $this->offeringId,
'student_id' => $this->studentId,
'email' => $this->email,
'invite_id' => $this->inviteId,
'status' => $this->status,
'invited_by' => $this->invitedBy,
];
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
class GroupAccessRepository {
private string $table;
public function __construct( private \wpdb $db ) {
$this->table = $db->prefix . 'us_group_access';
}
public function insert( GroupAccess $access ): int {
$this->db->insert(
$this->table,
[
'offering_id' => $access->offeringId,
'student_id' => $access->studentId,
'email' => $access->email,
'invite_id' => $access->inviteId,
'status' => $access->status,
'invited_by' => $access->invitedBy,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%s', '%d', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
}
/**
* Whether a student holds a live (invited or enrolled) grant for an offering.
*/
public function hasGrant( int $offeringId, int $studentId ): bool {
$count = (int) $this->db->get_var(
$this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND student_id = %d AND status IN ( %s, %s )',
$this->table,
$offeringId,
$studentId,
GroupAccess::STATUS_INVITED,
GroupAccess::STATUS_ENROLLED
)
);
return $count > 0;
}
/**
* The offering ids a student holds a live grant for — the invite-only classes
* to fold into their catalogue view.
*
* @return list<int>
*/
public function findGrantedOfferingIds( int $studentId ): array {
$rows = $this->db->get_col(
$this->db->prepare(
'SELECT DISTINCT offering_id FROM %i WHERE student_id = %d AND status IN ( %s, %s )',
$this->table,
$studentId,
GroupAccess::STATUS_INVITED,
GroupAccess::STATUS_ENROLLED
)
);
return array_values( array_map( \Unsupervised\Schedular\Val::int( ... ), $rows ) );
}
/**
* All grants for an offering, newest first.
*
* @return list<GroupAccess>
*/
public function findByOffering( int $offeringId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE offering_id = %d ORDER BY id DESC',
$this->table,
$offeringId
)
);
return array_map( GroupAccess::fromRow( ... ), $rows ?? [] );
}
/**
* Point email-invite grants for an address at the account created when the
* invitation was accepted, so the granted class unlocks for the new student.
* Only grants still awaiting an account (`student_id` NULL) are linked.
*/
public function linkStudentByEmail( string $email, int $studentId ): bool {
if ( '' === $email ) {
return false;
}
$sql = $this->db->prepare(
'UPDATE %i SET student_id = %d WHERE email = %s AND student_id IS NULL',
$this->table,
$studentId,
$email
);
return null !== $sql && false !== $this->db->query( $sql );
}
/**
* Flip a student's live grant for an offering to enrolled.
*/
public function markEnrolled( int $offeringId, int $studentId ): bool {
return false !== $this->db->update(
$this->table,
[ 'status' => GroupAccess::STATUS_ENROLLED ],
[
'offering_id' => $offeringId,
'student_id' => $studentId,
],
[ '%s' ],
[ '%d', '%d' ]
);
}
public function revoke( int $id ): bool {
return false !== $this->db->update(
$this->table,
[ 'status' => GroupAccess::STATUS_REVOKED ],
[ 'id' => $id ],
[ '%s' ],
[ '%d' ]
);
}
}
+478 -8
View File
@@ -3,35 +3,505 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Auth\Invite;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Val;
class GroupClassController {
public function __construct(
private EnrollmentRepository $enrollments,
private OfferingRepository $offerings,
private PaymentRepository $payments,
private GroupAccessRepository $access,
private PaymentService $paymentService,
private InviteRepository $invites,
private RegistrationMailer $mailer,
) {}
/**
* Studio-admin overview: every group class across instructors as a summary —
* who teaches it, when it meets, and how full it is — rather than a flat list
* of individual student enrolments. Selecting a class (`?class_id=<id>`) opens
* the same per-class details page instructors use, so a studio admin (including
* an owner-operator who also teaches) can view any class's roster and manage
* invite-only membership from here.
*/
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
}
$rows = array_map(
function ( Enrollment $enrollment ): array {
$offering = $this->offerings->findById( $enrollment->offeringId );
$student = get_userdata( $enrollment->studentId );
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
$notice = $this->handleFormAction( get_current_user_id() );
}
$offerings = $this->offerings->findAll( 0, Offering::KIND_GROUP_CLASS );
$baseUrl = admin_url( 'admin.php?page=us-group-classes' );
// View-state query param only (which class to drill into) — nothing is
// mutated from it, so no nonce applies.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$classId = absint( Val::int( $_GET['class_id'] ?? 0 ) );
$current = null;
foreach ( $offerings as $offering ) {
if ( $offering->id === $classId ) {
$current = $offering;
break;
}
}
if ( null !== $current ) {
// Enrolments are looked up by the class's own instructor; classDetail
// filters them down to this offering.
$class = $this->classDetail( $current, $this->enrollments->findByInstructor( $current->instructorId ) );
$students = $this->studentOptions();
include USC_PLUGIN_DIR . 'templates/admin/my-group-class-detail.php';
return;
}
$rows = array_map(
function ( Offering $offering ): array {
return [
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
'status' => $enrollment->status,
'id' => $offering->id,
'title' => $offering->title,
'instructor' => $this->instructorName( $offering ),
'when' => $this->whenLabel( $offering ),
'capacity' => $offering->capacity,
'enrolled' => $this->enrollments->countActiveForOffering( (int) $offering->id ),
'invite_only' => $offering->isInviteOnly(),
];
},
$this->enrollments->findAllActive()
$offerings
);
include USC_PLUGIN_DIR . 'templates/admin/group-classes.php';
}
/**
* Instructor view. By default a summary of the instructor's own group classes
* — each with when it meets and how many are enrolled — rather than a dump of
* every roster. A `class_id` query param drills into one class to show its
* roster of enrolled students and, for invite-only classes, the controls to
* add, grant access to, or email-invite students.
*/
public function renderInstructorPage(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
}
$instructorId = get_current_user_id();
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
$notice = $this->handleFormAction( $instructorId );
}
$offerings = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS );
$enrollments = $this->enrollments->findByInstructor( $instructorId );
// View-state query param only (which class to drill into) — nothing is
// mutated from it, so no nonce applies.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$classId = absint( Val::int( $_GET['class_id'] ?? 0 ) );
$current = null;
foreach ( $offerings as $offering ) {
if ( $offering->id === $classId ) {
$current = $offering;
break;
}
}
if ( null !== $current ) {
$baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
$class = $this->classDetail( $current, $enrollments );
$students = $this->studentOptions();
include USC_PLUGIN_DIR . 'templates/admin/my-group-class-detail.php';
return;
}
$classes = array_map(
fn( Offering $offering ): array => $this->classSummary( $offering, $enrollments ),
$offerings
);
$baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php';
}
/**
* Summary row for one class in the instructor overview: its identity, when it
* meets, and how many active enrolments it holds against capacity.
*
* @param list<Enrollment> $enrollments
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}
*/
private function classSummary( Offering $offering, array $enrollments ): array {
$enrolled = 0;
foreach ( $enrollments as $enrollment ) {
if ( $enrollment->offeringId === $offering->id && Enrollment::STATUS_ACTIVE === $enrollment->status ) {
++$enrolled;
}
}
return [
'id' => $offering->id,
'title' => $offering->title,
'when' => $this->whenLabel( $offering ),
'capacity' => $offering->capacity,
'enrolled' => $enrolled,
'invite_only' => $offering->isInviteOnly(),
];
}
/**
* Full details for one class: the summary fields, the class's own settings
* (instructor, price, duration, description, schedule, active state), the
* roster of enrolled students (with enrolment and payment status), and — for
* invite-only classes — the list of people invited but not yet enrolled.
*
* @param list<Enrollment> $enrollments
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
*/
private function classDetail( Offering $offering, array $enrollments ): array {
$roster = [];
foreach ( $enrollments as $enrollment ) {
if ( $enrollment->offeringId !== $offering->id ) {
continue;
}
$student = get_userdata( $enrollment->studentId );
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
$roster[] = [
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
'status' => $enrollment->status,
'payment' => $payment?->status,
];
}
return $this->classSummary( $offering, $enrollments ) + [
'instructor' => $this->instructorName( $offering ),
'price' => $offering->price,
'currency' => $offering->currency,
'duration' => $offering->durationMinutes,
'description' => $offering->description,
'schedule_note' => $offering->scheduleNote,
'active' => $offering->isActive,
'roster' => $roster,
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
];
}
/**
* The teaching instructor's display name — their real name or nickname, never
* the login. Falls back to the numeric id when the account is gone. See
* {@see UserName::format()}.
*/
private function instructorName( Offering $offering ): string {
$user = get_userdata( $offering->instructorId );
return UserName::format( $user instanceof \WP_User ? $user : null, $offering->instructorId );
}
/**
* Human-readable "when" label for a class: the class date (or weekly date
* range) and, when set, the start time. Empty when the class has no date.
*/
private function whenLabel( Offering $offering ): string {
if ( null === $offering->termStart ) {
return '';
}
$label = null === $offering->termEnd || $offering->termEnd === $offering->termStart
? (string) mysql2date( 'M j, Y', $offering->termStart )
: (string) mysql2date( 'M j, Y', $offering->termStart ) . ' ' . (string) mysql2date( 'M j, Y', $offering->termEnd );
if ( null !== $offering->classTime ) {
$label .= ' · ' . (string) mysql2date( 'g:i a', $offering->termStart . ' ' . $offering->classTime );
}
return $label;
}
/**
* Pending (not-yet-enrolled) access grants for an invite-only class, shown so
* the instructor can see who has been invited but has not enrolled yet.
*
* @return list<array{who: string, kind: string}>
*/
private function pendingInvites( int $offeringId ): array {
$out = [];
foreach ( $this->access->findByOffering( $offeringId ) as $grant ) {
if ( GroupAccess::STATUS_INVITED !== $grant->status ) {
continue;
}
if ( null !== $grant->studentId ) {
$user = get_userdata( $grant->studentId );
$out[] = [
'who' => $user ? $user->display_name : (string) $grant->studentId,
'kind' => __( 'Granted', 'unsupervised-schedular' ),
];
} else {
$out[] = [
'who' => $grant->email,
'kind' => __( 'Email invite', 'unsupervised-schedular' ),
];
}
}
return $out;
}
/**
* Handle a posted management action, returning a status notice for display.
* The action is scoped to a group class the current instructor owns, unless
* the caller is a studio admin (`view_all_lessons`) — who may manage any
* instructor's class, since the studio-admin Group Classes page reaches the
* same controls for every class.
*/
private function handleFormAction( int $instructorId ): string {
// Nonce is verified by the caller before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
$offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
$ownsOrManagesAll = null !== $offering
&& ( $offering->instructorId === $instructorId || current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) );
if ( null === $offering || ! $ownsOrManagesAll || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
return esc_html__( 'That group class was not found.', 'unsupervised-schedular' );
}
if ( 'add_direct' === $action ) {
return $this->addDirect( $offering, $this->postedStudentIds() );
}
if ( 'grant_access' === $action ) {
return $this->grantAccess( $offering, $this->postedStudentIds() );
}
if ( 'invite_email' === $action ) {
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
return $this->inviteEmail( $offering, $email );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
return '';
}
/**
* Directly enrol registered students, each with a pending payment at the
* class price (comp students are settled immediately by the payment service).
*
* @param list<int> $studentIds
*/
private function addDirect( Offering $offering, array $studentIds ): string {
$added = 0;
foreach ( $studentIds as $studentId ) {
if ( $this->enrollments->hasActiveEnrollment( (int) $offering->id, $studentId ) ) {
continue;
}
$enrollmentId = $this->enrollments->insert(
new Enrollment(
offeringId: (int) $offering->id,
studentId: $studentId,
instructorId: $offering->instructorId,
)
);
if ( $offering->price > 0.0 ) {
$payment = $this->paymentService->createForRegistration(
Payment::REG_ENROLLMENT,
$enrollmentId,
$studentId,
$offering->instructorId,
$offering->price,
$offering->currency,
$offering->etransferEmail
);
if ( null !== $payment && null !== $payment->id ) {
$this->enrollments->setPaymentId( $enrollmentId, $payment->id );
}
}
$this->access->markEnrolled( (int) $offering->id, $studentId );
++$added;
}
/* translators: %d: number of students added. */
return sprintf( esc_html__( '%d student(s) added to the class.', 'unsupervised-schedular' ), $added );
}
/**
* Grant registered students access to the class so it appears in their list
* for self-enrolment, notifying each by email.
*
* @param list<int> $studentIds
*/
private function grantAccess( Offering $offering, array $studentIds ): string {
$granted = 0;
foreach ( $studentIds as $studentId ) {
if (
$this->enrollments->hasActiveEnrollment( (int) $offering->id, $studentId )
|| $this->access->hasGrant( (int) $offering->id, $studentId )
) {
continue;
}
$this->access->insert(
new GroupAccess(
offeringId: (int) $offering->id,
studentId: $studentId,
status: GroupAccess::STATUS_INVITED,
invitedBy: get_current_user_id(),
)
);
$user = get_userdata( $studentId );
if ( $user instanceof \WP_User ) {
$this->mailer->sendClassAccessGranted( $user, $offering->title );
}
++$granted;
}
/* translators: %d: number of students granted access. */
return sprintf( esc_html__( '%d student(s) granted access.', 'unsupervised-schedular' ), $granted );
}
/**
* Invite someone by email. A registered address is treated as a grant; an
* unknown address gets a tokenised registration invite tied to the class,
* reusing any pending invite already outstanding for that address (in which
* case no new link is sent).
*/
private function inviteEmail( Offering $offering, string $email ): string {
if ( ! is_email( $email ) ) {
return esc_html__( 'Enter a valid email address.', 'unsupervised-schedular' );
}
$existingUserId = email_exists( $email );
if ( false !== $existingUserId ) {
return $this->grantAccess( $offering, [ (int) $existingUserId ] );
}
// Reuse an outstanding invite rather than mailing a second link; still
// attach a class grant so enrolment unlocks once they register.
$pending = $this->invites->findPendingByEmail( $email );
if ( null !== $pending ) {
$this->access->insert(
new GroupAccess(
offeringId: (int) $offering->id,
email: $email,
inviteId: $pending->id,
status: GroupAccess::STATUS_INVITED,
invitedBy: get_current_user_id(),
)
);
return esc_html__( 'This person already has a pending invitation; the class was added to it. No new link was sent.', 'unsupervised-schedular' );
}
$rawToken = wp_generate_password( 32, false );
$inviteId = $this->invites->insert(
new Invite(
email: $email,
token: Invite::hashToken( $rawToken ),
invitedBy: get_current_user_id(),
offeringId: (int) $offering->id,
)
);
if ( $inviteId <= 0 ) {
return esc_html__( 'Could not create the invite. Deactivate and reactivate the plugin to update the database, then try again.', 'unsupervised-schedular' );
}
$this->access->insert(
new GroupAccess(
offeringId: (int) $offering->id,
email: $email,
inviteId: $inviteId,
status: GroupAccess::STATUS_INVITED,
invitedBy: get_current_user_id(),
)
);
$this->mailer->sendClassInvite( $email, $this->registrationLink( $rawToken ), $offering->title );
return esc_html__( 'Invitation sent.', 'unsupervised-schedular' );
}
/**
* Registered students to offer in the add/grant selects, by display name.
*
* @return list<array{id: int, name: string}>
*/
private function studentOptions(): array {
$users = array_filter(
get_users(
[
'role' => RoleManager::STUDENT,
'orderby' => 'display_name',
'order' => 'ASC',
]
),
static fn( mixed $u ): bool => $u instanceof \WP_User
);
return array_values(
array_map(
static fn( \WP_User $u ): array => [
'id' => (int) $u->ID,
'name' => '' !== (string) $u->display_name ? (string) $u->display_name : (string) $u->user_email,
],
$users
)
);
}
/**
* The de-duplicated positive student ids posted from a multi-select.
*
* @return list<int>
*/
private function postedStudentIds(): array {
// Nonce is verified by the caller before this method runs.
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int below; slashes cannot survive integer coercion.
$raw = (array) ( $_POST['student_ids'] ?? [] );
$ids = array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $raw ) );
return array_values( array_unique( $ids ) );
}
/**
* Build the registration URL for a raw invite token, mirroring the invites
* admin page so class invites land on the same registration page.
*/
private function registrationLink( string $rawToken ): string {
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
$linkBase = $pageId > 0 ? (string) get_permalink( $pageId ) : '';
return add_query_arg( 'us_invite', rawurlencode( $rawToken ), '' !== $linkBase ? $linkBase : home_url( '/' ) );
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
/**
* Keeps an instructor's open availability out of the way of the group classes
* they teach. When a group class is scheduled (an assigned instructor plus a
* date, time, and duration), each session occupies the instructor: any open
* private-booking slot that overlaps a session is removed so students cannot
* book the instructor at the class time, and any already-booked slot that
* overlaps is reported as a conflict for the studio to resolve by hand — a
* booked lesson is never silently deleted.
*/
class ClassSlotReconciler {
public function __construct( private AvailabilityRepository $availability ) {}
/**
* Reconcile the assigned instructor's availability with the class schedule.
*
* @return array{removed: int, conflicts: list<string>} The number of open
* slots cleared, and the start datetime (`Y-m-d H:i:s`) of each booked
* slot that still clashes with a session.
*/
public function reconcile( Offering $offering ): array {
if ( Offering::KIND_GROUP_CLASS !== $offering->kind || $offering->instructorId <= 0 ) {
return [
'removed' => 0,
'conflicts' => [],
];
}
$removed = 0;
$conflicts = [];
foreach ( $offering->sessionWindows() as $window ) {
foreach ( $this->availability->findOverlapping( $offering->instructorId, $window['start'], $window['end'] ) as $slot ) {
if ( $slot->isBooked ) {
$conflicts[] = $slot->startDt;
continue;
}
if ( null !== $slot->id && $this->availability->delete( $slot->id ) ) {
++$removed;
}
}
}
return [
'removed' => $removed,
'conflicts' => $conflicts,
];
}
}
+93
View File
@@ -27,6 +27,19 @@ class Offering {
*/
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ];
/** Listed in the public catalogue; anyone with `book_lesson` may enrol. */
public const ACCESS_PUBLIC = 'public';
/** Hidden from the catalogue; only invited/added students may enrol (group classes). */
public const ACCESS_INVITE_ONLY = 'invite_only';
/**
* All valid access modes.
*
* @var list<string>
*/
public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ];
public function __construct(
public readonly int $instructorId,
public readonly string $kind,
@@ -40,12 +53,23 @@ class Offering {
public readonly ?int $capacity = null,
public readonly ?string $termStart = null,
public readonly ?string $termEnd = null,
public readonly ?string $classTime = null,
public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = null,
public readonly ?int $cancellationCutoffHours = null,
public readonly string $accessMode = self::ACCESS_PUBLIC,
public readonly bool $isActive = true,
public readonly ?int $id = null,
) {}
/**
* Whether the offering is hidden from the public catalogue and reachable
* only by invited or directly-added students.
*/
public function isInviteOnly(): bool {
return self::ACCESS_INVITE_ONLY === $this->accessMode;
}
/**
* Normalise a submitted term date to canonical `Y-m-d`, or null when it is
* not a real calendar date. Round-trips through DateTimeImmutable so
@@ -67,6 +91,69 @@ class Offering {
return ( new \DateTimeImmutable( $termStart ) )->modify( '+' . ( 7 * $weeks ) . ' days' )->format( 'Y-m-d' );
}
/**
* Normalise a submitted time-of-day to canonical `H:i:s`, or null when it is
* not a real time. Accepts the HTML `time` form (`H:i`, optionally with
* seconds); anything else is rejected so garbage never reaches the TIME column.
*/
public static function normalizeTime( string $value ): ?string {
foreach ( [ 'H:i:s', 'H:i' ] as $format ) {
$time = \DateTimeImmutable::createFromFormat( '!' . $format, $value );
if ( false !== $time && $time->format( $format ) === $value ) {
return $time->format( 'H:i:s' );
}
}
return null;
}
/**
* The concrete start/end datetimes of every session of this group class,
* derived from the class date(s), the class time, and the duration. A weekly
* class yields one window per week from `term_start` through `term_end`; a
* one-off class yields a single window. Returns an empty list unless the
* schedule is fully specified (date, time, and a positive duration), so it can
* never fabricate a session window from partial data.
*
* @return list<array{start: string, end: string}>
*/
public function sessionWindows(): array {
if (
null === $this->termStart
|| null === $this->classTime
|| null === $this->durationMinutes
|| $this->durationMinutes <= 0
) {
return [];
}
$first = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $this->termStart . ' ' . $this->classTime );
if ( false === $first ) {
return [];
}
$lastDay = null !== $this->termEnd ? $this->termEnd : $this->termStart;
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
$windows = [];
$cursor = $first;
$cursorDay = $cursor->format( 'Y-m-d' );
// Cap the walk at ten years of weeks so a term_end before term_start (or a
// bad value) can never spin into an unbounded loop.
for ( $i = 0; $i < 520 && $cursorDay <= $lastDay; $i++ ) {
$windows[] = [
'start' => $cursor->format( 'Y-m-d H:i:s' ),
'end' => $cursor->add( $step )->format( 'Y-m-d H:i:s' ),
];
$cursor = $cursor->modify( '+7 days' );
$cursorDay = $cursor->format( 'Y-m-d' );
}
return $windows;
}
public static function fromRow( \stdClass $row ): self {
return new self(
instructorId: Val::int( $row->instructor_id ),
@@ -81,8 +168,11 @@ class Offering {
capacity: Val::intOrNull( $row->capacity ),
termStart: Val::stringOrNull( $row->term_start ),
termEnd: Val::stringOrNull( $row->term_end ),
classTime: Val::stringOrNull( $row->class_time ?? null ),
scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
accessMode: '' !== Val::string( $row->access_mode ?? '' ) ? Val::string( $row->access_mode ) : self::ACCESS_PUBLIC,
isActive: Val::bool( $row->is_active ),
id: Val::int( $row->id ),
);
@@ -112,7 +202,10 @@ class Offering {
'capacity' => $this->capacity,
'term_start' => $this->termStart,
'term_end' => $this->termEnd,
'class_time' => $this->classTime,
'schedule_note' => $this->scheduleNote,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'access_mode' => $this->accessMode,
'is_active' => $this->isActive,
];
+127 -9
View File
@@ -8,7 +8,10 @@ use Unsupervised\Schedular\Val;
class OfferingController {
public function __construct( private OfferingRepository $repository ) {}
public function __construct(
private OfferingRepository $repository,
private ClassSlotReconciler $reconciler,
) {}
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) ) {
@@ -18,10 +21,15 @@ class OfferingController {
$instructorId = get_current_user_id();
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_offering_action' ) ) {
$this->handleFormAction( $instructorId, $manageAll );
$notice = $this->handleFormAction( $instructorId, $manageAll );
}
// Studio admins may assign any instructor to a class; a plain instructor
// only ever creates classes for themselves, so the picker is theirs alone.
$instructors = $manageAll ? $this->instructorOptions() : [];
// View-state query param only (which offering the form is editing) —
// nothing is mutated from it, so no nonce applies.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
@@ -43,15 +51,22 @@ class OfferingController {
include USC_PLUGIN_DIR . 'templates/admin/offerings.php';
}
private function handleFormAction( int $instructorId, bool $manageAll ): void {
/**
* Process the posted add/update/delete action, returning a status notice for
* display (e.g. how many booking slots a scheduled class cleared, or that a
* booked lesson clashes with it). An empty string means nothing to report.
*/
private function handleFormAction( int $instructorId, bool $manageAll ): string {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) {
$offering = $this->offeringFromPost( $instructorId );
$offering = $this->offeringFromPost( $instructorId, $manageAll );
if ( null !== $offering ) {
$this->repository->insert( $offering );
return $this->reconcileNotice( $offering );
}
}
@@ -60,9 +75,11 @@ class OfferingController {
if ( $offeringId > 0 ) {
$existing = $this->repository->findById( $offeringId );
if ( $existing && ( $manageAll || $existing->instructorId === $instructorId ) ) {
$offering = $this->offeringFromPost( $instructorId, $existing );
$offering = $this->offeringFromPost( $instructorId, $manageAll, $existing );
if ( null !== $offering ) {
$this->repository->update( $offeringId, $offering );
return $this->reconcileNotice( $offering );
}
}
}
@@ -78,15 +95,86 @@ class OfferingController {
}
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
return '';
}
/**
* Clear the assigned instructor's open booking slots that collide with a
* scheduled group class and describe the result, warning about any booked
* lesson that clashes (which the studio must resolve by hand).
*/
private function reconcileNotice( Offering $offering ): string {
if ( Offering::KIND_GROUP_CLASS !== $offering->kind ) {
return '';
}
$result = $this->reconciler->reconcile( $offering );
$parts = [];
if ( $result['removed'] > 0 ) {
$parts[] = sprintf(
/* translators: %d: number of open booking slots removed. */
_n(
'%d open booking slot was removed to hold the class time.',
'%d open booking slots were removed to hold the class time.',
$result['removed'],
'unsupervised-schedular'
),
$result['removed']
);
}
foreach ( $result['conflicts'] as $startDt ) {
$parts[] = sprintf(
/* translators: %s: date and time of the already-booked lesson that clashes. */
esc_html__( 'Conflict: a lesson is already booked at %s during this class.', 'unsupervised-schedular' ),
(string) mysql2date( 'M j, Y g:i a', $startDt )
);
}
return implode( ' ', $parts );
}
/**
* Registered instructors offered in the assignment select, by display name.
*
* @return list<array{id: int, name: string}>
*/
private function instructorOptions(): array {
$users = array_filter(
get_users(
[
'role' => RoleManager::INSTRUCTOR,
'orderby' => 'display_name',
'order' => 'ASC',
]
),
static fn( mixed $u ): bool => $u instanceof \WP_User
);
return array_values(
array_map(
static fn( \WP_User $u ): array => [
'id' => (int) $u->ID,
'name' => '' !== (string) $u->display_name ? (string) $u->display_name : (string) $u->user_email,
],
$users
)
);
}
/**
* Build an offering from the submitted add/edit form, or null when the
* submission is invalid. When `$existing` is given the result is an edit:
* it keeps the existing id, owner, and currency so an update can never
* reassign an offering to whoever happens to submit the form.
* it keeps the existing id and currency so an update can never rewrite those
* from whoever submits the form.
*
* The owning instructor normally stays fixed (the creator on add, the existing
* owner on edit). A studio admin (`$manageAll`) may instead assign the class to
* any instructor via the picker; a blank or absent choice keeps the default.
*/
private function offeringFromPost( int $instructorId, ?Offering $existing = null ): ?Offering {
private function offeringFromPost( int $instructorId, bool $manageAll, ?Offering $existing = null ): ?Offering {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) );
@@ -104,6 +192,11 @@ class OfferingController {
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
$capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) );
// A blank cutoff means "use the studio default" (null); any entered value
// (including 0 — cancel any time) is a per-offering override.
$cutoffRaw = trim( sanitize_text_field( Val::string( wp_unslash( $_POST['cancellation_cutoff_hours'] ?? '' ) ) ) );
$cutoffHours = '' === $cutoffRaw ? null : absint( Val::int( $cutoffRaw ) );
// Term dates: a class either meets once (term ends the day it starts)
// or repeats weekly for a set number of sessions.
$termStart = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['term_start'] ?? '' ) ) ) );
@@ -114,8 +207,10 @@ class OfferingController {
$termEnd = 'weekly' === $recurrence ? Offering::weeklyTermEnd( $termStart, $sessions ) : $termStart;
}
$classTime = Offering::normalizeTime( sanitize_text_field( Val::string( wp_unslash( $_POST['class_time'] ?? '' ) ) ) );
return new Offering(
instructorId: null !== $existing ? $existing->instructorId : $instructorId,
instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
kind: $kind,
title: $title,
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ),
@@ -127,14 +222,37 @@ class OfferingController {
capacity: $capacity > 0 ? $capacity : null,
termStart: $termStart,
termEnd: $termEnd,
classTime: $classTime,
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
cancellationCutoffHours: $cutoffHours,
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
isActive: isset( $_POST['is_active'] ),
id: $existing?->id,
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* The instructor the offering should belong to. A studio admin may reassign it
* via the posted `class_instructor_id`; otherwise it stays with the existing
* owner (edit) or the current user (add). A plain instructor can never change
* the owner, so the posted value is ignored unless `$manageAll` is set.
*/
private function resolveInstructorId( int $instructorId, bool $manageAll, ?Offering $existing ): int {
$fallback = null !== $existing ? $existing->instructorId : $instructorId;
if ( ! $manageAll ) {
return $fallback;
}
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$posted = absint( Val::int( $_POST['class_instructor_id'] ?? 0 ) );
return $posted > 0 ? $posted : $fallback;
}
private function nullableText( string $value ): ?string {
return '' === $value ? null : $value;
}
+78 -8
View File
@@ -4,11 +4,16 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Val;
class OfferingEndpoint {
public function __construct( private OfferingRepository $repository ) {}
public function __construct(
private OfferingRepository $repository,
private GroupAccessRepository $access,
) {}
/**
* Registers this endpoint's REST routes.
@@ -62,14 +67,69 @@ class OfferingEndpoint {
}
public function index( \WP_REST_Request $request ): \WP_REST_Response {
$offerings = $this->repository->findAll(
Val::int( $request->get_param( 'instructor_id' ) ),
Val::string( $request->get_param( 'kind' ) ),
activeOnly: true,
);
$instructorId = Val::int( $request->get_param( 'instructor_id' ) );
$kind = Val::string( $request->get_param( 'kind' ) );
// Public listing: omit the private e-transfer destination email.
return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray( includeEtransferEmail: false ), $offerings ), 200 );
// The public catalogue is public offerings only; invite-only classes are
// hidden from it and surfaced separately to the students granted access.
$offerings = $this->repository->findAll( $instructorId, $kind, activeOnly: true, accessMode: Offering::ACCESS_PUBLIC );
foreach ( $this->grantedInviteOnly( $instructorId, $kind ) as $granted ) {
$offerings[] = $granted;
}
// Public listing: omit the private e-transfer destination email, and
// attach the assigned instructor's display name so the front end can show
// students who teaches each class.
return new \WP_REST_Response( array_map( [ $this, 'present' ], $offerings ), 200 );
}
/**
* A public-facing offering array with the assigned instructor's name added —
* their real name or nickname, never the login (empty when the instructor
* account no longer exists). See {@see UserName::format()}.
*
* @return array<string, mixed>
*/
private function present( Offering $offering ): array {
$out = $offering->toArray( includeEtransferEmail: false );
$user = get_userdata( $offering->instructorId );
$out['instructor_name'] = UserName::format( $user instanceof \WP_User ? $user : null );
return $out;
}
/**
* The active invite-only offerings the caller has been granted access to,
* matching the same instructor/kind filters as the public catalogue.
*
* @return list<Offering>
*/
private function grantedInviteOnly( int $instructorId, string $kind ): array {
$grantedIds = $this->access->findGrantedOfferingIds( get_current_user_id() );
if ( [] === $grantedIds ) {
return [];
}
$out = [];
foreach ( $grantedIds as $offeringId ) {
$offering = $this->repository->findById( $offeringId );
if (
null === $offering
|| ! $offering->isActive
|| ! $offering->isInviteOnly()
|| ( $instructorId > 0 && $offering->instructorId !== $instructorId )
|| ( '' !== $kind && $offering->kind !== $kind )
) {
continue;
}
$out[] = $offering;
}
return $out;
}
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
@@ -103,6 +163,8 @@ class OfferingEndpoint {
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
);
@@ -148,6 +210,8 @@ class OfferingEndpoint {
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote,
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail,
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
accessMode: $request->has_param( 'access_mode' ) ? $this->accessMode( $request->get_param( 'access_mode' ), $existing->accessMode ) : $existing->accessMode,
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
id: $id,
);
@@ -210,6 +274,12 @@ class OfferingEndpoint {
return '' !== $email ? $email : null;
}
private function accessMode( mixed $value, string $fallback ): string {
$mode = Val::string( $value );
return in_array( $mode, Offering::VALID_ACCESS_MODES, true ) ? $mode : $fallback;
}
private function nullableInt( mixed $value ): ?int {
return ( null === $value || '' === $value ) ? null : Val::int( $value );
}
+15 -4
View File
@@ -14,11 +14,12 @@ class OfferingRepository {
/**
* Column formats aligned to {@see columns()} (instructor_id, kind, title,
* description, duration_minutes, price, currency, billing_mode, allow_weekly,
* capacity, term_start, term_end, schedule_note, etransfer_email, is_active).
* capacity, term_start, term_end, class_time, schedule_note, etransfer_email,
* cancellation_cutoff_hours, access_mode, is_active).
*
* @var list<string>
*/
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%d' ];
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
public function insert( Offering $offering ): int {
$this->db->insert(
@@ -59,18 +60,23 @@ class OfferingRepository {
'capacity' => $offering->capacity,
'term_start' => $offering->termStart,
'term_end' => $offering->termEnd,
'class_time' => $offering->classTime,
'schedule_note' => $offering->scheduleNote,
'etransfer_email' => $offering->etransferEmail,
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
'access_mode' => $offering->accessMode,
'is_active' => $offering->isActive ? 1 : 0,
];
}
/**
* Find offerings, optionally filtered by instructor, kind, and active state.
* Find offerings, optionally filtered by instructor, kind, active state, and
* access mode (e.g. `Offering::ACCESS_PUBLIC` to exclude invite-only classes
* from the public catalogue).
*
* @return list<Offering>
*/
public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null ): array {
public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null, ?string $accessMode = null ): array {
$where = [ '1 = 1' ];
$params = [];
@@ -89,6 +95,11 @@ class OfferingRepository {
$params[] = $activeOnly ? 1 : 0;
}
if ( null !== $accessMode ) {
$where[] = 'access_mode = %s';
$params[] = $accessMode;
}
$whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY title ASC";
+24
View File
@@ -16,6 +16,14 @@ class StudioSettings {
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
public const OPT_HST_RATE = 'us_hst_rate';
/**
* Studio-default cancellation cutoff, stored in hours. A student may not
* cancel a lesson once it starts within this many hours. Displayed to the
* admin in days; an offering may override it with its own hour value.
*/
public const OPT_CANCELLATION_CUTOFF_HOURS = 'us_cancellation_cutoff_hours';
public const DEFAULT_CANCELLATION_CUTOFF_HOURS = 24;
public const OPT_REGISTRATION_MODE = 'us_registration_mode';
public const MODE_INVITE = 'invite';
public const MODE_SELF_APPROVAL = 'self_approval';
@@ -69,6 +77,15 @@ class StudioSettings {
return max( 0.0, Val::float( get_option( self::OPT_HST_RATE, 0 ) ) );
}
/**
* The studio-default cancellation cutoff in hours: a student cannot cancel a
* lesson once it starts within this window. 0 means students may cancel any
* time. Offerings without their own override inherit this value.
*/
public function cancellationCutoffHours(): int {
return max( 0, Val::int( get_option( self::OPT_CANCELLATION_CUTOFF_HOURS, self::DEFAULT_CANCELLATION_CUTOFF_HOURS ) ) );
}
/**
* Whether Stripe is configured. When false the platform falls back to
* e-transfer billing and card processing is unavailable.
@@ -117,6 +134,8 @@ class StudioSettings {
$hstRate = $this->hstRate();
$stripeConfigured = $this->isStripeConfigured();
$openRegistration = $this->openRegistrationEnabled();
// Stored in hours, surfaced to the admin in whole days.
$cancellationCutoffDays = (int) round( $this->cancellationCutoffHours() / 24 );
include USC_PLUGIN_DIR . 'templates/admin/settings.php';
}
@@ -143,6 +162,11 @@ class StudioSettings {
$hstRate = isset( $_POST['hst_rate'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0;
update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) );
// The cutoff is entered in whole days but stored in hours.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::int() coerces to int; slashes cannot survive numeric coercion.
$cutoffDays = isset( $_POST['cancellation_cutoff_days'] ) ? max( 0, Val::int( $_POST['cancellation_cutoff_days'] ) ) : 0;
update_option( self::OPT_CANCELLATION_CUTOFF_HOURS, $cutoffDays * 24 );
$this->applyRegistrationMode( isset( $_POST['open_registration'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
+5 -3
View File
@@ -14,6 +14,7 @@ use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
@@ -58,6 +59,7 @@ class Plugin {
$acceptances = new AcceptanceRepository( $wpdb );
$invites = new InviteRepository( $wpdb );
$enrollments = new EnrollmentRepository( $wpdb );
$groupAccess = new GroupAccessRepository( $wpdb );
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
$paymentRepo = new PaymentRepository( $wpdb );
@@ -72,15 +74,15 @@ class Plugin {
$bookingPage = new BookingPage();
$loginPage = new LoginPage();
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer );
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
$groupClassPage = new GroupClassPage();
( new UpdateChecker() )->register();
( new RoleManager() )->register();
( new RegistrationLoginGate() )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
}
+4 -2
View File
@@ -9,13 +9,15 @@ class Answer {
public const REG_LESSON = 'lesson';
public const REG_ENROLLMENT = 'enrollment';
public const REG_ACCOUNT = 'account';
/**
* Polymorphic registration targets an answer can attach to.
* Polymorphic registration targets an answer can attach to. `account` is used
* by studio-wide questions answered at signup (registration_id = the user ID).
*
* @var list<string>
*/
public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT ];
public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT, self::REG_ACCOUNT ];
public function __construct(
public readonly int $questionId,
+22 -2
View File
@@ -12,6 +12,12 @@ class Question {
public const FIELD_SELECT = 'select';
public const FIELD_CHECKBOX = 'checkbox';
/** Question is scoped to a single offering, asked at booking/enrolment time. */
public const SCOPE_OFFERING = 'offering';
/** Question is studio-wide, asked once at account signup (no offering). */
public const SCOPE_ACCOUNT = 'account';
/**
* All valid field types.
*
@@ -24,19 +30,31 @@ class Question {
self::FIELD_CHECKBOX,
];
/**
* All valid scopes.
*
* @var list<string>
*/
public const VALID_SCOPES = [
self::SCOPE_OFFERING,
self::SCOPE_ACCOUNT,
];
/**
* Build an intake question value object.
*
* @param int|null $offeringId The owning offering, or null for account-scoped questions.
* @param list<string>|null $options Choices for a `select` field.
*/
public function __construct(
public readonly int $offeringId,
public readonly ?int $offeringId,
public readonly string $label,
public readonly string $fieldType = self::FIELD_TEXT,
public readonly ?array $options = null,
public readonly bool $isRequired = false,
public readonly int $sortOrder = 0,
public readonly bool $isActive = true,
public readonly string $scope = self::SCOPE_OFFERING,
public readonly ?int $id = null,
) {}
@@ -50,13 +68,14 @@ class Question {
}
return new self(
offeringId: Val::int( $row->offering_id ),
offeringId: Val::intOrNull( $row->offering_id ),
label: Val::string( $row->label ),
fieldType: Val::string( $row->field_type ),
options: $options,
isRequired: Val::bool( $row->is_required ),
sortOrder: Val::int( $row->sort_order ),
isActive: Val::bool( $row->is_active ),
scope: Val::string( $row->scope ),
id: Val::int( $row->id ),
);
}
@@ -70,6 +89,7 @@ class Question {
return [
'id' => $this->id,
'offering_id' => $this->offeringId,
'scope' => $this->scope,
'label' => $this->label,
'field_type' => $this->fieldType,
'options' => $this->options,
+36 -8
View File
@@ -23,8 +23,13 @@ class QuestionController {
$userId = get_current_user_id();
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only offering selector.
$offeringId = absint( Val::int( $_GET['offering_id'] ?? 0 ) );
// The selector posts either an offering id or the sentinel `account`.
// Account-signup questions are studio-wide, so only studio admins manage them.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only selector.
$selection = sanitize_text_field( Val::string( wp_unslash( $_GET['offering_id'] ?? '' ) ) );
$accountScope = $manageAll && Question::SCOPE_ACCOUNT === $selection;
$offeringId = $accountScope ? 0 : absint( Val::int( $selection ) );
$offeringList = $manageAll ? $this->offerings->findAll() : $this->offerings->findAll( $userId );
$selectedOffering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
@@ -33,7 +38,13 @@ class QuestionController {
}
$questions = null;
if ( null !== $selectedOffering ) {
if ( $accountScope ) {
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_question_action' ) ) {
$this->handleFormAction( null );
}
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT );
} elseif ( null !== $selectedOffering ) {
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_question_action' ) ) {
$this->handleFormAction( $selectedOffering );
}
@@ -44,20 +55,24 @@ class QuestionController {
include USC_PLUGIN_DIR . 'templates/admin/questions.php';
}
private function handleFormAction( Offering $offering ): void {
/**
* Handle an add/delete action for the given context: an offering, or account
* scope when $offering is null.
*/
private function handleFormAction( ?Offering $offering ): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) {
$this->addQuestion( (int) $offering->id );
$this->addQuestion( $offering );
}
if ( 'delete' === $action ) {
$questionId = absint( Val::int( $_POST['question_id'] ?? 0 ) );
if ( $questionId > 0 ) {
$question = $this->questions->findById( $questionId );
if ( $question && $question->offeringId === (int) $offering->id ) {
if ( $question && $this->belongsToContext( $question, $offering ) ) {
$this->questions->delete( $questionId );
}
}
@@ -65,7 +80,7 @@ class QuestionController {
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
private function addQuestion( int $offeringId ): void {
private function addQuestion( ?Offering $offering ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) );
$fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) );
@@ -76,17 +91,30 @@ class QuestionController {
$this->questions->insert(
new Question(
offeringId: $offeringId,
offeringId: null === $offering ? null : (int) $offering->id,
label: $label,
fieldType: $fieldType,
options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ),
isRequired: isset( $_POST['is_required'] ),
sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ),
scope: null === $offering ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING,
)
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* Whether a question belongs to the current editing context — the given
* offering, or account scope when $offering is null.
*/
private function belongsToContext( Question $question, ?Offering $offering ): bool {
if ( null === $offering ) {
return Question::SCOPE_ACCOUNT === $question->scope;
}
return $question->offeringId === (int) $offering->id;
}
private function canManageOffering( Offering $offering, int $userId, bool $manageAll ): bool {
return $manageAll || $offering->instructorId === $userId;
}
+8 -1
View File
@@ -126,6 +126,7 @@ class QuestionEndpoint {
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired,
sortOrder: $request->has_param( 'sort_order' ) ? Val::int( $request->get_param( 'sort_order' ) ) : $existing->sortOrder,
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
scope: $existing->scope,
id: $id,
);
@@ -167,8 +168,14 @@ class QuestionEndpoint {
/**
* Ensure the offering exists and the caller owns it (or is a studio admin).
* Account-scoped questions have no offering and are not managed over REST, so
* a null offering id is rejected as not found.
*/
private function requireOfferingOwner( int $offeringId ): ?\WP_Error {
private function requireOfferingOwner( ?int $offeringId ): ?\WP_Error {
if ( null === $offeringId ) {
return new \WP_Error( 'not_found', __( 'Question not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering ) {
+24 -2
View File
@@ -15,7 +15,7 @@ class QuestionRepository {
$this->db->insert(
$this->table,
$this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ],
[ '%d', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ]
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ]
);
return $this->db->insert_id;
@@ -26,7 +26,7 @@ class QuestionRepository {
$this->table,
$this->columns( $question ),
[ 'id' => $id ],
[ '%d', '%s', '%s', '%s', '%d', '%d', '%d' ],
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d' ],
[ '%d' ]
);
}
@@ -39,6 +39,7 @@ class QuestionRepository {
private function columns( Question $question ): array {
return [
'offering_id' => $question->offeringId,
'scope' => $question->scope,
'label' => $question->label,
'field_type' => $question->fieldType,
'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ),
@@ -69,6 +70,27 @@ class QuestionRepository {
return array_map( Question::fromRow( ... ), $rows ?? [] );
}
/**
* Find questions for a scope (e.g. account-signup), ordered for display.
*
* @return list<Question>
*/
public function findByScope( string $scope, bool $activeOnly = false ): array {
$sql = 'SELECT * FROM %i WHERE scope = %s';
$params = [ $this->table, $scope ];
if ( $activeOnly ) {
$sql .= ' AND is_active = %d';
$params[] = 1;
}
$sql .= ' ORDER BY sort_order ASC, id ASC';
$rows = $this->db->get_results( $this->db->prepare( $sql, $params ) );
return array_map( Question::fromRow( ... ), $rows ?? [] );
}
public function findById( int $id ): ?Question {
$row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
+7 -4
View File
@@ -7,12 +7,15 @@ use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\PaymentEndpoint;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\PolicyEndpoint;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyService;
@@ -33,13 +36,13 @@ class RestRegistrar {
private EnrollmentEndpoint $enrollmentEndpoint;
private PaymentEndpoint $paymentEndpoint;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, PaymentService $paymentService ) {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, $offerings );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService );
$this->offeringEndpoint = new OfferingEndpoint( $offerings );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) );
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService );
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess );
$this->paymentEndpoint = new PaymentEndpoint( $paymentService );
}
+23 -1
View File
@@ -63,8 +63,11 @@ class Schema {
capacity SMALLINT UNSIGNED DEFAULT NULL,
term_start DATE DEFAULT NULL,
term_end DATE DEFAULT NULL,
class_time TIME DEFAULT NULL,
schedule_note VARCHAR(191) DEFAULT NULL,
etransfer_email VARCHAR(191) DEFAULT NULL,
cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL,
access_mode VARCHAR(20) NOT NULL DEFAULT 'public',
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
@@ -75,7 +78,8 @@ class Schema {
"CREATE TABLE {$prefix}us_questions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
offering_id BIGINT UNSIGNED NOT NULL,
offering_id BIGINT UNSIGNED DEFAULT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'offering',
label VARCHAR(255) NOT NULL,
field_type VARCHAR(20) NOT NULL DEFAULT 'text',
options TEXT,
@@ -85,6 +89,7 @@ class Schema {
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY offering_id (offering_id),
KEY scope (scope),
KEY is_active (is_active)
) {$charset};",
@@ -187,6 +192,7 @@ class Schema {
token VARCHAR(64) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'us_student',
kind VARCHAR(10) NOT NULL DEFAULT 'personal',
offering_id BIGINT UNSIGNED DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
invited_by BIGINT UNSIGNED DEFAULT NULL,
accepted_user_id BIGINT UNSIGNED DEFAULT NULL,
@@ -198,6 +204,22 @@ class Schema {
KEY email (email),
KEY status (status)
) {$charset};",
"CREATE TABLE {$prefix}us_group_access (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
offering_id BIGINT UNSIGNED NOT NULL,
student_id BIGINT UNSIGNED DEFAULT NULL,
email VARCHAR(191) NOT NULL DEFAULT '',
invite_id BIGINT UNSIGNED DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'invited',
invited_by BIGINT UNSIGNED DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY offering_id (offering_id),
KEY student_id (student_id),
KEY email (email),
KEY status (status)
) {$charset};",
];
}
}
+3
View File
@@ -69,5 +69,8 @@ class ShortcodeRegistrar {
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
// Progressive enhancement for the two-step registration form (no dependencies).
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
}
}
+37
View File
@@ -35,6 +35,43 @@ class UpdateChecker {
public function register(): void {
add_filter( 'update_plugins_' . self::HOSTNAME, [ $this, 'provideUpdate' ], 10, 3 );
add_filter( 'plugin_row_meta', [ $this, 'filterRowMeta' ], 10, 2 );
}
/**
* Replace core's "View details" link on the Plugins screen.
*
* Core points that link at the WordPress.org plugin-information API
* (`plugin-install.php?tab=plugin-information&plugin=…`), which 404s in a
* "Plugin not found" iframe for this off-directory plugin. We swap it for a
* direct link to the matching Gitea release tag page, opened in a new tab —
* embedding Gitea in the thickbox iframe would be blocked by its
* `X-Frame-Options: SAMEORIGIN` anyway.
*
* @param mixed $meta Row-meta links for the plugin.
* @param mixed $plugin_file Plugin file the meta belongs to.
* @return mixed The (possibly modified) row-meta array.
*/
public function filterRowMeta( mixed $meta, mixed $plugin_file ): mixed {
if ( ! is_array( $meta ) || plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) {
return $meta;
}
// Drop core's WordPress.org "View details" thickbox link.
$meta = array_values(
array_filter(
$meta,
static fn( $item ): bool => ! ( is_string( $item ) && str_contains( $item, 'open-plugin-details-modal' ) )
)
);
$meta[] = sprintf(
'<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
esc_url( self::REPO_URL . '/releases/tag/v' . USC_VERSION ),
esc_html__( 'View details', 'unsupervised-schedular' )
);
return $meta;
}
/**
+35 -8
View File
@@ -5,29 +5,56 @@ if (! defined('ABSPATH')) {
exit;
}
/** @var list<array{student: string, offering: string, status: string}> $rows */
/**
* @var list<array{id: int|null, title: string, instructor: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}> $rows
* @var string $notice
* @var string $baseUrl
*/
?>
<div class="wrap">
<h1><?php esc_html_e('Group Classes', 'unsupervised-schedular'); ?></h1>
<p class="description"><?php esc_html_e('Active enrolments across all group classes.', 'unsupervised-schedular'); ?></p>
<p class="description"><?php esc_html_e('Every group class across instructors. Select one for its details, roster, and — for invite-only classes — to invite or add students.', 'unsupervised-schedular'); ?></p>
<?php if ('' !== $notice) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<?php if (empty($rows)) : ?>
<p><?php esc_html_e('No active enrolments.', 'unsupervised-schedular'); ?></p>
<p><?php esc_html_e('No group classes.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('When', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Enrolled', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row) : ?>
<tr>
<td><?php echo esc_html($row['student']); ?></td>
<td><?php echo esc_html($row['offering']); ?></td>
<td><?php echo esc_html($row['status']); ?></td>
<td>
<?php echo esc_html($row['title']); ?>
<?php if ($row['invite_only']) : ?>
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
<?php endif; ?>
</td>
<td><?php echo esc_html($row['instructor']); ?></td>
<td><?php echo '' !== $row['when'] ? esc_html($row['when']) : '&mdash;'; ?></td>
<td>
<?php
if (null === $row['capacity']) {
echo esc_html((string) $row['enrolled']);
} else {
echo esc_html($row['enrolled'] . ' / ' . $row['capacity']);
}
?>
</td>
<td>
<a class="button button-small" href="<?php echo esc_url(add_query_arg('class_id', (int) $row['id'], $baseUrl)); ?>"><?php echo $row['invite_only'] ? esc_html__('View & invite', 'unsupervised-schedular') : esc_html__('View details', 'unsupervised-schedular'); ?></a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
+189
View File
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/**
* @var array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>} $class
* @var list<array{id: int, name: string}> $students
* @var string $notice
* @var string $baseUrl
*/
?>
<div class="wrap">
<h1>
<?php echo esc_html($class['title']); ?>
<?php if ($class['invite_only']) : ?>
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
<?php endif; ?>
</h1>
<p>
<a href="<?php echo esc_url($baseUrl); ?>">&larr; <?php esc_html_e('Back to my group classes', 'unsupervised-schedular'); ?></a>
</p>
<?php if ('' !== $notice) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<h2><?php esc_html_e('Class details', 'unsupervised-schedular'); ?></h2>
<table class="widefat striped" style="max-width:40em;">
<tbody>
<tr>
<th scope="row"><?php esc_html_e('When', 'unsupervised-schedular'); ?></th>
<td><?php echo '' !== $class['when'] ? esc_html($class['when']) : '&mdash;'; ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($class['instructor']); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Enrolled', 'unsupervised-schedular'); ?></th>
<td>
<?php
if (null === $class['capacity']) {
printf(
/* translators: %d: number of enrolled students. */
esc_html__('%d enrolled', 'unsupervised-schedular'),
(int) $class['enrolled']
);
} else {
printf(
/* translators: 1: number of enrolled students, 2: class capacity. */
esc_html__('%1$d / %2$d enrolled', 'unsupervised-schedular'),
(int) $class['enrolled'],
(int) $class['capacity']
);
}
?>
</td>
</tr>
<?php if (null !== $class['duration']) : ?>
<tr>
<th scope="row"><?php esc_html_e('Duration', 'unsupervised-schedular'); ?></th>
<td>
<?php
printf(
/* translators: %d: session length in minutes. */
esc_html__('%d min', 'unsupervised-schedular'),
(int) $class['duration']
);
?>
</td>
</tr>
<?php endif; ?>
<tr>
<th scope="row"><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html(number_format($class['price'], 2) . ' ' . $class['currency']); ?></td>
</tr>
<?php if (null !== $class['schedule_note'] && '' !== $class['schedule_note']) : ?>
<tr>
<th scope="row"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($class['schedule_note']); ?></td>
</tr>
<?php endif; ?>
<?php if (null !== $class['description'] && '' !== $class['description']) : ?>
<tr>
<th scope="row"><?php esc_html_e('Description', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($class['description']); ?></td>
</tr>
<?php endif; ?>
<tr>
<th scope="row"><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<td>
<?php if ($class['active']) : ?>
<?php esc_html_e('Open for registration', 'unsupervised-schedular'); ?>
<?php else : ?>
<?php esc_html_e('Closed', 'unsupervised-schedular'); ?>
<?php endif; ?>
<?php if ($class['invite_only']) : ?>
&middot; <?php esc_html_e('Invite only', 'unsupervised-schedular'); ?>
<?php endif; ?>
</td>
</tr>
</tbody>
</table>
<h2><?php esc_html_e('Enrolled students', 'unsupervised-schedular'); ?></h2>
<?php if (empty($class['roster'])) : ?>
<p><?php esc_html_e('No enrolments yet.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Enrolment', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Payment', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($class['roster'] as $entry) : ?>
<tr>
<td><?php echo esc_html($entry['student']); ?></td>
<td><?php echo esc_html($entry['status']); ?></td>
<td><?php echo esc_html($entry['payment'] ?? '—'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($class['invite_only']) : ?>
<h2><?php esc_html_e('Invite &amp; enrol students', 'unsupervised-schedular'); ?></h2>
<p class="description"><?php esc_html_e('This class is invite only, so students join only when you add or invite them here.', 'unsupervised-schedular'); ?></p>
<?php if (! empty($class['invited'])) : ?>
<h3><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h3>
<ul class="ul-disc">
<?php foreach ($class['invited'] as $invitee) : ?>
<li><?php echo esc_html($invitee['who'] . ' — ' . $invitee['kind']); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<div class="us-group-invite-controls" style="display:flex; flex-wrap:wrap; gap:2em; margin:1em 0 2em;">
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Add students directly', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('Enrols them now with a pending payment.', 'unsupervised-schedular'); ?></p>
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
<?php foreach ($students as $student) : ?>
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
<?php endforeach; ?>
</select>
<p>
<button type="submit" name="usc_action" value="add_direct" class="button"><?php esc_html_e('Add to class', 'unsupervised-schedular'); ?></button>
</p>
</form>
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Make available to students', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('Grants access so they can enrol themselves.', 'unsupervised-schedular'); ?></p>
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
<?php foreach ($students as $student) : ?>
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
<?php endforeach; ?>
</select>
<p>
<button type="submit" name="usc_action" value="grant_access" class="button"><?php esc_html_e('Grant access', 'unsupervised-schedular'); ?></button>
</p>
</form>
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Invite by email', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('For someone without an account yet.', 'unsupervised-schedular'); ?></p>
<input type="email" name="email" class="regular-text" placeholder="<?php esc_attr_e('[email protected]', 'unsupervised-schedular'); ?>">
<p>
<button type="submit" name="usc_action" value="invite_email" class="button"><?php esc_html_e('Send invite', 'unsupervised-schedular'); ?></button>
</p>
</form>
</div>
<?php endif; ?>
</div>
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/**
* @var list<array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}> $classes
* @var string $notice
* @var string $baseUrl
*/
?>
<div class="wrap">
<h1><?php esc_html_e('My Group Classes', 'unsupervised-schedular'); ?></h1>
<p class="description"><?php esc_html_e('Your group classes. Select one for its details, roster, and — for invite-only classes — to invite or add students.', 'unsupervised-schedular'); ?></p>
<?php if ('' !== $notice) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<?php if (empty($classes)) : ?>
<p><?php esc_html_e('You have no group classes.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('When', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Enrolled', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($classes as $class) : ?>
<tr>
<td>
<?php echo esc_html($class['title']); ?>
<?php if ($class['invite_only']) : ?>
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
<?php endif; ?>
</td>
<td><?php echo '' !== $class['when'] ? esc_html($class['when']) : '&mdash;'; ?></td>
<td>
<?php
if (null === $class['capacity']) {
echo esc_html((string) $class['enrolled']);
} else {
echo esc_html($class['enrolled'] . ' / ' . $class['capacity']);
}
?>
</td>
<td>
<a class="button button-small" href="<?php echo esc_url(add_query_arg('class_id', (int) $class['id'], $baseUrl)); ?>"><?php echo $class['invite_only'] ? esc_html__('View & invite', 'unsupervised-schedular') : esc_html__('View details', 'unsupervised-schedular'); ?></a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
+57 -4
View File
@@ -10,6 +10,9 @@ if (! defined('ABSPATH')) {
/**
* @var list<\Unsupervised\Schedular\Offering\Offering> $offerings
* @var \Unsupervised\Schedular\Offering\Offering|null $editing Offering loaded into the form, or null when adding.
* @var list<array{id: int, name: string}> $instructors Instructors offered in the assignment picker (studio admins only).
* @var bool $manageAll Whether the current user may assign classes to other instructors.
* @var string $notice Status message from the last save (slot-clearing / conflicts).
*/
$baseUrl = admin_url('admin.php?page=us-offerings');
@@ -26,6 +29,10 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<div class="wrap">
<h1><?php esc_html_e('Offerings', 'unsupervised-schedular'); ?></h1>
<?php if ('' !== $notice) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<h2><?php $editing ? esc_html_e('Edit Offering', 'unsupervised-schedular') : esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
<form method="post">
<?php wp_nonce_field('usc_offering_action'); ?>
@@ -49,6 +56,19 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
</select>
</td>
</tr>
<?php if ($manageAll) : ?>
<tr>
<th><label for="class_instructor_id"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></label></th>
<td>
<select name="class_instructor_id" id="class_instructor_id">
<?php foreach ($instructors as $instructor) : ?>
<option value="<?php echo esc_attr((string) $instructor['id']); ?>" <?php echo $editing && $editing->instructorId === $instructor['id'] ? 'selected' : ''; ?>><?php echo esc_html($instructor['name']); ?></option>
<?php endforeach; ?>
</select>
<p class="description"><?php esc_html_e('Who teaches this. Assigning a group class clears that instructors open booking slots at the class time.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<?php endif; ?>
<tr>
<th><label for="description"><?php esc_html_e('Description', 'unsupervised-schedular'); ?></label></th>
<td><textarea name="description" id="description" class="large-text" rows="4"><?php echo esc_textarea($editing->description ?? ''); ?></textarea></td>
@@ -85,6 +105,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<span class="description"><?php esc_html_e('Group classes only — date of the first class', 'unsupervised-schedular'); ?></span>
</td>
</tr>
<tr>
<th><label for="class_time"><?php esc_html_e('Class time', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="time" name="class_time" id="class_time" value="<?php echo esc_attr(null === ($editing->classTime ?? null) ? '' : substr((string) $editing->classTime, 0, 5)); ?>">
<span class="description"><?php esc_html_e('Group classes only — the time each session starts. Combined with the duration to block the instructors availability.', 'unsupervised-schedular'); ?></span>
</td>
</tr>
<tr>
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
<td>
@@ -103,6 +130,20 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<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>
</tr>
<tr>
<th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="number" name="cancellation_cutoff_hours" id="cancellation_cutoff_hours" min="0" step="1" placeholder="<?php esc_attr_e('Uses the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr(null === ($editing->cancellationCutoffHours ?? null) ? '' : (string) $editing->cancellationCutoffHours); ?>">
<p class="description"><?php esc_html_e('How many hours before a lesson a student may still cancel it. Leave blank to use the studio default; 0 lets students cancel any time.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr>
<th><?php esc_html_e('Invite only', 'unsupervised-schedular'); ?></th>
<td>
<label><input type="checkbox" name="invite_only" value="1" <?php echo $editing && $editing->isInviteOnly() ? 'checked' : ''; ?>> <?php esc_html_e('Hide from the booking list — students join by invitation only (group classes)', 'unsupervised-schedular'); ?></label>
<p class="description"><?php esc_html_e('Invite-only classes never appear in the student catalog. Add or invite students from My Lessons → My Group Classes.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr>
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
<td><label><input type="checkbox" name="is_active" value="1" <?php echo null === $editing || $editing->isActive ? 'checked' : ''; ?>> <?php esc_html_e('Open for registration', 'unsupervised-schedular'); ?></label></td>
@@ -137,7 +178,12 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<?php foreach ($offerings as $offering) : ?>
<tr>
<td><?php echo esc_html((string) $offering->id); ?></td>
<td><?php echo esc_html($offering->title); ?></td>
<td>
<?php echo esc_html($offering->title); ?>
<?php if ($offering->isInviteOnly()) : ?>
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
<?php endif; ?>
</td>
<td><?php echo esc_html($offering->kind); ?></td>
<td><?php echo $offering->durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '&mdash;'; ?></td>
<td><?php echo esc_html(number_format($offering->price, 2) . ' ' . $offering->currency); ?></td>
@@ -145,10 +191,17 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<td>
<?php if (null === $offering->termStart) : ?>
&mdash;
<?php elseif (null === $offering->termEnd || $offering->termEnd === $offering->termStart) : ?>
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart)); ?>
<?php else : ?>
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart) . ' ' . (string) mysql2date('M j, Y', $offering->termEnd)); ?>
<?php
if (null === $offering->termEnd || $offering->termEnd === $offering->termStart) {
echo esc_html((string) mysql2date('M j, Y', $offering->termStart));
} else {
echo esc_html((string) mysql2date('M j, Y', $offering->termStart) . ' ' . (string) mysql2date('M j, Y', $offering->termEnd));
}
if (null !== $offering->classTime) {
echo '<br><span class="description">' . esc_html((string) mysql2date('g:i a', $offering->termStart . ' ' . $offering->classTime)) . '</span>';
}
?>
<?php endif; ?>
</td>
<td><?php echo $offering->isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
+18 -6
View File
@@ -10,6 +10,8 @@ if (! defined('ABSPATH')) {
/**
* @var list<\Unsupervised\Schedular\Offering\Offering> $offeringList
* @var \Unsupervised\Schedular\Offering\Offering|null $selectedOffering
* @var bool $accountScope Whether the studio-wide account-signup questions are selected.
* @var bool $manageAll Whether the current user is a studio admin (may edit account questions).
* @var list<\Unsupervised\Schedular\Registration\Question>|null $questions
*/
?>
@@ -18,21 +20,31 @@ if (! defined('ABSPATH')) {
<form method="get">
<input type="hidden" name="page" value="us-questions">
<label for="offering_id"><?php esc_html_e('Offering', 'unsupervised-schedular'); ?></label>
<label for="offering_id"><?php esc_html_e('Questions for', 'unsupervised-schedular'); ?></label>
<select name="offering_id" id="offering_id" onchange="this.form.submit()">
<option value="0"><?php esc_html_e('— Select an offering —', 'unsupervised-schedular'); ?></option>
<option value="0"><?php esc_html_e('— Select —', 'unsupervised-schedular'); ?></option>
<?php if ($manageAll) : ?>
<option value="<?php echo esc_attr(Question::SCOPE_ACCOUNT); ?>" <?php selected($accountScope); ?>>
<?php esc_html_e('Account signup (all registrations)', 'unsupervised-schedular'); ?>
</option>
<?php endif; ?>
<?php foreach ($offeringList as $offering) : ?>
<option value="<?php echo esc_attr((string) $offering->id); ?>" <?php selected($selectedOffering && $selectedOffering->id === $offering->id); ?>>
<option value="<?php echo esc_attr((string) $offering->id); ?>" <?php selected(! $accountScope && $selectedOffering && $selectedOffering->id === $offering->id); ?>>
<?php echo esc_html($offering->title); ?>
</option>
<?php endforeach; ?>
</select>
</form>
<?php if (null === $selectedOffering) : ?>
<p><?php esc_html_e('Choose an offering to manage its intake questions.', 'unsupervised-schedular'); ?></p>
<?php if (! $accountScope && null === $selectedOffering) : ?>
<p><?php esc_html_e('Choose an offering to manage its intake questions, or "Account signup" for the questions every new student answers when registering.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<?php if ($accountScope) : ?>
<h2><?php esc_html_e('Account signup questions', 'unsupervised-schedular'); ?></h2>
<p><?php esc_html_e('Every new student answers these required-if-marked questions as a second step after choosing their name and password.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<h2><?php echo esc_html(sprintf(/* translators: %s: offering title */ __('Questions for "%s"', 'unsupervised-schedular'), $selectedOffering->title)); ?></h2>
<?php endif; ?>
<h3><?php esc_html_e('Add Question', 'unsupervised-schedular'); ?></h3>
<form method="post">
@@ -74,7 +86,7 @@ if (! defined('ABSPATH')) {
<h3><?php esc_html_e('Current Questions', 'unsupervised-schedular'); ?></h3>
<?php if (empty($questions)) : ?>
<p><?php esc_html_e('No questions configured for this offering.', 'unsupervised-schedular'); ?></p>
<p><?php esc_html_e('No questions configured yet.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
+12
View File
@@ -16,6 +16,7 @@ if (! defined('ABSPATH')) {
* @var float $hstRate
* @var bool $stripeConfigured
* @var bool $openRegistration
* @var int $cancellationCutoffDays
*/
?>
<div class="wrap">
@@ -90,6 +91,17 @@ if (! defined('ABSPATH')) {
</tr>
</table>
<h2><?php esc_html_e('Cancellations', 'unsupervised-schedular'); ?></h2>
<table class="form-table">
<tr>
<th><label for="cancellation_cutoff_days"><?php esc_html_e('Cancellation cutoff (days)', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="number" name="cancellation_cutoff_days" id="cancellation_cutoff_days" class="small-text" min="0" step="1" value="<?php echo esc_attr((string) $cancellationCutoffDays); ?>">
<p class="description"><?php esc_html_e('How far ahead of a lesson a student may still cancel it. Within this window only an instructor can cancel. 0 lets students cancel any time. An offering can override this with its own value.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
</table>
<h2><?php esc_html_e('Registration', 'unsupervised-schedular'); ?></h2>
<table class="form-table">
<tr>
+28
View File
@@ -11,6 +11,7 @@ if (! defined('ABSPATH')) {
* @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
* @var list<array{id: int, offering: string, status: string}> $enrolments
* @var list<array{policy: string, version: string, context: string, accepted_at: string}> $acceptances
* @var list<array{question: string, answer: string, required: bool}> $registrationInfo
* @var list<array{question: string, answer: string, context: string}> $intake
* @var list<array{created_at: string, context: string, method: string, status: string, amount: float, tax_amount: float, total: float, currency: string, receipt: string}> $payments
* @var string $backUrl
@@ -101,6 +102,33 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
<?php submit_button(esc_html__('Save account details', 'unsupervised-schedular'), 'secondary', 'submit', false); ?>
</form>
<h2><?php esc_html_e('Registration Information', 'unsupervised-schedular'); ?></h2>
<?php if (empty($registrationInfo)) : ?>
<p><?php esc_html_e('No registration questions are configured.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($registrationInfo as $row) : ?>
<tr>
<td>
<?php echo esc_html($row['question']); ?>
<?php if ($row['required']) : ?>
<span class="us-required" aria-hidden="true">*</span>
<?php endif; ?>
</td>
<td><?php echo esc_html($row['answer']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($canBilling) : ?>
<h2><?php esc_html_e('Billing method', 'unsupervised-schedular'); ?></h2>
<form method="post">
+62 -1
View File
@@ -1,6 +1,8 @@
<?php
declare(strict_types=1);
use Unsupervised\Schedular\Registration\Question;
if (! defined('ABSPATH')) {
exit;
}
@@ -16,7 +18,42 @@ if (! defined('ABSPATH')) {
* @var string $loginUrl Where the post-confirmation sign-in link points.
* @var string $error
* @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
* @var list<Question> $accountQuestions Studio-wide questions answered as step two.
*/
/**
* Render one account-signup question's input, named `us_answers[<id>]`.
*/
$renderQuestionField = static function (Question $question): void {
$id = (int) $question->id;
$name = 'us_answers[' . $id . ']';
$fieldId = 'us-reg-q-' . $id;
$required = $question->isRequired ? ' required' : '';
?>
<p>
<label for="<?php echo esc_attr($fieldId); ?>">
<?php echo esc_html($question->label); ?>
<?php if ($question->isRequired) : ?>
<span class="us-required" aria-hidden="true">*</span>
<?php endif; ?>
</label>
<?php if ($question->fieldType === Question::FIELD_TEXTAREA) : ?>
<textarea name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>" rows="4"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>></textarea>
<?php elseif ($question->fieldType === Question::FIELD_SELECT) : ?>
<select name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>>
<option value=""><?php esc_html_e('— Select —', 'unsupervised-schedular'); ?></option>
<?php foreach ((array) $question->options as $option) : ?>
<option value="<?php echo esc_attr((string) $option); ?>"><?php echo esc_html((string) $option); ?></option>
<?php endforeach; ?>
</select>
<?php elseif ($question->fieldType === Question::FIELD_CHECKBOX) : ?>
<input type="checkbox" name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>" value="1"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>>
<?php else : ?>
<input type="text" name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>>
<?php endif; ?>
</p>
<?php
};
?>
<div class="us-register-form">
<?php if ($successType === 'invite') : ?>
@@ -43,10 +80,12 @@ if (! defined('ABSPATH')) {
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
<?php endif; ?>
<form method="post" action="">
<?php $hasQuestions = ! empty($accountQuestions); ?>
<form method="post" action="" <?php echo $hasQuestions ? 'data-steps="1"' : ''; ?>>
<?php wp_nonce_field('us_student_register'); ?>
<input type="hidden" name="us_invite" value="<?php echo esc_attr($token); ?>">
<div class="us-reg-step" data-step="1">
<p>
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
<?php if ($inviteValid && $invite !== null && ! $invite->isGroup()) : ?>
@@ -83,9 +122,31 @@ if (! defined('ABSPATH')) {
</fieldset>
<?php endif; ?>
<?php if ($hasQuestions) : ?>
<p>
<button type="button" class="us-reg-next"><?php esc_html_e('Next', 'unsupervised-schedular'); ?></button>
</p>
<?php else : ?>
<p>
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
</p>
<?php endif; ?>
</div>
<?php if ($hasQuestions) : ?>
<div class="us-reg-step" data-step="2">
<fieldset class="us-reg-questions">
<legend><?php esc_html_e('Registration information', 'unsupervised-schedular'); ?></legend>
<?php foreach ($accountQuestions as $question) : ?>
<?php $renderQuestionField($question); ?>
<?php endforeach; ?>
</fieldset>
<p>
<button type="button" class="us-reg-back"><?php esc_html_e('Back', 'unsupervised-schedular'); ?></button>
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
</p>
</div>
<?php endif; ?>
</form>
<?php endif; ?>
<?php endif; ?>
+2 -1
View File
@@ -35,11 +35,12 @@ class InviteRepositoryTest extends TestCase
return $d['email'] === '[email protected]'
&& $d['token'] === 'tok123'
&& $d['kind'] === Invite::KIND_PERSONAL
&& $d['offering_id'] === null
&& $d['status'] === Invite::STATUS_PENDING
&& $d['invited_by'] === 2
&& $d['expires_at'] === null;
}),
['%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s']
['%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s']
);
$this->db->insert_id = 5;
+35 -1
View File
@@ -155,8 +155,42 @@ class InviteTest extends TestCase
{
$arr = (new Invite('[email protected]', 'tok', id: 1))->toArray();
foreach (['id', 'email', 'token', 'role', 'kind', 'status', 'invited_by', 'accepted_user_id', 'accepted_at', 'expires_at'] as $key) {
foreach (['id', 'email', 'token', 'role', 'kind', 'status', 'invited_by', 'accepted_user_id', 'accepted_at', 'expires_at', 'offering_id'] as $key) {
self::assertArrayHasKey($key, $arr);
}
}
public function testOfferingIdRoundTrips(): void
{
$invite = Invite::fromRow((object) [
'id' => '5',
'email' => '[email protected]',
'token' => 'tok123',
'role' => RoleManager::STUDENT,
'status' => Invite::STATUS_PENDING,
'invited_by' => '2',
'accepted_user_id' => null,
'accepted_at' => null,
'offering_id' => '8',
]);
self::assertSame(8, $invite->offeringId);
self::assertSame(8, $invite->toArray()['offering_id']);
}
public function testOfferingIdDefaultsToNullWhenColumnMissing(): void
{
$invite = Invite::fromRow((object) [
'id' => '5',
'email' => '[email protected]',
'token' => 'tok123',
'role' => RoleManager::STUDENT,
'status' => Invite::STATUS_PENDING,
'invited_by' => null,
'accepted_user_id' => null,
'accepted_at' => null,
]);
self::assertNull($invite->offeringId);
}
}
@@ -81,4 +81,42 @@ class RegistrationMailerTest extends TestCase
{
self::assertFalse((new RegistrationMailer())->sendRejected(''));
}
public function testSendClassAccessGrantedEmailsTheStudent(): void
{
Functions\expect('wp_mail')
->once()
->with(
'[email protected]',
Mockery::on(static fn (string $subject): bool => str_contains($subject, 'Choir')),
Mockery::on(static fn (string $body): bool => str_contains($body, 'Choir'))
)
->andReturn(true);
self::assertTrue((new RegistrationMailer())->sendClassAccessGranted($this->user('[email protected]'), 'Choir'));
}
public function testSendClassAccessGrantedReturnsFalseWithoutRecipient(): void
{
self::assertFalse((new RegistrationMailer())->sendClassAccessGranted($this->user(''), 'Choir'));
}
public function testSendClassInviteIncludesTheLink(): void
{
Functions\expect('wp_mail')
->once()
->with(
'[email protected]',
Mockery::type('string'),
Mockery::on(static fn (string $body): bool => str_contains($body, 'http://join.test'))
)
->andReturn(true);
self::assertTrue((new RegistrationMailer())->sendClassInvite('[email protected]', 'http://join.test', 'Choir'));
}
public function testSendClassInviteReturnsFalseWithoutRecipient(): void
{
self::assertFalse((new RegistrationMailer())->sendClassInvite('', 'http://join.test', 'Choir'));
}
}
+94
View File
@@ -9,12 +9,17 @@ use Unsupervised\Schedular\Auth\Invite;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersion;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class RegistrationPageTest extends TestCase
@@ -28,16 +33,28 @@ class RegistrationPageTest extends TestCase
Functions\when('wp_unslash')->alias(static fn ($v) => $v);
Functions\when('sanitize_text_field')->alias(static fn ($v) => $v);
Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v);
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
Functions\when('absint')->alias(static fn ($v) => (int) $v);
Functions\when('current_time')->justReturn('2024-01-01 00:00:00');
$invites = Mockery::mock(InviteRepository::class);
$policies = Mockery::mock(PolicyRepository::class);
$questions = Mockery::mock(QuestionRepository::class);
$answers = Mockery::mock(AnswerRepository::class);
$policies->shouldReceive('findForScope')->andReturn([])->byDefault();
$questions->shouldReceive('findByScope')->andReturn([])->byDefault();
$answers->shouldReceive('insert')->andReturn(1)->byDefault();
$access = Mockery::mock(GroupAccessRepository::class);
$access->shouldReceive('linkStudentByEmail')->andReturn(true)->byDefault();
$this->ctx = [
'invites' => $invites,
'policies' => $policies,
'questions' => $questions,
'answers' => $answers,
'access' => $access,
'mailer' => Mockery::mock(RegistrationMailer::class),
'settings' => Mockery::mock(StudioSettings::class),
];
@@ -49,6 +66,9 @@ class RegistrationPageTest extends TestCase
Mockery::mock(AcceptanceRepository::class),
$this->ctx['settings'],
$this->ctx['mailer'],
$questions,
$answers,
$access,
);
$_POST = [];
@@ -96,6 +116,26 @@ class RegistrationPageTest extends TestCase
self::assertSame('invite', $this->submit($invite, false));
}
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
Functions\when('wp_set_current_user')->justReturn(null);
Functions\when('wp_set_auth_cookie')->justReturn(null);
// A personal invite tied to a class grant links the new account to it.
$this->ctx['access']->shouldReceive('linkStudentByEmail')->once()->with('[email protected]', 42)->andReturn(true);
$invite = new Invite(email: '[email protected]', token: 'hash', offeringId: 8);
self::assertSame('invite', $this->submit($invite, false));
}
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
@@ -309,4 +349,58 @@ class RegistrationPageTest extends TestCase
self::assertNotSame('confirm', $result);
self::assertNotSame('', $result);
}
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
$question = new Question(null, 'Emergency contact', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 5);
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
// A missing required answer must be caught before any account is created.
Functions\expect('wp_insert_user')->never();
$this->ctx['answers']->shouldReceive('insert')->never();
$result = $this->submit(null, true);
self::assertNotSame('confirm', $result);
self::assertNotSame('', $result);
}
public function testRecordsAccountAnswersOnSuccess(): void
{
$_POST = [
'password' => 'password123',
'display_name' => 'Ada',
'us_answers' => [ '5' => 'By a friend' ],
];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
Functions\expect('wp_set_current_user')->once();
Functions\expect('wp_set_auth_cookie')->once();
$this->ctx['invites']->shouldReceive('markAccepted')->once();
$question = new Question(null, 'How did you hear about us?', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 5);
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
// The answer is written against the new user (account scope).
$this->ctx['answers']->shouldReceive('insert')
->once()
->with(Mockery::on(static function (Answer $answer): bool {
return $answer->registrationType === Answer::REG_ACCOUNT
&& $answer->registrationId === 42
&& $answer->studentId === 42
&& $answer->questionId === 5
&& $answer->answerValue === 'By a friend';
}))
->andReturn(1);
$invite = new Invite(email: '[email protected]', token: 'hash');
self::assertSame('invite', $this->submit($invite, false));
}
}
+38
View File
@@ -125,6 +125,44 @@ class StudentHistoryTest extends TestCase
self::assertSame('—', $rows[0]['answer']);
}
public function testIntakeAnswersExcludeAccountScopeAnswers(): void
{
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
new Answer(9, Answer::REG_ACCOUNT, 5, 5, 'By a friend', 2),
new Answer(4, Answer::REG_LESSON, 12, 5, 'Beginner', 1),
]);
// Only the booking-scoped answer is resolved; the account answer is dropped.
$this->questions->shouldReceive('findById')->with(4)
->andReturn(new Question(1, 'Experience level', id: 4));
$rows = $this->history->intakeAnswers(5);
self::assertCount(1, $rows);
self::assertSame('Experience level', $rows[0]['question']);
self::assertSame('Lesson #12', $rows[0]['context']);
}
public function testRegistrationInfoPairsAccountQuestionsWithAnswers(): void
{
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_ACCOUNT, 5)->andReturn([
new Answer(4, Answer::REG_ACCOUNT, 5, 5, 'Yes', 1),
]);
$this->questions->shouldReceive('findByScope')->once()->with(Question::SCOPE_ACCOUNT)->andReturn([
new Question(null, 'Consent to email', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 4),
new Question(null, 'Anything else?', isRequired: false, scope: Question::SCOPE_ACCOUNT, id: 7),
]);
$rows = $this->history->registrationInfo(5);
self::assertSame(
[
[ 'question' => 'Consent to email', 'answer' => 'Yes', 'required' => true ],
[ 'question' => 'Anything else?', 'answer' => '—', 'required' => false ],
],
$rows
);
}
public function testPaymentsBuildDisplayRows(): void
{
$this->payments->shouldReceive('findByStudent')->once()->with(5)->andReturn([
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Mockery;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class UserNameTest extends TestCase
{
private function user(string $first, string $last, string $nickname): \WP_User
{
$user = Mockery::mock(\WP_User::class);
$user->first_name = $first;
$user->last_name = $last;
$user->nickname = $nickname;
return $user;
}
public function testPrefersFirstAndLastName(): void
{
self::assertSame('Ada Lovelace', UserName::format($this->user('Ada', 'Lovelace', 'ada_login')));
}
public function testUsesFirstNameAloneWhenLastNameMissing(): void
{
self::assertSame('Ada', UserName::format($this->user('Ada', '', 'ada_login')));
}
public function testFallsBackToNicknameWhenNoRealName(): void
{
self::assertSame('Countess', UserName::format($this->user('', '', 'Countess')));
}
public function testFallsBackToIdWhenNothingSet(): void
{
self::assertSame('42', UserName::format($this->user('', '', ''), 42));
}
public function testReturnsFallbackIdWhenUserMissing(): void
{
self::assertSame('42', UserName::format(null, 42));
self::assertSame('', UserName::format(null));
}
}
@@ -343,4 +343,37 @@ class AvailabilityRepositoryTest extends TestCase
self::assertCount(1, $slots);
self::assertInstanceOf(AvailabilitySlot::class, $slots[0]);
}
public function testFindOverlappingQueriesTheInstructorAndWindow(): void
{
$row = (object) [
'id' => '5',
'instructor_id' => '3',
'offering_id' => null,
'start_dt' => '2026-09-08 16:00:00',
'end_dt' => '2026-09-08 17:00:00',
'duration_minutes' => '60',
'is_booked' => '0',
'recurrence_group' => null,
];
// Half-open overlap: start_dt < window end AND end_dt > window start, with
// the window bounds bound in that order.
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/start_dt < %s AND end_dt > %s/'),
'wp_us_availability',
3,
'2026-09-08 17:00:00',
'2026-09-08 16:00:00'
)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([$row]);
$slots = $this->repo->findOverlapping(3, '2026-09-08 16:00:00', '2026-09-08 17:00:00');
self::assertCount(1, $slots);
self::assertInstanceOf(AvailabilitySlot::class, $slots[0]);
}
}
@@ -9,11 +9,13 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Tests\Unit\TestCase;
@@ -24,6 +26,7 @@ class BookingEndpointTest extends TestCase
private OfferingRepository $offerings;
private RegistrationGate $gate;
private PaymentService $payments;
private StudioSettings $settings;
private BookingEndpoint $endpoint;
protected function setUp(): void
@@ -34,12 +37,17 @@ class BookingEndpointTest extends TestCase
Functions\when('wp_unslash')->returnArg();
Functions\when('sanitize_text_field')->returnArg();
Functions\when('get_current_user_id')->justReturn(5);
// Fixed "now" well before the fixture slot start (2026-07-01 10:00), so
// the cancellation cutoff never trips unless a test moves it.
Functions\when('current_time')->justReturn('2026-06-01 10:00:00');
$this->availability = Mockery::mock(AvailabilityRepository::class);
$this->bookings = Mockery::mock(BookingRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->gate = Mockery::mock(RegistrationGate::class);
$this->payments = Mockery::mock(PaymentService::class);
$this->settings = Mockery::mock(StudioSettings::class);
$this->settings->shouldReceive('cancellationCutoffHours')->andReturn(24)->byDefault();
$this->endpoint = new BookingEndpoint(
$this->availability,
@@ -47,6 +55,7 @@ class BookingEndpointTest extends TestCase
$this->offerings,
$this->gate,
$this->payments,
new CancellationPolicy($this->settings),
);
}
@@ -378,6 +387,57 @@ class BookingEndpointTest extends TestCase
{
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CANCELLED)->once()->andReturn(true);
$this->availability->shouldReceive('release')->with(10)->once()->andReturn(true);
$this->payments->shouldReceive('voidPending')->with(12)->once();
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']);
}
public function testCancelWithinStudioCutoffIsRejected(): void
{
// Now (2026-06-01 10:00) is only 24h before a slot at 2026-06-02 10:00,
// exactly the studio cutoff — inside the window, so cancellation closes.
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->availability->shouldReceive('findById')->with(10)->andReturn(new AvailabilitySlot(
instructorId: 3,
startDt: '2026-06-02 09:59:00',
endDt: '2026-06-02 10:59:00',
id: 10,
));
$this->bookings->shouldNotReceive('updateStatus');
$this->availability->shouldNotReceive('release');
$this->payments->shouldNotReceive('voidPending');
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('cancellation_closed', $result->get_error_code());
}
public function testCancelUsesOfferingCutoffOverrideWhenSet(): void
{
// Offering overrides the 24h studio default with 0 hours — cancel any time,
// even for a lesson starting in a minute.
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 4, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->availability->shouldReceive('findById')->with(10)->andReturn(new AvailabilitySlot(
instructorId: 3,
startDt: '2026-06-01 10:01:00',
endDt: '2026-06-01 11:01:00',
id: 10,
));
$this->offerings->shouldReceive('findById')->with(4)->andReturn(new Offering(
instructorId: 3,
kind: Offering::KIND_PRIVATE_LESSON,
title: 'Trial',
cancellationCutoffHours: 0,
));
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CANCELLED)->once()->andReturn(true);
$this->availability->shouldReceive('release')->with(10)->once()->andReturn(true);
$this->payments->shouldReceive('voidPending')->with(12)->once();
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Mockery;
use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class CancellationPolicyTest extends TestCase
{
private function policy(int $studioDefaultHours): CancellationPolicy
{
$settings = Mockery::mock(StudioSettings::class);
$settings->shouldReceive('cancellationCutoffHours')->andReturn($studioDefaultHours);
return new CancellationPolicy($settings);
}
public function testCutoffHoursFallsBackToStudioDefaultWhenOverrideNull(): void
{
self::assertSame(24, $this->policy(24)->cutoffHours(null));
}
public function testCutoffHoursUsesOverrideWhenSet(): void
{
// Even a zero override wins over the studio default — it is a deliberate
// "cancel any time" choice, not an absent value.
self::assertSame(0, $this->policy(24)->cutoffHours(0));
self::assertSame(72, $this->policy(24)->cutoffHours(72));
}
public function testCutoffHoursIgnoresNegativeOverride(): void
{
self::assertSame(24, $this->policy(24)->cutoffHours(-5));
}
public function testStudentMayCancelWhenOutsideWindow(): void
{
// 48h before a 24h cutoff — comfortably outside.
self::assertTrue(
$this->policy(24)->studentMayCancel('2026-07-03 10:00:00', null, '2026-07-01 10:00:00')
);
}
public function testStudentMayNotCancelWhenInsideWindow(): void
{
// 12h before a 24h cutoff — inside the window.
self::assertFalse(
$this->policy(24)->studentMayCancel('2026-07-01 22:00:00', null, '2026-07-01 10:00:00')
);
}
public function testExactlyAtCutoffBoundaryIsAllowed(): void
{
// Exactly 24h ahead of a 24h cutoff is still cancellable.
self::assertTrue(
$this->policy(24)->studentMayCancel('2026-07-02 10:00:00', null, '2026-07-01 10:00:00')
);
}
public function testZeroCutoffAlwaysAllowsCancellation(): void
{
self::assertTrue(
$this->policy(0)->studentMayCancel('2026-07-01 10:00:01', null, '2026-07-01 10:00:00')
);
}
public function testOfferingOverrideNarrowsWindow(): void
{
// Studio default is 0 (any time), but this offering demands 48h notice.
self::assertFalse(
$this->policy(0)->studentMayCancel('2026-07-02 10:00:00', 48, '2026-07-01 10:00:00')
);
}
public function testPastLessonCannotBeCancelled(): void
{
self::assertFalse(
$this->policy(24)->studentMayCancel('2026-07-01 09:00:00', null, '2026-07-01 10:00:00')
);
}
public function testDescribeCutoffUsesDaysForWholeDays(): void
{
$policy = $this->policy(24);
self::assertSame('1 day', $policy->describeCutoff(24));
self::assertSame('2 days', $policy->describeCutoff(48));
}
public function testDescribeCutoffUsesHoursOtherwise(): void
{
$policy = $this->policy(24);
self::assertSame('12 hours', $policy->describeCutoff(12));
self::assertSame('1 hour', $policy->describeCutoff(1));
}
}
@@ -7,6 +7,7 @@ use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
@@ -20,6 +21,7 @@ class EnrollmentEndpointTest extends TestCase
private OfferingRepository $offerings;
private RegistrationGate $gate;
private PaymentService $payments;
private GroupAccessRepository $access;
private EnrollmentEndpoint $endpoint;
protected function setUp(): void
@@ -35,12 +37,14 @@ class EnrollmentEndpointTest extends TestCase
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->gate = Mockery::mock(RegistrationGate::class);
$this->payments = Mockery::mock(PaymentService::class);
$this->access = Mockery::mock(GroupAccessRepository::class);
$this->endpoint = new EnrollmentEndpoint(
$this->enrollments,
$this->offerings,
$this->gate,
$this->payments,
$this->access,
);
}
@@ -49,6 +53,11 @@ class EnrollmentEndpointTest extends TestCase
return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: $price, id: 8);
}
private function inviteOnlyOffering(): Offering
{
return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Private Choir', accessMode: Offering::ACCESS_INVITE_ONLY, id: 8);
}
private function expectSuccessfulEnrollment(): void
{
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
@@ -98,4 +107,31 @@ class EnrollmentEndpointTest extends TestCase
$result->get_data()['payment']
);
}
public function testInviteOnlyClassRejectsStudentWithoutGrant(): void
{
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
$this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(false);
$this->enrollments->shouldReceive('insert')->never();
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invite_required', $result->get_error_code());
self::assertSame(403, $result->error_data['invite_required']['status']);
}
public function testInviteOnlyClassAllowsGrantedStudentAndMarksEnrolled(): void
{
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
$this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(true);
$this->expectSuccessfulEnrollment();
$this->access->shouldReceive('markEnrolled')->once()->with(8, 5);
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(201, $result->get_status());
}
}
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\GroupClass\GroupAccess;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GroupAccessRepositoryTest extends TestCase
{
private \wpdb $db;
private GroupAccessRepository $repo;
protected function setUp(): void
{
parent::setUp();
$this->db = Mockery::mock(\wpdb::class);
$this->db->prefix = 'wp_';
$this->repo = new GroupAccessRepository($this->db);
}
public function testInsertReturnsId(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_group_access',
Mockery::on(static function (array $d): bool {
return $d['offering_id'] === 8
&& $d['student_id'] === 5
&& $d['status'] === GroupAccess::STATUS_INVITED;
}),
['%d', '%d', '%s', '%d', '%s', '%d', '%s']
);
$this->db->insert_id = 3;
self::assertSame(3, $this->repo->insert(new GroupAccess(offeringId: 8, studentId: 5, invitedBy: 2)));
}
public function testHasGrantTrueWhenCountPositive(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/offering_id = %d AND student_id = %d AND status IN/'),
'wp_us_group_access',
8,
5,
GroupAccess::STATUS_INVITED,
GroupAccess::STATUS_ENROLLED
)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->andReturn('1');
self::assertTrue($this->repo->hasGrant(8, 5));
}
public function testHasGrantFalseWhenZero(): void
{
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->andReturn('0');
self::assertFalse($this->repo->hasGrant(8, 5));
}
public function testFindGrantedOfferingIdsReturnsInts(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/DISTINCT offering_id.*student_id = %d/s'),
'wp_us_group_access',
5,
GroupAccess::STATUS_INVITED,
GroupAccess::STATUS_ENROLLED
)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_col')->andReturn(['8', '9']);
self::assertSame([8, 9], $this->repo->findGrantedOfferingIds(5));
}
public function testFindByOfferingMapsRows(): void
{
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([
(object) [
'id' => '1',
'offering_id' => '8',
'student_id' => '5',
'email' => '',
'invite_id' => null,
'status' => GroupAccess::STATUS_INVITED,
'invited_by' => '2',
],
]);
$grants = $this->repo->findByOffering(8);
self::assertCount(1, $grants);
self::assertSame(5, $grants[0]->studentId);
}
public function testLinkStudentByEmailUpdatesNullStudentRows(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/UPDATE %i SET student_id = %d WHERE email = %s AND student_id IS NULL/'),
'wp_us_group_access',
5,
'[email protected]'
)
->andReturn('UPDATE ...');
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1);
self::assertTrue($this->repo->linkStudentByEmail('[email protected]', 5));
}
public function testLinkStudentByEmailIgnoresEmptyEmail(): void
{
$this->db->shouldReceive('prepare')->never();
self::assertFalse($this->repo->linkStudentByEmail('', 5));
}
public function testMarkEnrolledUpdatesStatus(): void
{
$this->db->shouldReceive('update')
->once()
->with(
'wp_us_group_access',
['status' => GroupAccess::STATUS_ENROLLED],
['offering_id' => 8, 'student_id' => 5],
['%s'],
['%d', '%d']
)
->andReturn(1);
self::assertTrue($this->repo->markEnrolled(8, 5));
}
public function testRevokeUpdatesStatus(): void
{
$this->db->shouldReceive('update')
->once()
->with('wp_us_group_access', ['status' => GroupAccess::STATUS_REVOKED], ['id' => 3], ['%s'], ['%d'])
->andReturn(1);
self::assertTrue($this->repo->revoke(3));
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Unsupervised\Schedular\GroupClass\GroupAccess;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GroupAccessTest extends TestCase
{
public function testDefaultsToInvitedStatusAndNullStudent(): void
{
$access = new GroupAccess(offeringId: 8);
self::assertSame(GroupAccess::STATUS_INVITED, $access->status);
self::assertNull($access->studentId);
self::assertSame('', $access->email);
}
public function testFromRowMapsColumns(): void
{
$row = (object) [
'id' => '3',
'offering_id' => '8',
'student_id' => '5',
'email' => '[email protected]',
'invite_id' => '9',
'status' => GroupAccess::STATUS_ENROLLED,
'invited_by' => '2',
];
$access = GroupAccess::fromRow($row);
self::assertSame(3, $access->id);
self::assertSame(8, $access->offeringId);
self::assertSame(5, $access->studentId);
self::assertSame('[email protected]', $access->email);
self::assertSame(9, $access->inviteId);
self::assertSame(GroupAccess::STATUS_ENROLLED, $access->status);
self::assertSame(2, $access->invitedBy);
}
public function testFromRowCastsNullStudentAndInvite(): void
{
$row = (object) [
'id' => '3',
'offering_id' => '8',
'student_id' => null,
'email' => '[email protected]',
'invite_id' => null,
'status' => GroupAccess::STATUS_INVITED,
'invited_by' => null,
];
$access = GroupAccess::fromRow($row);
self::assertNull($access->studentId);
self::assertNull($access->inviteId);
self::assertNull($access->invitedBy);
}
public function testToArrayContainsExpectedKeys(): void
{
$arr = (new GroupAccess(offeringId: 8, studentId: 5, id: 3))->toArray();
foreach (['id', 'offering_id', 'student_id', 'email', 'invite_id', 'status', 'invited_by'] as $key) {
self::assertArrayHasKey($key, $arr);
}
}
}
@@ -0,0 +1,533 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GroupClassControllerTest extends TestCase
{
private EnrollmentRepository&Mockery\MockInterface $enrollments;
private OfferingRepository&Mockery\MockInterface $offerings;
private PaymentRepository&Mockery\MockInterface $payments;
private GroupAccessRepository&Mockery\MockInterface $access;
private PaymentService&Mockery\MockInterface $paymentService;
private InviteRepository&Mockery\MockInterface $invites;
private RegistrationMailer&Mockery\MockInterface $mailer;
private GroupClassController $controller;
protected function setUp(): void
{
parent::setUp();
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->payments = Mockery::mock(PaymentRepository::class);
$this->access = Mockery::mock(GroupAccessRepository::class);
$this->paymentService = Mockery::mock(PaymentService::class);
$this->invites = Mockery::mock(InviteRepository::class);
$this->mailer = Mockery::mock(RegistrationMailer::class);
$this->controller = new GroupClassController(
$this->enrollments,
$this->offerings,
$this->payments,
$this->access,
$this->paymentService,
$this->invites,
$this->mailer,
);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3);
Functions\when('get_users')->justReturn([]);
Functions\when('esc_attr')->returnArg();
Functions\when('esc_attr_e')->returnArg();
Functions\when('esc_url')->returnArg();
Functions\when('admin_url')->justReturn('admin.php?page=us-my-group-classes');
Functions\when('add_query_arg')->alias(
static fn ($key, $value, $url) => $url . '&' . $key . '=' . $value
);
Functions\when('absint')->alias(static fn ($v) => abs((int) $v));
Functions\when('mysql2date')->alias(
static fn (string $format, string $date) => date($format, (int) strtotime($date))
);
Functions\when('wp_nonce_field')->justReturn('');
$_GET = [];
}
/**
* A WP_User whose real name (and display name) is the given full name, so
* both instructor resolution (first + last) and roster display (display name)
* render it.
*/
private function userNamed(string $full): \WP_User
{
[$first, $last] = array_pad(explode(' ', $full, 2), 2, '');
$user = Mockery::mock(\WP_User::class);
$user->first_name = $first;
$user->last_name = $last;
$user->nickname = $full;
$user->display_name = $full;
return $user;
}
private function offering(int $id, string $title, ?int $capacity): Offering
{
return new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: $title,
capacity: $capacity,
id: $id,
);
}
private function renderInstructor(): string
{
ob_start();
$this->controller->renderInstructorPage();
return (string) ob_get_clean();
}
public function testInstructorSummaryListsClassWithEnrolmentCountAndRosterLink(): void
{
$offering = $this->offering(8, 'Choir', 10);
$active = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: 42, id: 1);
$this->offerings->shouldReceive('findAll')->once()
->with(3, Offering::KIND_GROUP_CLASS)->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([$active]);
// The summary must not resolve individual students — it is a class list.
$this->payments->shouldReceive('findById')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('Choir', $html);
self::assertStringContainsString('1 / 10', $html);
self::assertStringContainsString('View details', $html);
self::assertStringContainsString('class_id=8', $html);
}
public function testClassDetailListsRosterWithPaymentStatus(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Choir', 10);
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: 42, id: 1);
$this->offerings->shouldReceive('findAll')->once()
->with(3, Offering::KIND_GROUP_CLASS)->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([$enrollment]);
$this->payments->shouldReceive('findById')->once()->with(42)->andReturn(
new Payment(studentId: 5, instructorId: 3, registrationType: Payment::REG_ENROLLMENT, registrationId: 1, amount: 100.0, status: Payment::STATUS_PAID, id: 42)
);
$html = $this->renderInstructor();
self::assertStringContainsString('1 / 10 enrolled', $html);
self::assertStringContainsString('Ada Lovelace', $html);
self::assertStringContainsString('paid', $html);
}
public function testClassDetailShowsClassSettingsAndInviteControlsForInviteOnly(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$_GET = ['class_id' => '8'];
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Private Choir',
price: 120.0,
description: 'A year of choir.',
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: '2026-09-08',
classTime: '16:00:00',
accessMode: Offering::ACCESS_INVITE_ONLY,
id: 8,
);
$this->offerings->shouldReceive('findAll')->once()
->with(3, Offering::KIND_GROUP_CLASS)->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$this->access->shouldReceive('findByOffering')->once()->with(8)->andReturn([]);
$html = $this->renderInstructor();
// The details section.
self::assertStringContainsString('Class details', $html);
self::assertStringContainsString('Ada Lovelace', $html);
self::assertStringContainsString('120.00', $html);
self::assertStringContainsString('A year of choir.', $html);
// The invite/add controls are reached from this page.
self::assertStringContainsString('Add students directly', $html);
self::assertStringContainsString('Invite by email', $html);
}
public function testClassDetailEnrolmentCountExcludesCancelledButRosterKeepsThem(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Grace Hopper'));
$_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Band', null);
$active = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 1);
$cancelled = new Enrollment(offeringId: 8, studentId: 6, instructorId: 3, status: Enrollment::STATUS_CANCELLED, id: 2);
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([$active, $cancelled]);
$html = $this->renderInstructor();
// Unlimited capacity offering counts only the active enrolment.
self::assertStringContainsString('1 enrolled', $html);
// But the roster still shows the cancelled row.
self::assertStringContainsString('cancelled', $html);
}
public function testClassDetailFreeEnrolmentShowsDashForPayment(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Alan Turing'));
$_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Theory', 5);
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: null, id: 1);
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([$enrollment]);
$this->payments->shouldReceive('findById')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('—', $html);
}
public function testEnrolmentsForOtherClassesAreNotMixedIn(): void
{
$offering = $this->offering(8, 'Choir', 5);
$mine = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 1);
$other = new Enrollment(offeringId: 9, studentId: 6, instructorId: 3, id: 2);
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([$mine, $other]);
$html = $this->renderInstructor();
self::assertStringContainsString('1 / 5', $html);
}
public function testClassDetailWithNoEnrolmentsShowsEmptyMessage(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Jazz', 5);
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([]);
$html = $this->renderInstructor();
self::assertStringContainsString('No enrolments yet.', $html);
}
public function testInstructorWithNoClassesShowsEmptyMessage(): void
{
$this->offerings->shouldReceive('findAll')->once()->andReturn([]);
$this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([]);
$html = $this->renderInstructor();
self::assertStringContainsString('You have no group classes.', $html);
}
public function testStudioAdminPageSummarisesClassesNotStudents(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
capacity: 10,
termStart: '2026-09-08',
termEnd: '2026-09-08',
classTime: '16:00:00',
id: 8,
);
$this->offerings->shouldReceive('findAll')->once()
->with(0, Offering::KIND_GROUP_CLASS)->andReturn([$offering]);
$this->enrollments->shouldReceive('countActiveForOffering')->once()->with(8)->andReturn(4);
ob_start();
$this->controller->renderPage();
$html = (string) ob_get_clean();
self::assertStringContainsString('Choir', $html);
self::assertStringContainsString('Ada Lovelace', $html);
self::assertStringContainsString('4 / 10', $html);
}
public function testStudioAdminCanOpenClassDetailWithInviteControls(): void
{
// A studio admin (view_all_lessons) opens a class taught by instructor 7.
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$_GET = ['class_id' => '8'];
$offering = new Offering(
instructorId: 7,
kind: Offering::KIND_GROUP_CLASS,
title: 'Private Choir',
price: 120.0,
accessMode: Offering::ACCESS_INVITE_ONLY,
id: 8,
);
$this->offerings->shouldReceive('findAll')->once()
->with(0, Offering::KIND_GROUP_CLASS)->andReturn([$offering]);
// Detail rosters are looked up by the class's own instructor (7).
$this->enrollments->shouldReceive('findByInstructor')->once()->with(7)->andReturn([]);
$this->access->shouldReceive('findByOffering')->once()->with(8)->andReturn([]);
ob_start();
$this->controller->renderPage();
$html = (string) ob_get_clean();
self::assertStringContainsString('Class details', $html);
self::assertStringContainsString('Add students directly', $html);
self::assertStringContainsString('Invite by email', $html);
}
public function testDeniesUsersWithoutViewLessonsCapability(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\expect('wp_die')->once()->andThrow(new \RuntimeException('denied'));
$this->expectException(\RuntimeException::class);
$this->controller->renderInstructorPage();
}
protected function tearDown(): void
{
$_POST = [];
$_GET = [];
parent::tearDown();
}
private function inviteOnlyOffering(float $price = 0.0): Offering
{
return new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Private Choir',
price: $price,
accessMode: Offering::ACCESS_INVITE_ONLY,
id: 8,
);
}
/** Stub the form-processing helpers and the render tail shared by all action tests. */
private function stubActionContext(): void
{
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\when('wp_unslash')->returnArg();
Functions\when('sanitize_email')->returnArg();
Functions\when('absint')->alias(static fn ($v) => abs((int) $v));
// Render tail: no classes/enrolments to draw so the assertion targets the notice.
$this->offerings->shouldReceive('findAll')->with(3, Offering::KIND_GROUP_CLASS)->andReturn([]);
$this->enrollments->shouldReceive('findByInstructor')->with(3)->andReturn([]);
}
public function testAddDirectEnrolsStudentWithPendingPayment(): void
{
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
$this->enrollments->shouldReceive('insert')->once()->andReturn(44);
$payment = new Payment(
studentId: 5,
instructorId: 3,
registrationType: Payment::REG_ENROLLMENT,
registrationId: 44,
amount: 100.0,
method: Payment::METHOD_ETRANSFER,
status: Payment::STATUS_PENDING,
id: 12,
);
$this->paymentService->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 100.0, 'CAD', null)
->andReturn($payment);
$this->enrollments->shouldReceive('setPaymentId')->once()->with(44, 12)->andReturn(true);
$this->access->shouldReceive('markEnrolled')->once()->with(8, 5);
$html = $this->renderInstructor();
self::assertStringContainsString('1 student(s) added to the class.', $html);
}
public function testGrantAccessCreatesGrantAndEmailsStudent(): void
{
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
$this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(false);
$this->access->shouldReceive('insert')->once()->andReturn(1);
$user = Mockery::mock(\WP_User::class);
$user->user_email = '[email protected]';
Functions\when('get_userdata')->justReturn($user);
$this->mailer->shouldReceive('sendClassAccessGranted')->once()->with($user, 'Private Choir')->andReturn(true);
$html = $this->renderInstructor();
self::assertStringContainsString('1 student(s) granted access.', $html);
}
public function testInviteEmailForNewAddressCreatesInviteAndSendsLink(): void
{
$_POST = ['usc_action' => 'invite_email', 'offering_id' => 8, 'email' => '[email protected]'];
$this->stubActionContext();
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
$this->invites->shouldReceive('findPendingByEmail')->with('[email protected]')->andReturn(null);
Functions\when('wp_generate_password')->justReturn('rawtoken');
Functions\when('get_option')->justReturn(0);
Functions\when('home_url')->justReturn('http://home.test/');
Functions\when('add_query_arg')->justReturn('http://home.test/?us_invite=rawtoken');
$this->invites->shouldReceive('insert')->once()->andReturn(7);
$this->access->shouldReceive('insert')->once()->andReturn(2);
$this->mailer->shouldReceive('sendClassInvite')->once()->with('[email protected]', 'http://home.test/?us_invite=rawtoken', 'Private Choir')->andReturn(true);
$html = $this->renderInstructor();
self::assertStringContainsString('Invitation sent.', $html);
}
public function testInviteEmailReusesPendingInviteWithoutSendingLink(): void
{
$_POST = ['usc_action' => 'invite_email', 'offering_id' => 8, 'email' => '[email protected]'];
$this->stubActionContext();
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
$this->invites->shouldReceive('findPendingByEmail')->with('[email protected]')->andReturn(
new \Unsupervised\Schedular\Auth\Invite(email: '[email protected]', token: 'hash', id: 9)
);
// No new invite row and no email — just a grant attached to the existing invite.
$this->invites->shouldReceive('insert')->never();
$this->mailer->shouldReceive('sendClassInvite')->never();
$this->access->shouldReceive('insert')->once()->andReturn(2);
$html = $this->renderInstructor();
self::assertStringContainsString('No new link was sent.', $html);
}
public function testInviteEmailForExistingAccountGrantsAccess(): void
{
$_POST = ['usc_action' => 'invite_email', 'offering_id' => 8, 'email' => '[email protected]'];
$this->stubActionContext();
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(55);
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 55)->andReturn(false);
$this->access->shouldReceive('hasGrant')->with(8, 55)->andReturn(false);
$this->access->shouldReceive('insert')->once()->andReturn(3);
$user = Mockery::mock(\WP_User::class);
$user->user_email = '[email protected]';
Functions\when('get_userdata')->justReturn($user);
$this->mailer->shouldReceive('sendClassAccessGranted')->once()->andReturn(true);
$html = $this->renderInstructor();
self::assertStringContainsString('1 student(s) granted access.', $html);
}
public function testActionRejectedForClassNotOwnedByPlainInstructor(): void
{
// A plain instructor (no view_all_lessons) may only manage their own classes.
Functions\when('current_user_can')->alias(
static fn (string $cap) => 'view_all_lessons' !== $cap
);
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
// Offering owned by a different instructor (7, not the current user 3).
$foreign = new Offering(instructorId: 7, kind: Offering::KIND_GROUP_CLASS, title: 'Other', accessMode: Offering::ACCESS_INVITE_ONLY, id: 8);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($foreign);
$this->enrollments->shouldReceive('insert')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('That group class was not found.', $html);
}
public function testStudioAdminCanManageInviteForAnotherInstructorsClass(): void
{
// current_user_can returns true for everything (incl. view_all_lessons),
// so the studio admin may add students to a class they do not own.
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
$foreign = new Offering(instructorId: 7, kind: Offering::KIND_GROUP_CLASS, title: 'Other', price: 100.0, accessMode: Offering::ACCESS_INVITE_ONLY, id: 8);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($foreign);
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
$this->enrollments->shouldReceive('insert')->once()->andReturn(44);
// The enrolment and payment use the class's own instructor (7), not the admin.
$payment = new Payment(
studentId: 5,
instructorId: 7,
registrationType: Payment::REG_ENROLLMENT,
registrationId: 44,
amount: 100.0,
status: Payment::STATUS_PENDING,
id: 12,
);
$this->paymentService->shouldReceive('createForRegistration')
->once()->with(Payment::REG_ENROLLMENT, 44, 5, 7, 100.0, 'CAD', null)->andReturn($payment);
$this->enrollments->shouldReceive('setPaymentId')->once()->with(44, 12)->andReturn(true);
$this->access->shouldReceive('markEnrolled')->once()->with(8, 5);
$html = $this->renderInstructor();
self::assertStringContainsString('1 student(s) added to the class.', $html);
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Offering;
use Mockery;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class ClassSlotReconcilerTest extends TestCase
{
private AvailabilityRepository&Mockery\MockInterface $availability;
private ClassSlotReconciler $reconciler;
protected function setUp(): void
{
parent::setUp();
$this->availability = Mockery::mock(AvailabilityRepository::class);
$this->reconciler = new ClassSlotReconciler($this->availability);
}
private function groupClass(?string $termEnd = null): Offering
{
return new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: $termEnd ?? '2026-09-08',
classTime: '16:00:00',
id: 8,
);
}
public function testRemovesOpenSlotsOverlappingASession(): void
{
$open = new AvailabilitySlot(3, '2026-09-08 16:00:00', '2026-09-08 17:00:00', 60, id: 12);
$this->availability->shouldReceive('findOverlapping')
->once()->with(3, '2026-09-08 16:00:00', '2026-09-08 17:00:00')
->andReturn([$open]);
$this->availability->shouldReceive('delete')->once()->with(12)->andReturn(true);
$result = $this->reconciler->reconcile($this->groupClass());
self::assertSame(1, $result['removed']);
self::assertSame([], $result['conflicts']);
}
public function testReportsBookedSlotAsConflictWithoutDeleting(): void
{
$booked = new AvailabilitySlot(3, '2026-09-08 16:00:00', '2026-09-08 17:00:00', 60, isBooked: true, id: 12);
$this->availability->shouldReceive('findOverlapping')->once()->andReturn([$booked]);
// A booked lesson is never deleted.
$this->availability->shouldReceive('delete')->never();
$result = $this->reconciler->reconcile($this->groupClass());
self::assertSame(0, $result['removed']);
self::assertSame(['2026-09-08 16:00:00'], $result['conflicts']);
}
public function testWeeklyClassReconcilesEverySession(): void
{
// Three weekly sessions from Sep 8 to Sep 22.
$this->availability->shouldReceive('findOverlapping')
->once()->with(3, '2026-09-08 16:00:00', '2026-09-08 17:00:00')->andReturn([]);
$this->availability->shouldReceive('findOverlapping')
->once()->with(3, '2026-09-15 16:00:00', '2026-09-15 17:00:00')->andReturn([]);
$this->availability->shouldReceive('findOverlapping')
->once()->with(3, '2026-09-22 16:00:00', '2026-09-22 17:00:00')->andReturn([]);
$result = $this->reconciler->reconcile($this->groupClass('2026-09-22'));
self::assertSame(0, $result['removed']);
}
public function testUnscheduledClassIsSkipped(): void
{
// No class time set — nothing to reconcile.
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
durationMinutes: 60,
termStart: '2026-09-08',
id: 8,
);
$this->availability->shouldReceive('findOverlapping')->never();
$result = $this->reconciler->reconcile($offering);
self::assertSame(['removed' => 0, 'conflicts' => []], $result);
}
public function testPrivateLessonOfferingIsSkipped(): void
{
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_PRIVATE_LESSON,
title: '30 min',
durationMinutes: 30,
termStart: '2026-09-08',
termEnd: '2026-09-08',
classTime: '16:00:00',
id: 8,
);
$this->availability->shouldReceive('findOverlapping')->never();
$result = $this->reconciler->reconcile($offering);
self::assertSame(['removed' => 0, 'conflicts' => []], $result);
}
}
+123 -1
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Offering;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -13,6 +14,7 @@ use Unsupervised\Schedular\Tests\Unit\TestCase;
class OfferingControllerTest extends TestCase
{
private OfferingRepository&Mockery\MockInterface $repository;
private ClassSlotReconciler&Mockery\MockInterface $reconciler;
private OfferingController $controller;
protected function setUp(): void
@@ -20,13 +22,16 @@ class OfferingControllerTest extends TestCase
parent::setUp();
$this->repository = Mockery::mock(OfferingRepository::class);
$this->controller = new OfferingController($this->repository);
$this->reconciler = Mockery::mock(ClassSlotReconciler::class);
$this->reconciler->shouldReceive('reconcile')->andReturn(['removed' => 0, 'conflicts' => []])->byDefault();
$this->controller = new OfferingController($this->repository, $this->reconciler);
$_POST = [];
$_GET = [];
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3);
Functions\when('get_users')->justReturn([]);
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('admin_url')->justReturn('admin.php?page=us-offerings');
Functions\when('add_query_arg')->alias(
@@ -70,6 +75,123 @@ class OfferingControllerTest extends TestCase
$this->render();
}
public function testAddGroupClassStoresClassTimeAndReconcilesSlots(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Ballet Beginners',
'kind' => Offering::KIND_GROUP_CLASS,
'term_start' => '2026-09-08',
'class_time' => '16:30',
'duration_minutes' => '60',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => '16:30:00' === $o->classTime
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
// A scheduled class is reconciled against the instructor's availability,
// and the resulting notice is surfaced to the admin.
$this->reconciler->shouldReceive('reconcile')->once()->with(Mockery::on(
static fn (Offering $o) => '16:30:00' === $o->classTime
))->andReturn(['removed' => 2, 'conflicts' => []]);
$html = $this->render();
self::assertStringContainsString('2 open booking slots were removed', $html);
}
public function testGarbageClassTimeIsRejected(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Choir',
'kind' => Offering::KIND_GROUP_CLASS,
'class_time' => 'not-a-time',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => null === $o->classTime
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->render();
}
public function testStudioAdminAssignsClassToChosenInstructor(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Choir',
'kind' => Offering::KIND_GROUP_CLASS,
'class_instructor_id' => '7',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => 7 === $o->instructorId
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->render();
}
public function testInstructorCannotReassignClassToAnotherInstructor(): void
{
// A plain instructor (no manage_instructors) — the posted instructor id
// must be ignored so the class stays theirs.
Functions\when('current_user_can')->alias(
static fn (string $cap) => 'manage_instructors' !== $cap
);
$_POST = [
'usc_action' => 'add',
'title' => 'Choir',
'kind' => Offering::KIND_GROUP_CLASS,
'class_instructor_id' => '7',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => 3 === $o->instructorId
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->render();
}
public function testAddInviteOnlyGroupClassStoresInviteOnlyAccess(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Private Choir',
'kind' => Offering::KIND_GROUP_CLASS,
'invite_only' => '1',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => Offering::ACCESS_INVITE_ONLY === $o->accessMode
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->render();
}
public function testAddWithoutInviteOnlyDefaultsToPublicAccess(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Open Choir',
'kind' => Offering::KIND_GROUP_CLASS,
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => Offering::ACCESS_PUBLIC === $o->accessMode
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->render();
}
public function testAddOneOffGroupClassEndsOnItsStartDate(): void
{
$_POST = [
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Offering;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class OfferingEndpointTest extends TestCase
{
private OfferingRepository&Mockery\MockInterface $repository;
private GroupAccessRepository&Mockery\MockInterface $access;
private OfferingEndpoint $endpoint;
protected function setUp(): void
{
parent::setUp();
Functions\when('get_current_user_id')->justReturn(5);
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']);
$this->repository = Mockery::mock(OfferingRepository::class);
$this->access = Mockery::mock(GroupAccessRepository::class);
$this->endpoint = new OfferingEndpoint($this->repository, $this->access);
}
private function group(int $id, string $access): Offering
{
return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: "Class $id", accessMode: $access, id: $id);
}
public function testIndexReturnsPublicOfferingsOnlyWhenNoGrants(): void
{
$this->repository->shouldReceive('findAll')
->once()
->with(0, '', Mockery::on(static fn ($v): bool => true === $v), Offering::ACCESS_PUBLIC)
->andReturn([$this->group(1, Offering::ACCESS_PUBLIC)]);
$this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([]);
$data = $this->endpoint->index(new \WP_REST_Request())->get_data();
self::assertCount(1, $data);
self::assertSame(1, $data[0]['id']);
}
public function testIndexMergesGrantedInviteOnlyOfferings(): void
{
$this->repository->shouldReceive('findAll')
->once()
->with(0, '', Mockery::any(), Offering::ACCESS_PUBLIC)
->andReturn([$this->group(1, Offering::ACCESS_PUBLIC)]);
$this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([8]);
$this->repository->shouldReceive('findById')->with(8)->andReturn($this->group(8, Offering::ACCESS_INVITE_ONLY));
$data = $this->endpoint->index(new \WP_REST_Request())->get_data();
self::assertSame([1, 8], array_column($data, 'id'));
}
public function testIndexOmitsGrantedOfferingThatIsNoLongerInviteOnly(): void
{
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([8]);
// Grant persists but the class was flipped back to public — it is already
// in the public list, so it must not be appended a second time.
$this->repository->shouldReceive('findById')->with(8)->andReturn($this->group(8, Offering::ACCESS_PUBLIC));
$data = $this->endpoint->index(new \WP_REST_Request())->get_data();
self::assertSame([], $data);
}
public function testIndexRespectsKindFilterForGrantedOfferings(): void
{
$this->repository->shouldReceive('findAll')
->with(0, Offering::KIND_PRIVATE_LESSON, Mockery::any(), Offering::ACCESS_PUBLIC)
->andReturn([]);
$this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([8]);
// Granted class is a group class; the request filters to private lessons.
$this->repository->shouldReceive('findById')->with(8)->andReturn($this->group(8, Offering::ACCESS_INVITE_ONLY));
$data = $this->endpoint->index(new \WP_REST_Request(['kind' => Offering::KIND_PRIVATE_LESSON]))->get_data();
self::assertSame([], $data);
}
public function testIndexIncludesInstructorNameForEachOffering(): void
{
$instructor = Mockery::mock(\WP_User::class);
$instructor->first_name = 'Ada';
$instructor->last_name = 'Lovelace';
$instructor->nickname = 'ada_login';
$instructor->display_name = 'ada_login';
Functions\when('get_userdata')->justReturn($instructor);
$this->repository->shouldReceive('findAll')->andReturn([$this->group(1, Offering::ACCESS_PUBLIC)]);
$this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([]);
$data = $this->endpoint->index(new \WP_REST_Request())->get_data();
// Real name is shown, not the login-style display name.
self::assertSame('Ada Lovelace', $data[0]['instructor_name']);
}
public function testIndexOmitsEtransferEmailFromPublicListing(): void
{
$this->repository->shouldReceive('findAll')->andReturn([
new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', etransferEmail: '[email protected]', id: 1),
]);
$this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([]);
$data = $this->endpoint->index(new \WP_REST_Request())->get_data();
self::assertArrayNotHasKey('etransfer_email', $data[0]);
}
}
@@ -154,6 +154,44 @@ class OfferingRepositoryTest extends TestCase
$this->repo->findAll(3, Offering::KIND_GROUP_CLASS);
}
public function testFindAllFiltersByAccessMode(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/access_mode = %s/'),
Mockery::on(static fn (array $p): bool => $p === ['wp_us_offerings', Offering::ACCESS_PUBLIC])
)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([]);
self::assertSame([], $this->repo->findAll(accessMode: Offering::ACCESS_PUBLIC));
}
public function testInsertPersistsAccessMode(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_offerings',
Mockery::on(static fn (array $data): bool => $data['access_mode'] === Offering::ACCESS_INVITE_ONLY),
Mockery::type('array')
);
$this->db->insert_id = 1;
$offering = new Offering(
instructorId: 5,
kind: Offering::KIND_GROUP_CLASS,
title: 'Private Choir',
accessMode: Offering::ACCESS_INVITE_ONLY,
);
self::assertSame(1, $this->repo->insert($offering));
}
public function testDeleteCallsWpdbDelete(): void
{
$this->db->shouldReceive('delete')
@@ -182,6 +220,7 @@ class OfferingRepositoryTest extends TestCase
'term_end' => null,
'schedule_note' => null,
'etransfer_email' => null,
'cancellation_cutoff_hours' => null,
'is_active' => '1',
];
}
+148 -1
View File
@@ -52,6 +52,82 @@ class OfferingTest extends TestCase
self::assertSame('2026-09-08', Offering::weeklyTermEnd('2026-09-08', 0));
}
public function testNormalizeTimeAcceptsHtmlTimeInput(): void
{
self::assertSame('16:30:00', Offering::normalizeTime('16:30'));
self::assertSame('09:00:00', Offering::normalizeTime('09:00:00'));
}
public function testNormalizeTimeRejectsGarbage(): void
{
self::assertNull(Offering::normalizeTime(''));
self::assertNull(Offering::normalizeTime('25:00'));
self::assertNull(Offering::normalizeTime('not-a-time'));
}
public function testSessionWindowsForOneOffClass(): void
{
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Recital',
durationMinutes: 90,
termStart: '2026-09-08',
termEnd: '2026-09-08',
classTime: '16:00:00',
);
self::assertSame(
[['start' => '2026-09-08 16:00:00', 'end' => '2026-09-08 17:30:00']],
$offering->sessionWindows()
);
}
public function testSessionWindowsWalksWeeklyTerm(): void
{
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: '2026-09-22',
classTime: '16:00:00',
);
$windows = $offering->sessionWindows();
self::assertCount(3, $windows);
self::assertSame('2026-09-08 16:00:00', $windows[0]['start']);
self::assertSame('2026-09-22 16:00:00', $windows[2]['start']);
self::assertSame('2026-09-22 17:00:00', $windows[2]['end']);
}
public function testSessionWindowsEmptyWhenScheduleIncomplete(): void
{
// No class time.
$noTime = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: '2026-09-08',
);
self::assertSame([], $noTime->sessionWindows());
// No duration.
$noDuration = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2026-09-08',
termEnd: '2026-09-08',
classTime: '16:00:00',
);
self::assertSame([], $noDuration->sessionWindows());
}
public function testDefaults(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
@@ -84,6 +160,7 @@ class OfferingTest extends TestCase
'term_end' => '2027-06-30',
'schedule_note' => 'Tuesdays 4:00pm',
'etransfer_email' => null,
'cancellation_cutoff_hours' => '48',
'is_active' => '1',
];
@@ -95,19 +172,89 @@ class OfferingTest extends TestCase
self::assertSame(120.00, $offering->price);
self::assertSame(20, $offering->capacity);
self::assertSame(Offering::BILLING_FULL_TERM, $offering->billingMode);
self::assertSame(48, $offering->cancellationCutoffHours);
self::assertTrue($offering->isActive);
}
public function testFromRowMapsNullCancellationCutoff(): void
{
$row = (object) [
'id' => '7',
'instructor_id' => '3',
'kind' => Offering::KIND_PRIVATE_LESSON,
'title' => '30 min lesson',
'description' => null,
'duration_minutes' => '30',
'price' => '40.00',
'currency' => 'CAD',
'billing_mode' => Offering::BILLING_ONE_TIME,
'allow_weekly' => '0',
'capacity' => null,
'term_start' => null,
'term_end' => null,
'schedule_note' => null,
'etransfer_email' => null,
'cancellation_cutoff_hours' => null,
'is_active' => '1',
];
$offering = Offering::fromRow($row);
self::assertNull($offering->cancellationCutoffHours);
}
public function testToArrayContainsExpectedKeys(): void
{
$offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', id: 10);
$arr = $offering->toArray();
foreach (['id', 'instructor_id', 'kind', 'title', 'price', 'billing_mode', 'is_active'] as $key) {
foreach (['id', 'instructor_id', 'kind', 'title', 'price', 'billing_mode', 'access_mode', 'is_active'] as $key) {
self::assertArrayHasKey($key, $arr);
}
}
public function testDefaultsToPublicAccess(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', id: 10);
self::assertSame(Offering::ACCESS_PUBLIC, $offering->accessMode);
self::assertFalse($offering->isInviteOnly());
}
public function testInviteOnlyAccessIsReported(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', accessMode: Offering::ACCESS_INVITE_ONLY, id: 10);
self::assertTrue($offering->isInviteOnly());
self::assertSame(Offering::ACCESS_INVITE_ONLY, $offering->toArray()['access_mode']);
}
public function testFromRowReadsInviteOnlyAccessMode(): void
{
$row = (object) [
'id' => '7',
'instructor_id' => '3',
'kind' => Offering::KIND_GROUP_CLASS,
'title' => 'Private Choir',
'description' => null,
'duration_minutes' => null,
'price' => '0.00',
'currency' => 'CAD',
'billing_mode' => Offering::BILLING_FULL_TERM,
'allow_weekly' => '0',
'capacity' => null,
'term_start' => null,
'term_end' => null,
'schedule_note' => null,
'etransfer_email' => null,
'cancellation_cutoff_hours' => null,
'access_mode' => Offering::ACCESS_INVITE_ONLY,
'is_active' => '1',
];
self::assertTrue(Offering::fromRow($row)->isInviteOnly());
}
public function testToArrayIncludesEtransferEmailByDefault(): void
{
$offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', etransferEmail: '[email protected]', id: 10);
+23
View File
@@ -20,6 +20,29 @@ class StudioSettingsTest extends TestCase
self::assertFalse($settings->openRegistrationEnabled());
}
public function testCancellationCutoffDefaultsToOneDay(): void
{
Functions\when('get_option')->alias(static fn (string $name, $default) => $default);
self::assertSame(24, (new StudioSettings())->cancellationCutoffHours());
}
public function testCancellationCutoffReadsStoredHours(): void
{
Functions\when('get_option')->alias(static fn (string $name) =>
$name === StudioSettings::OPT_CANCELLATION_CUTOFF_HOURS ? '72' : '');
self::assertSame(72, (new StudioSettings())->cancellationCutoffHours());
}
public function testCancellationCutoffClampsNegativeToZero(): void
{
Functions\when('get_option')->alias(static fn (string $name) =>
$name === StudioSettings::OPT_CANCELLATION_CUTOFF_HOURS ? '-5' : '');
self::assertSame(0, (new StudioSettings())->cancellationCutoffHours());
}
public function testOpenRegistrationEnabledWhenStored(): void
{
Functions\when('get_option')->alias(static fn (string $name) =>
+10
View File
@@ -53,5 +53,15 @@ class AnswerTest extends TestCase
{
self::assertContains(Answer::REG_LESSON, Answer::VALID_REGISTRATION_TYPES);
self::assertContains(Answer::REG_ENROLLMENT, Answer::VALID_REGISTRATION_TYPES);
self::assertContains(Answer::REG_ACCOUNT, Answer::VALID_REGISTRATION_TYPES);
}
public function testAccountAnswerTargetsTheUser(): void
{
$answer = new Answer(3, Answer::REG_ACCOUNT, 42, 42, 'By a friend');
self::assertSame(Answer::REG_ACCOUNT, $answer->registrationType);
self::assertSame(42, $answer->registrationId);
self::assertSame(42, $answer->studentId);
}
}
@@ -115,6 +115,7 @@ class QuestionRepositoryTest extends TestCase
$row = (object) [
'id' => '3',
'offering_id' => '7',
'scope' => Question::SCOPE_OFFERING,
'label' => 'Q',
'field_type' => Question::FIELD_TEXT,
'options' => null,
@@ -132,6 +133,68 @@ class QuestionRepositoryTest extends TestCase
self::assertInstanceOf(Question::class, $questions[0]);
}
public function testInsertAccountQuestionStoresScopeAndNullOffering(): void
{
Functions\expect('current_time')->andReturn('2026-04-01 12:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_questions',
Mockery::on(static function (array $data): bool {
return $data['offering_id'] === null
&& $data['scope'] === Question::SCOPE_ACCOUNT
&& $data['label'] === 'Emergency contact';
}),
Mockery::type('array')
);
$this->db->insert_id = 30;
$question = new Question(null, 'Emergency contact', scope: Question::SCOPE_ACCOUNT);
self::assertSame(30, $this->repo->insert($question));
}
public function testFindByScopeActiveOnlyPreparesQuery(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/scope = %s AND is_active = %d/'),
Mockery::on(static fn (array $p): bool => $p === ['wp_us_questions', Question::SCOPE_ACCOUNT, 1])
)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([]);
self::assertSame([], $this->repo->findByScope(Question::SCOPE_ACCOUNT, activeOnly: true));
}
public function testFindByScopeReturnsQuestions(): void
{
$row = (object) [
'id' => '5',
'offering_id' => null,
'scope' => Question::SCOPE_ACCOUNT,
'label' => 'How did you hear about us?',
'field_type' => Question::FIELD_TEXT,
'options' => null,
'is_required' => '1',
'sort_order' => '0',
'is_active' => '1',
];
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([$row]);
$questions = $this->repo->findByScope(Question::SCOPE_ACCOUNT);
self::assertCount(1, $questions);
self::assertNull($questions[0]->offeringId);
self::assertSame(Question::SCOPE_ACCOUNT, $questions[0]->scope);
}
public function testFindByIdReturnsNullWhenNotFound(): void
{
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
+40 -1
View File
@@ -19,14 +19,24 @@ class QuestionTest extends TestCase
self::assertFalse($question->isRequired);
self::assertSame(0, $question->sortOrder);
self::assertTrue($question->isActive);
self::assertSame(Question::SCOPE_OFFERING, $question->scope);
self::assertNull($question->id);
}
public function testAccountScopeQuestionHasNoOffering(): void
{
$question = new Question(null, 'Emergency contact', scope: Question::SCOPE_ACCOUNT);
self::assertNull($question->offeringId);
self::assertSame(Question::SCOPE_ACCOUNT, $question->scope);
}
public function testFromRowDecodesOptionsJson(): void
{
$row = (object) [
'id' => '3',
'offering_id' => '7',
'scope' => Question::SCOPE_OFFERING,
'label' => 'Pick a level',
'field_type' => Question::FIELD_SELECT,
'options' => '["Beginner","Advanced"]',
@@ -42,6 +52,7 @@ class QuestionTest extends TestCase
self::assertSame(['Beginner', 'Advanced'], $question->options);
self::assertTrue($question->isRequired);
self::assertSame(2, $question->sortOrder);
self::assertSame(Question::SCOPE_OFFERING, $question->scope);
}
public function testFromRowHandlesNullOptions(): void
@@ -49,6 +60,7 @@ class QuestionTest extends TestCase
$row = (object) [
'id' => '4',
'offering_id' => '7',
'scope' => Question::SCOPE_OFFERING,
'label' => 'Notes',
'field_type' => Question::FIELD_TEXTAREA,
'options' => null,
@@ -63,12 +75,33 @@ class QuestionTest extends TestCase
self::assertFalse($question->isActive);
}
public function testFromRowHandlesAccountScopeWithNullOffering(): void
{
$row = (object) [
'id' => '5',
'offering_id' => null,
'scope' => Question::SCOPE_ACCOUNT,
'label' => 'How did you hear about us?',
'field_type' => Question::FIELD_TEXT,
'options' => null,
'is_required' => '1',
'sort_order' => '0',
'is_active' => '1',
];
$question = Question::fromRow($row);
self::assertNull($question->offeringId);
self::assertSame(Question::SCOPE_ACCOUNT, $question->scope);
self::assertTrue($question->isRequired);
}
public function testToArrayContainsExpectedKeys(): void
{
$question = new Question(7, 'Label', Question::FIELD_TEXT, id: 9);
$arr = $question->toArray();
foreach (['id', 'offering_id', 'label', 'field_type', 'options', 'is_required', 'sort_order', 'is_active'] as $key) {
foreach (['id', 'offering_id', 'scope', 'label', 'field_type', 'options', 'is_required', 'sort_order', 'is_active'] as $key) {
self::assertArrayHasKey($key, $arr);
}
}
@@ -80,4 +113,10 @@ class QuestionTest extends TestCase
self::assertContains(Question::FIELD_SELECT, Question::VALID_FIELD_TYPES);
self::assertContains(Question::FIELD_CHECKBOX, Question::VALID_FIELD_TYPES);
}
public function testValidScopeConstants(): void
{
self::assertContains(Question::SCOPE_OFFERING, Question::VALID_SCOPES);
self::assertContains(Question::SCOPE_ACCOUNT, Question::VALID_SCOPES);
}
}
+35
View File
@@ -35,10 +35,45 @@ class UpdateCheckerTest extends TestCase
public function testRegisterHooksHostnameFilter(): void
{
Filters\expectAdded('update_plugins_git.unsupervised.ca')->once();
Filters\expectAdded('plugin_row_meta')->once();
(new UpdateChecker())->register();
}
public function testFilterRowMetaReplacesViewDetailsLinkForThisPlugin(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
$meta = [
'Version 1.0.0',
'<a href="https://wordpress.org/plugin-install.php?...&plugin=unsupervised-schedular" class="thickbox open-plugin-details-modal">View details</a>',
];
$result = (new UpdateChecker())->filterRowMeta($meta, self::PLUGIN_FILE);
// Core's thickbox modal link is gone.
foreach ($result as $item) {
self::assertStringNotContainsString('open-plugin-details-modal', $item);
}
// Replaced with a new-tab link to the Gitea release tag for USC_VERSION (1.0.0 in tests).
self::assertStringContainsString(
UpdateChecker::REPO_URL . '/releases/tag/v1.0.0',
end($result)
);
self::assertStringContainsString('target="_blank"', end($result));
}
public function testFilterRowMetaLeavesOtherPluginsUntouched(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
$meta = ['<a href="#" class="thickbox open-plugin-details-modal">View details</a>'];
$result = (new UpdateChecker())->filterRowMeta($meta, 'other-plugin/other-plugin.php');
self::assertSame($meta, $result);
}
public function testOffersUpdateWhenReleaseIsNewer(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
+4 -3
View File
@@ -1,12 +1,13 @@
<?php
/**
* Plugin Name: Unsupervised Scheduler
* Plugin URI: https://unsupervised.ca
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.0.0
* Version: 1.1.0
* Requires at least: 6.2
* Requires PHP: 8.1
* Author: Unsupervised
* Author URI: https://unsupervised.ca
* License: GPL-2.0-or-later
* Text Domain: unsupervised-schedular
* Update URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
@@ -20,7 +21,7 @@ if (! defined('ABSPATH')) {
exit;
}
define('USC_VERSION', '1.0.0');
define('USC_VERSION', '1.1.0');
define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));