1 Commits
Author SHA1 Message Date
thatguygriffandClaude Opus 4.8 5140e76347 Harden booking, offering exposure, payments, and invites
CI / No Debug Code (pull_request) Successful in 11s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m42s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m17s
CI / Tests (PHP 8.1) (pull_request) Successful in 2m17s
CI / Coding Standards (pull_request) Successful in 2m26s
CI / PHPStan (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Has been skipped
Security fixes from a pen-test review (issues #31–#37):

- #31 Booking no longer trusts a client-supplied offering_id: a slot-tied
  offering is authoritative and any offering used must belong to the slot's
  instructor, closing a free/misrouted-payment bypass.
- #34 Availability slot creation rejects an offering the instructor does not
  own (AvailabilityEndpoint now takes OfferingRepository).
- #32 Offering/question/policy listing endpoints now require book_lesson
  instead of being public (no anonymous consumer exists); Offering::toArray
  also omits etransfer_email from listings as defense-in-depth.
- #33 Slots are claimed atomically (UPDATE ... WHERE is_booked = 0) before a
  lesson is inserted, preventing a double-booking race.
- #35 A single weekly booking is capped (MAX_WEEKLY_OCCURRENCES) and only
  creates lessons for slots it actually claimed.
- #36 Stripe secret/webhook keys are write-only in the settings UI and a blank
  submit keeps the stored value; secrets are never echoed back into HTML.
- #37 Pending invites expire after 14 days (Invite::isAcceptable), enforced at
  registration and surfaced on the admin invites list.

Adds BookingEndpointTest plus Invite/Offering/AvailabilityRepository coverage
and minimal WP_REST_Request/WP_REST_Response stubs. composer test (200),
lint, and cs all green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-09 17:00:54 -03:00
165 changed files with 839 additions and 13619 deletions
+1 -2
View File
@@ -97,8 +97,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Check for debug statements - name: Check for debug statements
run: | run: |
# \b keeps method calls like DateTimeImmutable::add() from matching dd(. if grep -rn --include="*.php" -E "(var_dump|var_export|print_r|error_log|dd\(|dump\()" src/; then
if grep -rn --include="*.php" -E "\b(var_dump|var_export|print_r|error_log|dd|dump)\s*\(" src/; then
echo "Debug code found in src/ — please remove before merging." echo "Debug code found in src/ — please remove before merging."
exit 1 exit 1
fi fi
-165
View File
@@ -1,165 +0,0 @@
name: Release
# Fires when a v* tag is pushed — including tags created through Gitea's
# "New Release" UI. Builds the distributable plugin zip and attaches it to
# the release for that tag (creating the release if only a bare tag was
# pushed). The attached zip is what UpdateChecker serves to WordPress
# sites as the update package.
on:
push:
tags:
- 'v*'
jobs:
release:
name: Build and Publish Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
# A tag that disagrees with the plugin header would make sites see a
# phantom update forever (or never see a real one), so fail fast.
- name: Verify tag matches plugin version
id: meta
run: |
tag_version="${GITHUB_REF_NAME#v}"
header_version="$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*([^[:space:]]+).*/\1/p' unsupervised-schedular.php | head -1)"
if [ "$tag_version" != "$header_version" ]; then
echo "Tag ${GITHUB_REF_NAME} does not match plugin header Version: ${header_version}" >&2
exit 1
fi
echo "version=${header_version}" >> "$GITHUB_OUTPUT"
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Run tests
run: composer test
- name: Build plugin zip
run: composer build
# 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 }}
run: |
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
version="${{ steps.meta.outputs.version }}"
zip="dist/unsupervised-schedular-${version}.zip"
# Pre-release versions (1.2.3-rc.1) are flagged so Gitea's
# /releases/latest endpoint — and therefore the update checker —
# skips them.
prerelease=false
case "$version" in *-*) prerelease=true ;; esac
# Reuse the release if the tag was created via Gitea's release UI.
release_id="$(curl -sS -H "Authorization: token ${TOKEN}" \
"${api}/releases/tags/${GITHUB_REF_NAME}" | jq -r '.id // empty' || true)"
if [ -z "$release_id" ]; then
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 "${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}"
curl -fsS -X POST \
"${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
@@ -1,33 +0,0 @@
# 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.
+1 -7
View File
@@ -38,8 +38,6 @@ src/ — All plugin PHP (PSR-4 namespace: Unsupervised\Schedula
AdminMenu.php — Registers wp-admin menu pages AdminMenu.php — Registers wp-admin menu pages
RestRegistrar.php — Registers all REST routes under us-scheduler/v1 RestRegistrar.php — Registers all REST routes under us-scheduler/v1
ShortcodeRegistrar.php — Registers [us_booking] and [us_student_login] shortcodes ShortcodeRegistrar.php — Registers [us_booking] and [us_student_login] shortcodes
BlockRegistrar.php — Registers Gutenberg dynamic-block wrappers for the shortcodes
BlockPreview.php — Static editor-preview markup for the blocks
templates/ — PHP view files included by controllers/shortcodes templates/ — PHP view files included by controllers/shortcodes
assets/ — CSS and JS (vanilla JS, no build step) assets/ — CSS and JS (vanilla JS, no build step)
tests/Unit/ — PHPUnit unit tests (PSR-4: Unsupervised\Schedular\Tests\) tests/Unit/ — PHPUnit unit tests (PSR-4: Unsupervised\Schedular\Tests\)
@@ -68,9 +66,6 @@ All database access goes through repository classes within their domain package.
| `AdminMenu` | Registers wp-admin menu pages | | `AdminMenu` | Registers wp-admin menu pages |
| `RestRegistrar` | Registers all REST routes under `us-scheduler/v1` | | `RestRegistrar` | Registers all REST routes under `us-scheduler/v1` |
| `ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes | | `ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes |
| `BlockRegistrar` | Registers Gutenberg dynamic-block wrappers for the shortcodes |
| `BlockPreview` | Static editor-preview markup for the blocks |
| `Val` | Runtime coercion of untyped WP boundary values (wpdb rows, REST params, superglobals) |
| `Auth\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps | | `Auth\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps |
| `Auth\LoginPage` | Renders front-end student login form | | `Auth\LoginPage` | Renders front-end student login form |
| `Availability\AvailabilitySlot` | Immutable value object for a slot row | | `Availability\AvailabilitySlot` | Immutable value object for a slot row |
@@ -100,7 +95,6 @@ All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()
- When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method - When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method
### Adding a Feature ### Adding a Feature
0. **If the feature touches `Schema.php`, bump both the `Version:` header and `USC_VERSION` in `unsupervised-schedular.php`.** `Plugin::boot()` only re-runs `Installer`/`dbDelta` when the stored `us_schedular_version` differs, so a schema change without a version bump never reaches existing sites and inserts into new columns fail silently.
1. Write the feature doc in `docs/features/<feature-name>.md` (data model, API, classes, test paths). 1. Write the feature doc in `docs/features/<feature-name>.md` (data model, API, classes, test paths).
2. Create a domain package under `src/<Domain>/` containing all classes for that feature. 2. Create a domain package under `src/<Domain>/` containing all classes for that feature.
3. Add template(s) under `templates/` if needed. 3. Add template(s) under `templates/` if needed.
@@ -110,6 +104,6 @@ All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()
### CI ### CI
Gitea Actions (`.gitea/workflows/ci.yml`) runs on every push and pull request: Gitea Actions (`.gitea/workflows/ci.yml`) runs on every push and pull request:
- **lint** — PHPCS WordPress coding standards - **lint** — PHPCS WordPress coding standards
- **static-analysis** — PHPStan level 10 - **static-analysis** — PHPStan level 6
- **test** — PHPUnit on PHP 8.1, 8.2, 8.3 - **test** — PHPUnit on PHP 8.1, 8.2, 8.3
- **no-debug** — rejects commits with `var_dump`, `error_log`, etc. in `src/` - **no-debug** — rejects commits with `var_dump`, `error_log`, etc. in `src/`
+9 -5
View File
@@ -2,9 +2,9 @@
A WordPress plugin for instructor/student lesson scheduling — private lessons and A WordPress plugin for instructor/student lesson scheduling — private lessons and
group classes — with offerings, intake questions, versioned policies, account group classes — with offerings, intake questions, versioned policies, account
registration, and online payments. registration, and (coming) online payments.
**Version:** 1.0.0 · **Requires:** WordPress 6.0+, PHP 8.1+ · **License:** GPL-2.0-or-later **Version:** 1.0.0-rc.1 · **Requires:** WordPress 6.0+, PHP 8.1+ · **License:** GPL-2.0-or-later
> Pre-release. The booking platform is being built feature-by-feature; see > Pre-release. The booking platform is being built feature-by-feature; see
> [Implementation status](#implementation-status) below. > [Implementation status](#implementation-status) below.
@@ -31,13 +31,17 @@ model, REST API, classes, and tests. For contributor/architecture guidance see
| Availability (durations, weekly recurrence, calendar) | [availability-management.md](docs/features/availability-management.md) | ✅ Implemented | | Availability (durations, weekly recurrence, calendar) | [availability-management.md](docs/features/availability-management.md) | ✅ Implemented |
| Registration questions (per-offering intake) | [registration-questions.md](docs/features/registration-questions.md) | ✅ Implemented | | Registration questions (per-offering intake) | [registration-questions.md](docs/features/registration-questions.md) | ✅ Implemented |
| Policies (drafting, versioning, tracked acceptance) | [policies.md](docs/features/policies.md) | ✅ Implemented | | Policies (drafting, versioning, tracked acceptance) | [policies.md](docs/features/policies.md) | ✅ Implemented |
| Account registration (invite or open self-approval, email confirmation, signup policy acceptance) | [account-registration.md](docs/features/account-registration.md) | ✅ Implemented | | Account registration (invite-only, signup policy acceptance) | [account-registration.md](docs/features/account-registration.md) | ✅ Implemented |
| Lesson booking (offering → questions → policies) | [lesson-booking.md](docs/features/lesson-booking.md) | ✅ Implemented | | Lesson booking (offering → questions → policies) | [lesson-booking.md](docs/features/lesson-booking.md) | ✅ Implemented |
| Group classes (capacity-enforced enrolment) | [group-classes.md](docs/features/group-classes.md) | ✅ Implemented | | Group classes (capacity-enforced enrolment) | [group-classes.md](docs/features/group-classes.md) | ✅ Implemented |
| Student administration (studio-admin view) | [student-administration.md](docs/features/student-administration.md) | ✅ Implemented | | Student administration (studio-admin view) | [student-administration.md](docs/features/student-administration.md) | ✅ Implemented |
| Payments (Stripe card charge + e-transfer/comp + receipts + HST) | [payments.md](docs/features/payments.md) | ✅ Implemented | | Payments (e-transfer/comp + receipts + HST; Stripe card charge pending) | [payments.md](docs/features/payments.md) | 🟡 Partial |
| Payment reporting (monthly per-instructor + HST + CSV) | [payment-reporting.md](docs/features/payment-reporting.md) | ✅ Implemented | | Payment reporting (monthly per-instructor + HST + CSV) | [payment-reporting.md](docs/features/payment-reporting.md) | ✅ Implemented |
> Payments are deliberately deferred to the end: booking and enrolment ship with a
> clean seam (a lesson lands `pending`, an enrolment `active`, with `payment_id`
> null) into which the pay→confirm + receipt step plugs later.
## Shortcodes ## Shortcodes
| Shortcode | Purpose | | Shortcode | Purpose |
@@ -45,7 +49,7 @@ model, REST API, classes, and tests. For contributor/architecture guidance see
| `[us_booking]` | Student calendar + private-lesson registration flow | | `[us_booking]` | Student calendar + private-lesson registration flow |
| `[us_group_classes]` | Browse and enrol in group classes | | `[us_group_classes]` | Browse and enrol in group classes |
| `[us_student_login]` | Front-end student login | | `[us_student_login]` | Front-end student login |
| `[us_student_register]` | Account registration — invite-based, or open self-signup with email confirmation + admin approval (accepts signup policies) | | `[us_student_register]` | Invite-based account registration (accepts signup policies) |
## REST API ## REST API
-135
View File
@@ -33,138 +33,3 @@
color: #c00; color: #c00;
margin-top: 8px; margin-top: 8px;
} }
.us-my-lessons {
margin-bottom: 24px;
}
.us-my-lesson {
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px 16px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.us-my-lesson-actions {
display: flex;
gap: 12px;
align-items: center;
}
.us-cancel-lesson {
background: transparent;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px 12px;
cursor: pointer;
color: #c00;
}
.us-cancel-lesson:hover {
border-color: #c00;
}
.us-lesson-status {
font-size: 0.85em;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
background: #eee;
}
.us-lesson-status-confirmed {
background: #e2f5e5;
color: #1a7d2e;
}
.us-lesson-status-pending {
background: #fdf3d7;
color: #8a6d1a;
}
.us-view-toggle {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.us-view-toggle button {
padding: 6px 16px;
border: 1px solid #ccc;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.us-view-toggle button.us-active {
background: #333;
border-color: #333;
color: #fff;
}
.us-week-nav {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.us-week-nav button {
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.us-week-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
}
.us-week-day {
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px;
min-height: 90px;
}
.us-week-day-heading {
margin: 0 0 8px;
font-size: 0.85em;
text-align: center;
}
.us-week-slot {
display: block;
width: 100%;
margin-bottom: 6px;
}
.us-week-empty {
display: block;
text-align: center;
opacity: 0.4;
}
@media (max-width: 640px) {
.us-week-grid {
grid-template-columns: 1fr;
}
.us-week-day {
min-height: 0;
}
}
/* Shown only in block-editor previews (see BlockPreview). */
.us-editor-note {
font-size: 0.85em;
font-style: italic;
opacity: 0.7;
}
-219
View File
@@ -1,219 +0,0 @@
/* global wp */
(function () {
'use strict';
const { registerBlockType } = wp.blocks;
const { createElement: el, useState, useEffect } = wp.element;
const { useBlockProps, InspectorControls } = wp.blockEditor;
const { PanelBody, SelectControl, ToggleControl } = wp.components;
const { useSelect } = wp.data;
const apiFetch = wp.apiFetch;
const ServerSideRender = wp.serverSideRender;
const { __ } = wp.i18n;
/**
* Dropdown of published pages with a leading "default" choice.
* Values are page IDs; 0 means the default behaviour.
*/
function PageSelect(props) {
const pages = useSelect(
(select) => select('core').getEntityRecords('postType', 'page', {
per_page: -1,
orderby: 'title',
order: 'asc',
status: 'publish',
_fields: 'id,title',
}),
[]
);
const options = [{ label: props.defaultLabel, value: '0' }].concat(
(pages || []).map((page) => ({
label: (page.title && page.title.rendered) || __('(no title)', 'unsupervised-schedular'),
value: String(page.id),
}))
);
return el(SelectControl, {
label: props.label,
help: props.help,
value: String(props.value || 0),
options: options,
onChange: (value) => props.onChange(parseInt(value, 10) || 0),
});
}
/**
* Dropdown of active group classes fetched from the plugin's public
* offerings endpoint. Values are offering IDs; 0 means all classes.
*/
function GroupClassSelect(props) {
const [offerings, setOfferings] = useState(null);
useEffect(() => {
apiFetch({ path: '/us-scheduler/v1/offerings?kind=group_class' })
.then(setOfferings)
.catch(() => setOfferings([]));
}, []);
const options = [{ label: __('All classes', 'unsupervised-schedular'), value: '0' }].concat(
(offerings || []).map((o) => ({
label: o.title || __('(no title)', 'unsupervised-schedular'),
value: String(o.id),
}))
);
// A previously chosen class that is no longer offered (deleted or
// deactivated) keeps its stored id visible instead of silently
// pretending "All classes" is selected.
const value = String(props.value || 0);
if (offerings !== null && !options.some((opt) => opt.value === value)) {
options.push({
label: __('Unavailable class #', 'unsupervised-schedular') + value,
value: value,
});
}
return el(SelectControl, {
label: props.label,
help: props.help,
value: value,
options: options,
onChange: (newValue) => props.onChange(parseInt(newValue, 10) || 0),
});
}
const blocks = [
{
name: 'us-scheduler/booking',
title: __('Lesson Booking', 'unsupervised-schedular'),
description: __('Lets students browse availability and book lessons. Shows a styled preview in the editor.', 'unsupervised-schedular'),
icon: 'calendar-alt',
keywords: ['booking', 'lesson', 'schedule'],
shortcode: 'us_booking',
attributes: {
loginPageId: { type: 'number', default: 0 },
autoRedirect: { type: 'boolean', default: false },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('Logged-out visitors', 'unsupervised-schedular') },
el(PageSelect, {
label: __('Login page', 'unsupervised-schedular'),
help: __('Where the log-in link sends visitors who are not logged in.', 'unsupervised-schedular'),
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }),
}),
el(ToggleControl, {
label: __('Redirect automatically', 'unsupervised-schedular'),
help: __('Send logged-out visitors straight to the login page instead of showing a link.', 'unsupervised-schedular'),
checked: !!attributes.autoRedirect,
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
})
),
},
{
name: 'us-scheduler/student-login',
title: __('Student Login', 'unsupervised-schedular'),
description: __('The front-end login form for students.', 'unsupervised-schedular'),
icon: 'admin-users',
keywords: ['login', 'student', 'sign in'],
shortcode: 'us_student_login',
attributes: {
bookingPageId: { type: 'number', default: 0 },
autoRedirect: { type: 'boolean', default: false },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('Logged-in visitors', 'unsupervised-schedular') },
el(PageSelect, {
label: __('Booking page', 'unsupervised-schedular'),
help: __('Where students are sent after logging in, and where the link shown to already-logged-in visitors points.', 'unsupervised-schedular'),
defaultLabel: __('This page', 'unsupervised-schedular'),
value: attributes.bookingPageId,
onChange: (bookingPageId) => setAttributes({ bookingPageId }),
}),
el(ToggleControl, {
label: __('Redirect automatically', 'unsupervised-schedular'),
help: __('Send logged-in visitors straight to the booking page instead of showing a link. Requires a booking page to be chosen.', 'unsupervised-schedular'),
checked: !!attributes.autoRedirect,
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
})
),
},
{
name: 'us-scheduler/student-register',
title: __('Student Registration', 'unsupervised-schedular'),
description: __('The invite-only student registration form.', 'unsupervised-schedular'),
icon: 'welcome-add-page',
keywords: ['register', 'student', 'invite'],
shortcode: 'us_student_register',
attributes: {
loginPageId: { type: 'number', default: 0 },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('After email confirmation', 'unsupervised-schedular') },
el(PageSelect, {
label: __('Sign-in page', 'unsupervised-schedular'),
help: __('Where the sign-in link shown after a student confirms their email address sends them.', 'unsupervised-schedular'),
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }),
})
),
},
{
name: 'us-scheduler/group-classes',
title: __('Group Classes', 'unsupervised-schedular'),
description: __('Lets students browse and enrol in group classes. Shows a styled preview in the editor.', 'unsupervised-schedular'),
icon: 'groups',
keywords: ['group', 'class', 'enrol'],
shortcode: 'us_group_classes',
attributes: {
offeringId: { type: 'number', default: 0 },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('Classes shown', 'unsupervised-schedular') },
el(GroupClassSelect, {
label: __('Class', 'unsupervised-schedular'),
help: __('Show only one group class, for embedding on a page dedicated to it.', 'unsupervised-schedular'),
value: attributes.offeringId,
onChange: (offeringId) => setAttributes({ offeringId }),
})
),
},
];
blocks.forEach((def) => {
registerBlockType(def.name, {
apiVersion: 3,
title: def.title,
description: def.description,
icon: def.icon,
category: 'widgets',
keywords: def.keywords,
supports: { html: false, multiple: false },
attributes: def.attributes || {},
example: {},
edit: function Edit(props) {
const inspector = def.inspector
? el(InspectorControls, {}, def.inspector(props.attributes, props.setAttributes))
: null;
return el(
'div',
useBlockProps(),
inspector,
el(ServerSideRender, { block: def.name, attributes: props.attributes })
);
},
save: () => null,
transforms: {
from: [{ type: 'shortcode', tag: def.shortcode }],
},
});
});
}());
+26 -278
View File
@@ -5,10 +5,9 @@
const app = document.getElementById('us-booking-app'); const app = document.getElementById('us-booking-app');
if (!app) return; if (!app) return;
const slotList = document.getElementById('us-slot-list'); const slotList = document.getElementById('us-slot-list');
const myLessons = document.getElementById('us-my-lessons'); const confirm = document.getElementById('us-booking-confirmation');
const confirm = document.getElementById('us-booking-confirmation'); const errorBox = document.getElementById('us-booking-error');
const errorBox = document.getElementById('us-booking-error');
const { restUrl, nonce } = usScheduler; const { restUrl, nonce } = usScheduler;
function apiFetch(path, options = {}) { function apiFetch(path, options = {}) {
@@ -44,13 +43,7 @@
} }
const dayKey = (dt) => String(dt).slice(0, 10); const dayKey = (dt) => String(dt).slice(0, 10);
const timeOf = (dt) => String(dt).slice(11, 16);
// "2026-07-06 14:30:00" → "2:30 PM"
function timeOf(dt) {
const hours = Number(String(dt).slice(11, 13));
const minutes = String(dt).slice(14, 16);
return `${hours % 12 || 12}:${minutes} ${hours < 12 ? 'AM' : 'PM'}`;
}
function dayLabel(key) { function dayLabel(key) {
const date = new Date(key + 'T00:00:00'); const date = new Date(key + 'T00:00:00');
@@ -60,12 +53,6 @@
}); });
} }
function shortDayLabel(key) {
const date = new Date(key + 'T00:00:00');
if (Number.isNaN(date.getTime())) return key;
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
function groupByDay(slots) { function groupByDay(slots) {
const groups = new Map(); const groups = new Map();
slots.forEach((slot) => { slots.forEach((slot) => {
@@ -76,39 +63,14 @@
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])); return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
} }
// --- calendar view state (week is the default; week keeps its position) ---
let allSlots = [];
let view = 'week';
let weekStart = null;
const pad = (n) => String(n).padStart(2, '0');
const toKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
function addDays(key, days) {
const date = new Date(key + 'T00:00:00');
date.setDate(date.getDate() + days);
return toKey(date);
}
// First day of the week containing `key`, honouring the site's
// start-of-week setting (0 = Sunday … 6 = Saturday).
function weekStartOf(key) {
const startOfWeek = Number(usScheduler.startOfWeek) || 0;
const date = new Date(key + 'T00:00:00');
return addDays(key, -((date.getDay() - startOfWeek + 7) % 7));
}
function toggleHtml() {
return `
<div class="us-view-toggle" role="group" aria-label="Calendar view">
<button type="button" id="us-view-list" class="${view === 'list' ? 'us-active' : ''}">List</button>
<button type="button" id="us-view-week" class="${view === 'week' ? 'us-active' : ''}">Week</button>
</div>`;
}
// Agenda-style calendar: available slots grouped by day. // Agenda-style calendar: available slots grouped by day.
function listHtml() { function renderSlots(slots) {
return groupByDay(allSlots).map(([key, daySlots]) => ` if (!slots.length) {
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
return;
}
slotList.innerHTML = groupByDay(slots).map(([key, daySlots]) => `
<div class="us-day"> <div class="us-day">
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3> <h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
${daySlots.map((slot) => ` ${daySlots.map((slot) => `
@@ -119,69 +81,10 @@
`).join('')} `).join('')}
</div> </div>
`).join(''); `).join('');
}
// Weekly calendar: seven day columns with a bookable button per slot. slotList.querySelectorAll('.us-book-btn').forEach((btn) => {
function weekHtml() { const slot = slots.find((s) => String(s.id) === btn.dataset.slotId);
const byDay = new Map(groupByDay(allSlots)); btn.addEventListener('click', () => openRegistration(slot));
const days = [...Array(7).keys()].map((i) => addDays(weekStart, i));
const columns = days.map((key) => {
const daySlots = byDay.get(key) || [];
const buttons = daySlots.map((slot) => `
<button data-slot-id="${slot.id}" class="us-book-btn us-week-slot" title="${escHtml(String(slot.duration_minutes))} min">
${escHtml(timeOf(slot.start_dt))}
</button>
`).join('');
return `
<div class="us-week-day">
<h4 class="us-week-day-heading">${escHtml(shortDayLabel(key))}</h4>
${buttons || '<span class="us-week-empty" aria-hidden="true">—</span>'}
</div>`;
}).join('');
return `
<div class="us-week-nav">
<button type="button" id="us-week-prev">&lsaquo; Previous week</button>
<strong class="us-week-label">Week of ${escHtml(shortDayLabel(weekStart))}</strong>
<button type="button" id="us-week-next">Next week &rsaquo;</button>
</div>
<div class="us-week-grid">${columns}</div>`;
}
function render() {
if (!allSlots.length) {
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
return;
}
// Anchor the week view to the week of the earliest open slot (the API
// returns slots ordered by start), so the first look is never empty.
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(allSlots[0].start_dt));
slotList.innerHTML = toggleHtml() + (view === 'week' ? weekHtml() : listHtml());
wireCalendarEvents();
}
function wireCalendarEvents() {
document.getElementById('us-view-list').addEventListener('click', () => {
view = 'list';
render();
});
document.getElementById('us-view-week').addEventListener('click', () => {
view = 'week';
render();
});
const prev = document.getElementById('us-week-prev');
const next = document.getElementById('us-week-next');
if (prev) prev.addEventListener('click', () => { weekStart = addDays(weekStart, -7); render(); });
if (next) next.addEventListener('click', () => { weekStart = addDays(weekStart, 7); render(); });
slotList.querySelectorAll('.us-book-btn[data-slot-id]').forEach((btn) => {
const slot = allSlots.find((s) => String(s.id) === btn.dataset.slotId);
if (slot) btn.addEventListener('click', () => openRegistration(slot));
}); });
} }
@@ -211,84 +114,23 @@
</div>`; </div>`;
} }
// Active private-lesson offerings per instructor, so revisiting the
// registration form does not refetch the same catalog.
const offeringCache = new Map();
function instructorOfferings(instructorId) {
if (offeringCache.has(instructorId)) {
return Promise.resolve(offeringCache.get(instructorId));
}
return apiFetch(`offerings?instructor_id=${instructorId}&kind=private_lesson`).then((list) => {
offeringCache.set(instructorId, list);
return list;
});
}
// "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)"
function offeringLabel(o) {
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
const price = Number(o.price) > 0
? `$${Number(o.price).toFixed(2)} ${o.currency}`
: 'Free';
return `${o.title} (${duration}${price})`;
}
function openRegistration(slot) { function openRegistration(slot) {
clearError(); clearError();
const offeringId = Number(slot.offering_id) || 0;
const qPath = offeringId ? `offerings/${offeringId}/questions` : null;
Promise.all([ Promise.all([
instructorOfferings(Number(slot.instructor_id)), qPath ? apiFetch(qPath) : Promise.resolve([]),
apiFetch('policies?scope=booking'), apiFetch('policies?scope=booking'),
]) ])
.then(([offerings, policies]) => { .then(([questions, policies]) => {
renderRegistration(slot, offerings, policies); renderRegistration(slot, offeringId, questions, policies);
}) })
.catch((err) => showError(err.message)); .catch((err) => showError(err.message));
} }
function offeringFieldHtml(tied, tiedId, choices) { function renderRegistration(slot, offeringId, questions, policies) {
if (tiedId) {
// The slot is tied to one offering: show it locked so the student
// sees exactly what they are booking.
const label = tied ? offeringLabel(tied) : `Offering #${tiedId}`;
return `
<p class="us-offering">
<label>Lesson type<br>
<select id="us-offering" disabled><option>${escHtml(label)}</option></select></label>
</p>`;
}
return `
<p class="us-offering">
<label>Lesson type<br>
<select id="us-offering" required>
<option value="">— Choose a lesson type —</option>
${choices.map((o) => `<option value="${o.id}">${escHtml(offeringLabel(o))}</option>`).join('')}
</select></label>
</p>`;
}
function renderRegistration(slot, offerings, policies) {
const tiedId = Number(slot.offering_id) || 0;
const tied = tiedId ? offerings.find((o) => Number(o.id) === tiedId) : null;
// Generic slots offer every lesson type that fits the slot's length.
const choices = tiedId
? []
: offerings.filter((o) => !o.duration_minutes || Number(o.duration_minutes) === Number(slot.duration_minutes));
if (!tiedId && !choices.length) {
// The server rejects offering-less bookings, so without a matching
// lesson type this time cannot be booked online.
slotList.innerHTML = `
<div class="us-register">
<p>This time cannot be booked online right now. Please contact the instructor.</p>
<p><button type="button" id="us-cancel" class="us-cancel-btn">Back</button></p>
</div>`;
document.getElementById('us-cancel').addEventListener('click', loadSlots);
return;
}
const weekly = slot.recurrence_group const weekly = slot.recurrence_group
? `<p><label><input type="checkbox" id="us-weekly"> Reserve this time weekly for the term</label></p>` ? `<p><label><input type="checkbox" id="us-weekly"> Reserve this time weekly for the term</label></p>`
: ''; : '';
@@ -297,8 +139,7 @@
<div class="us-register"> <div class="us-register">
<h3>${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}${escHtml(timeOf(slot.end_dt))}</h3> <h3>${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}${escHtml(timeOf(slot.end_dt))}</h3>
<form id="us-register-form"> <form id="us-register-form">
${offeringFieldHtml(tied, tiedId, choices)} ${questions.map(questionField).join('')}
<div id="us-questions"></div>
${policies.map(policyField).join('')} ${policies.map(policyField).join('')}
${weekly} ${weekly}
<p> <p>
@@ -308,42 +149,10 @@
</form> </form>
</div>`; </div>`;
// The intake questions belong to the selected offering, so they follow
// the picker instead of being fixed at render time.
let selectedId = tiedId;
let questions = [];
const questionsBox = document.getElementById('us-questions');
function loadQuestions() {
questions = [];
questionsBox.innerHTML = '';
if (!selectedId) return;
apiFetch(`offerings/${selectedId}/questions`)
.then((qs) => {
questions = qs;
questionsBox.innerHTML = qs.map(questionField).join('');
})
.catch((err) => showError(err.message));
}
if (!tiedId) {
document.getElementById('us-offering').addEventListener('change', (e) => {
selectedId = Number(e.target.value) || 0;
loadQuestions();
});
}
loadQuestions();
document.getElementById('us-cancel').addEventListener('click', loadSlots); document.getElementById('us-cancel').addEventListener('click', loadSlots);
document.getElementById('us-register-form').addEventListener('submit', (e) => { document.getElementById('us-register-form').addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
if (!selectedId) { submitBooking(e.target, slot, offeringId, questions);
showError('Please choose a lesson type.');
return;
}
submitBooking(e.target, slot, selectedId, questions);
}); });
} }
@@ -370,68 +179,11 @@
accepted_policy_version_ids: accepted, accepted_policy_version_ids: accepted,
}), }),
}) })
// A booking with nothing owed has no payment, so there is no payment .then((res) => window.usPayment.collect('lesson', (res.ids || [])[0], slotList))
// step to run — the booking is already confirmed server-side. .then((result) => showConfirmation(window.usPayment.message(result)))
.then((res) => (res.payment
? window.usPayment.collect('lesson', (res.ids || [])[0], slotList)
: null))
.then((result) => {
loadMyLessons();
showConfirmation(window.usPayment.message(result));
})
.catch((err) => showError(err.message)); .catch((err) => showError(err.message));
} }
function lessonStatusLabel(status) {
if (status === 'pending') return 'Pending payment';
if (status === 'confirmed') return 'Confirmed';
return status.charAt(0).toUpperCase() + status.slice(1);
}
function renderMyLessons(lessons) {
const upcoming = lessons.filter((l) => l.start_dt);
if (!upcoming.length) {
myLessons.innerHTML = '';
return;
}
myLessons.innerHTML = `
<div class="us-my-lessons">
<h3>Your upcoming lessons</h3>
${upcoming.map((l) => `
<div class="us-my-lesson">
<span>${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span>
<span class="us-my-lesson-actions">
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
</span>
</div>
`).join('')}
</div>`;
myLessons.querySelectorAll('.us-cancel-lesson').forEach((btn) => {
btn.addEventListener('click', () => cancelLesson(Number(btn.dataset.lessonId)));
});
}
function cancelLesson(id) {
if (!window.confirm('Cancel this lesson? The time will be released for other students.')) {
return;
}
clearError();
apiFetch(`bookings/${id}/cancel`, { method: 'POST' })
.then(loadSlots)
.catch((err) => showError(err.message));
}
function loadMyLessons() {
if (!myLessons) return;
// The lesson list is a bonus panel: never let it break slot browsing.
apiFetch('bookings')
.then(renderMyLessons)
.catch(() => { myLessons.innerHTML = ''; });
}
function showConfirmation(message) { function showConfirmation(message) {
confirm.textContent = message; confirm.textContent = message;
slotList.style.display = 'none'; slotList.style.display = 'none';
@@ -442,12 +194,8 @@
clearError(); clearError();
slotList.style.display = 'block'; slotList.style.display = 'block';
confirm.style.display = 'none'; confirm.style.display = 'none';
loadMyLessons();
apiFetch('availability') apiFetch('availability')
.then((slots) => { .then(renderSlots)
allSlots = slots;
render();
})
.catch((err) => showError(err.message)); .catch((err) => showError(err.message));
} }
+7 -67
View File
@@ -10,10 +10,6 @@
const errorBox = document.getElementById('us-group-error'); const errorBox = document.getElementById('us-group-error');
const { restUrl, nonce } = usScheduler; const { restUrl, nonce } = usScheduler;
// When the shortcode/block pins a single offering, only that class is
// shown, so the page can be embedded alongside a full class description.
const singleOfferingId = Number(app.dataset.offering || 0);
function apiFetch(path, options = {}) { function apiFetch(path, options = {}) {
return fetch(restUrl + path, { return fetch(restUrl + path, {
...options, ...options,
@@ -72,60 +68,20 @@
</div>`; </div>`;
} }
// Parse a Y-m-d date into local time; new Date('Y-m-d') would parse as function renderClasses(offerings) {
// UTC midnight and can display as the previous day in western timezones. const groups = offerings.filter((o) => o.kind === 'group_class');
function formatDate(ymd) {
const [y, m, d] = ymd.split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function termLabel(o) {
if (!o.term_start) return '';
if (!o.term_end || o.term_end === o.term_start) {
return formatDate(o.term_start);
}
const weekMs = 7 * 24 * 60 * 60 * 1000;
const sessions = Math.round((new Date(o.term_end) - new Date(o.term_start)) / weekMs) + 1;
return `${formatDate(o.term_start)} ${formatDate(o.term_end)} (${sessions} weekly sessions)`;
}
// 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) {
groups = groups.filter((o) => Number(o.id) === singleOfferingId);
}
if (!groups.length) { if (!groups.length) {
list.innerHTML = singleOfferingId list.innerHTML = '<p>No group classes are open for enrolment right now.</p>';
? '<p>This class is not open for enrolment right now.</p>'
: '<p>No group classes are open for enrolment right now.</p>';
return; return;
} }
list.innerHTML = groups.map((o) => ` list.innerHTML = groups.map((o) => `
<div class="us-class"> <div class="us-class">
<h3>${escHtml(o.title)}</h3> <h3>${escHtml(o.title)}</h3>
${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.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${o.description ? `<p>${escHtml(o.description)}</p>` : ''} ${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p> <p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
${enrolledOfferingIds.has(Number(o.id)) <button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>
? '<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>'
: `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`}
</div> </div>
`).join(''); `).join('');
@@ -186,11 +142,7 @@
accepted_policy_version_ids: accepted, accepted_policy_version_ids: accepted,
}), }),
}) })
// An enrolment with nothing owed has no payment, so there is no .then((res) => window.usPayment.collect('enrollment', res.id, list))
// payment step to run.
.then((res) => (res.payment
? window.usPayment.collect('enrollment', res.id, list)
: null))
.then((result) => showConfirmation(window.usPayment.message(result))) .then((result) => showConfirmation(window.usPayment.message(result)))
.catch((err) => showError(err.message)); .catch((err) => showError(err.message));
} }
@@ -205,20 +157,8 @@
clearError(); clearError();
list.style.display = 'block'; list.style.display = 'block';
confirm.style.display = 'none'; confirm.style.display = 'none';
// The student's own enrolments are fetched alongside the catalog so a apiFetch('offerings?kind=group_class')
// class they already have an active enrolment in shows its status .then(renderClasses)
// instead of offering to enrol them again (the API would reject the
// duplicate anyway). A cancelled enrolment does not block re-enrolling.
Promise.all([
apiFetch('offerings?kind=group_class'),
apiFetch('enrollments'),
])
.then(([offerings, enrollments]) => renderClasses(
offerings,
new Set(enrollments
.filter((e) => e.status === 'active')
.map((e) => Number(e.offering_id)))
))
.catch((err) => showError(err.message)); .catch((err) => showError(err.message));
} }
-57
View File
@@ -1,57 +0,0 @@
/**
* 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]);
}
});
})();
+3 -3
View File
@@ -11,8 +11,8 @@
"phpunit/phpunit": "^10.5", "phpunit/phpunit": "^10.5",
"brain/monkey": "^2.6", "brain/monkey": "^2.6",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"phpstan/phpstan": "^2.0", "phpstan/phpstan": "^1.10",
"szepeviktor/phpstan-wordpress": "^2.0", "szepeviktor/phpstan-wordpress": "^1.3",
"php-stubs/wordpress-stubs": "^6.0", "php-stubs/wordpress-stubs": "^6.0",
"squizlabs/php_codesniffer": "^3.7", "squizlabs/php_codesniffer": "^3.7",
"wp-coding-standards/wpcs": "^3.0" "wp-coding-standards/wpcs": "^3.0"
@@ -30,7 +30,7 @@
"scripts": { "scripts": {
"test": "phpunit --configuration phpunit.xml", "test": "phpunit --configuration phpunit.xml",
"test:coverage": "phpunit --configuration phpunit.xml --coverage-html coverage/", "test:coverage": "phpunit --configuration phpunit.xml --coverage-html coverage/",
"lint": "phpstan analyse --configuration phpstan.neon --memory-limit=1G", "lint": "phpstan analyse src/ --level=6 --configuration phpstan.neon --memory-limit=1G",
"cs": "phpcs --standard=phpcs.xml.dist", "cs": "phpcs --standard=phpcs.xml.dist",
"cs:fix": "phpcbf --standard=phpcs.xml.dist", "cs:fix": "phpcbf --standard=phpcs.xml.dist",
"build": "bash bin/build-zip.sh" "build": "bash bin/build-zip.sh"
+20 -108
View File
@@ -4,87 +4,27 @@
People register for a student account through a front-end page, accepting any People register for a student account through a front-end page, accepting any
signup-scoped policies at that time. Registration is **invite-only** by default: a signup-scoped policies at that time. Registration is **invite-only** by default: a
studio admin sends an invite, and the invitee completes signup via a tokenised studio admin sends an invite, and the invitee completes signup via a tokenised
link. A studio can instead switch on **open (self-approval) registration**, where link. A settings seam (`us_registration_mode`) allows switching to open
anyone may sign up, confirm their email, and then be approved by a studio admin self-registration with approval later.
before the account can be used. Both modes coexist — invites keep working when
open registration is on.
A studio admin can also generate a **group invite link** — a multi-use, tokenised
link with an explicit expiry date (e.g. for a newsletter). Anyone with the link
may register while it is valid, regardless of the registration mode: they supply
their own email, must confirm it, and are then **approved automatically**
group-link signups never enter the Pending Students queue.
## Registration Modes ## Registration Modes
Stored in the `us_registration_mode` option (default `invite`), toggled from Stored in the `us_registration_mode` option (default `invite`):
**Studio Settings → Registration**: - `invite` — only a valid, pending invite token grants access to the registration form. *(implemented)*
- `invite` — only a valid, pending invite token grants access to the registration form. - `self_approval` — anyone may register; the account is created in a pending state until a studio admin approves it. *(reserved for a later iteration)*
- `self_approval` — anyone may register on the registration page; each account is created in a pending state, must confirm its email, and is then approved (or rejected) by a studio admin.
### Enabling open registration
The Studio Settings toggle is the source of truth. Enabling it mirrors into the
two core WordPress options the flow relies on, and **snapshots** their previous
values (`us_registration_prev_can_register`, `us_registration_prev_default_role`):
- `users_can_register``1` (Settings → General "Anyone can register")
- `default_role``us_student`
Disabling restores the snapshot, so the toggle never permanently overwrites a
site's own membership settings. Only enable/disable *transitions* touch the core
options — saving unrelated settings leaves them alone.
See `Payment\StudioSettings::applyRegistrationMode()`.
### Blocking the native registration form
Because `users_can_register=1` also switches on WordPress's own
`wp-login.php?action=register` form — which cannot collect the required signup
policy acceptances — that form is blocked while open registration is on, so it can
never be used to create a policy-less account (`Auth\EmailConfirmationHandler`):
- `register_url` filter points WordPress's "Register" links at the registration page.
- `login_init` action redirects any `action=register` request (GET **and** POST) to the registration page before any processing runs.
- `registration_errors` filter is a fail-safe that rejects `register_new_user()` outright.
## Account Lifecycle (self-approval)
State lives entirely in user meta (`Auth\RegistrationStatus`). Only the raw
confirmation token's SHA-256 hash is stored; the token expires after 48h
(`EMAIL_CONFIRM_EXPIRY_HOURS`).
| State | User meta | Login | Booking |
|---|---|---|---|
| Email unconfirmed | `us_awaiting_approval=1`, `us_email_confirm_token`(hash) + `us_email_confirm_expires` set | blocked ("confirm your email") | — |
| Confirmed, awaiting approval | `us_awaiting_approval=1`, `us_email_confirmed=1`, token/expiry cleared | allowed | withheld → pending screen |
| Approved / active | `us_awaiting_approval` deleted, `us_email_confirmed=1` | allowed | full student |
| Rejected | account hard-deleted (`wp_delete_user`) | n/a | n/a |
| Invite/admin-created student | none of these metas | allowed | full student |
- **Login gate** (`Auth\RegistrationLoginGate`): the `wp_authenticate_user` filter blocks login while the email is unconfirmed; the `user_has_cap` filter withholds `book_lesson` while `us_awaiting_approval` is set, so a confirmed-but-unapproved student only reaches the "awaiting approval" screen on the booking page.
- **Email confirmation** (`Auth\EmailConfirmationHandler` on `template_redirect`): opening the emailed `?us_confirm=<token>` link confirms the email, notifies the studio admins, and redirects back to the registration page with `?us_confirmed=1` (or `expired`). On `?us_confirmed=1` the registration page replaces the form with the confirmation message plus a "Sign in to your account" link — the configured sign-in page (block `loginPageId` / shortcode `login_page_id` attribute), falling back to the WordPress login screen. The `expired` notice keeps the form.
- **Approval** (`Auth\RegistrationApprovalController`, **Students → Pending Students**, `manage_students`): approve clears the pending flags and emails the student; reject emails them and hard-deletes the account so the email is freed to re-apply.
- **Emails**: `Auth\RegistrationMailer` sends the confirmation link, the admin heads-up, and the approval/rejection notices.
## Data Model — `{prefix}us_invites` ## Data Model — `{prefix}us_invites`
| Column | Type | Notes | | Column | Type | Notes |
|--------------------|------------------|--------------------------------------------------------| |--------------------|------------------|--------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key | | `id` | BIGINT UNSIGNED | Primary key |
| `email` | VARCHAR(191) | Invited email address; empty string for group links | | `email` | VARCHAR(191) | Invited email address |
| `token` | VARCHAR(64) | SHA-256 hash of the token embedded in the registration link (raw token is never stored) | | `token` | VARCHAR(64) | Opaque token embedded in the registration link |
| `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) | | `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) |
| `kind` | VARCHAR(10) | `personal` (single-use, per email) or `group` (multi-use link) | | `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` |
| `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 | | `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 | | `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending |
| `created_at` | DATETIME | Insertion time | | `created_at` | DATETIME | Insertion time |
| `accepted_at` | DATETIME | When accepted; NULL while pending / for group links | | `accepted_at` | DATETIME | When accepted; NULL while pending |
| `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 ## Policy Acceptance Scope
Policies declare **when** they must be accepted via `us_policies.acceptance_scope`: Policies declare **when** they must be accepted via `us_policies.acceptance_scope`:
@@ -94,38 +34,19 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
`registration_id = <new user ID>`. `registration_id = <new user ID>`.
## Flow (invite mode) ## Flow (invite mode)
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. 1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created with a token and a registration link.
2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash. 2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`).
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. 3. The form pre-fills the email and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox.
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`. 4. On submit, the token is re-validated; a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in.
## Flow (self-approval mode)
1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`).
2. Anyone opens `[us_student_register]`; the form collects an editable email, display name, password, and the required signup policies.
3. On submit a `us_student` user is created in the pending state (`RegistrationStatus::markPending()`), acceptances are recorded (`account` type), a confirmation email is sent, and the user is **not** logged in.
4. The applicant opens the emailed `?us_confirm=<token>` link → email confirmed, studio admins notified.
5. Studio admin approves under **Students → Pending Students** → pending flags cleared, student emailed; they can now log in and book. Rejection deletes the account.
## Flow (group invite link)
1. Studio admin opens **Invites** and generates a **group link**, choosing the expiry date (required; the link stops working at the end of that day). The link is shown **once**, like personal invite links.
2. Anyone opens the link while it is pending and unexpired — in **any** registration mode — and the form collects an **editable email**, display name, password, and the signup policies.
3. On submit the account is created pending with the auto-approve marker (`RegistrationStatus::markPending($userId, autoApprove: true)`, meta `us_auto_approve`) and a confirmation email is sent. The invite row is **not** marked accepted — the link remains usable by others.
4. Opening the `?us_confirm=<token>` link confirms the email and **approves the account immediately** (`EmailConfirmationHandler`): no admin heads-up, no Pending Students entry; the student gets the "approved" email and the page shows a "ready to use" notice (`?us_confirmed=ready`) with a sign-in link.
5. The link can be revoked at any time from the Invites page.
## Admin Interface ## Admin Interface
**Invites** in wp-admin (`manage_students`, studio admin only): **Invites** in wp-admin (`manage_students`, studio admin only):
- Select the **registration page** (the page hosting `[us_student_register]`), stored in the `us_registration_page_id` option; invitation links point there (falling back to the home page if unset) - Select the **registration page** (the page hosting `[us_student_register]`), stored in the `us_registration_page_id` option; invitation links point there (falling back to the home page if unset)
- Invite an email (creates a pending invite; the link is displayed once, at creation only) - Invite an email (creates a pending invite + link)
- Generate a **group invite link** with a required expiry date (link displayed once) - List pending invites; revoke an invite
- List pending invites (email or "Group link", created + expiry dates); revoke an invite
**Pending Students** — submenu under Students (`manage_students`), only relevant in `self_approval` mode:
- "Awaiting approval" (email confirmed) — approve or reject
- "Awaiting email confirmation" (not yet confirmed) — reject only
## Frontend Shortcode ## Frontend Shortcode
- `[us_student_register]` — the registration page. In `invite` mode: shows the form for a valid pending invite, else an "by invitation only" message. In `self_approval` mode: shows the form to anyone (editable email), and renders confirmation-result notices from `?us_confirmed=1|expired`. - `[us_student_register]` — the registration page. Shows the form for a valid pending invite; otherwise shows an "by invitation only" message (in `invite` mode).
## Token Redirect ## Token Redirect
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`) A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
@@ -135,25 +56,16 @@ covers invitation links generated/shared before a registration page was selected
No-op when no registration page is set. No-op when no registration page is set.
## Capabilities ## Capabilities
- `manage_students` — manage invites and approve/reject pending students (studio admin; administrators inherit it via the `user_has_cap` filter). Added to `RoleManager::STUDIO_ADMIN_CAPS`. - `manage_students` — manage invites (studio admin; administrators inherit it via the `user_has_cap` filter). Added to `RoleManager::STUDIO_ADMIN_CAPS`.
## Implementation ## Implementation
- Models: `Unsupervised\Schedular\Auth\Invite` - Models: `Unsupervised\Schedular\Auth\Invite`
- Repository: `Unsupervised\Schedular\Auth\InviteRepository` - Repository: `Unsupervised\Schedular\Auth\InviteRepository`
- Admin controllers: `Unsupervised\Schedular\Auth\RegistrationController` (invites), `Unsupervised\Schedular\Auth\RegistrationApprovalController` (pending students) - Admin controller: `Unsupervised\Schedular\Auth\RegistrationController`
- Frontend: `Unsupervised\Schedular\Auth\RegistrationPage` - Frontend: `Unsupervised\Schedular\Auth\RegistrationPage`
- Self-approval flow: `Auth\RegistrationStatus` (lifecycle meta), `Auth\RegistrationLoginGate` (login + booking-cap gate), `Auth\EmailConfirmationHandler` (confirm link + native-form block), `Auth\RegistrationMailer` (emails)
- Settings toggle: `Payment\StudioSettings` (`us_registration_mode`, core-option mirror/restore)
- Reuses `Policy\PolicyRepository`, `Policy\PolicyVersionRepository`, `Policy\AcceptanceRepository` - Reuses `Policy\PolicyRepository`, `Policy\PolicyVersionRepository`, `Policy\AcceptanceRepository`
- Schema: `us_invites`; `us_policies.acceptance_scope`. Self-approval adds no tables — state is WordPress user meta. - Schema: `us_invites`; `us_policies.acceptance_scope`
## Tests ## Tests
- `tests/Unit/Auth/InviteTest.php` - `tests/Unit/Auth/InviteTest.php`
- `tests/Unit/Auth/InviteRepositoryTest.php` - `tests/Unit/Auth/InviteRepositoryTest.php`
- `tests/Unit/Auth/RegistrationStatusTest.php`
- `tests/Unit/Auth/RegistrationLoginGateTest.php`
- `tests/Unit/Auth/EmailConfirmationHandlerTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php`
- `tests/Unit/Auth/RegistrationApprovalControllerTest.php`
- `tests/Unit/Auth/RegistrationMailerTest.php`
- `tests/Unit/Payment/StudioSettingsTest.php`
+13 -41
View File
@@ -1,7 +1,7 @@
# Feature: Availability Management # Feature: Availability Management
## Overview ## Overview
Instructors define same-day date/time windows during which they are available for private lessons. On save, a window is split into consecutive lesson-length slots (09:0016:00 with 60-minute lessons becomes seven rows), each independently bookable by students. Windows may be generated as a weekly-recurring series. Instructors define date/time windows during which they are available for private lessons. Students book from these windows. Windows carry a lesson length and may be generated as a weekly-recurring series.
## Data Model — `{prefix}us_availability` ## Data Model — `{prefix}us_availability`
@@ -11,44 +11,31 @@ Instructors define same-day date/time windows during which they are available fo
| `instructor_id` | BIGINT UNSIGNED | WordPress user ID | | `instructor_id` | BIGINT UNSIGNED | WordPress user ID |
| `offering_id` | BIGINT UNSIGNED | Nullable FK → `us_offerings.id` (private-lesson type) | | `offering_id` | BIGINT UNSIGNED | Nullable FK → `us_offerings.id` (private-lesson type) |
| `start_dt` | DATETIME | Slot start — stored as `Y-m-d H:i:s` | | `start_dt` | DATETIME | Slot start — stored as `Y-m-d H:i:s` |
| `end_dt` | DATETIME | Slot end — always `start_dt + duration_minutes` | | `end_dt` | DATETIME | Slot end — stored as `Y-m-d H:i:s` |
| `duration_minutes` | SMALLINT | Lesson length (e.g. 30, 60) | | `duration_minutes` | SMALLINT | Lesson length the window accommodates (e.g. 30, 60) |
| `is_booked` | TINYINT(1) | 0 = available, 1 = booked | | `is_booked` | TINYINT(1) | 0 = available, 1 = booked |
| `recurrence_group` | BIGINT UNSIGNED | Nullable — weekly-recurring windows share one group id | | `recurrence_group` | BIGINT UNSIGNED | Nullable — weekly-recurring windows share one group id |
| `created_at` | DATETIME | Insertion time | | `created_at` | DATETIME | Insertion time |
A slot's `duration_minutes` is matched against the offering a student picks: a A window's `duration_minutes` is matched against the offering a student picks: a
30-minute private offering can only be booked into a slot whose 30-minute private offering can only be booked into a window whose
`duration_minutes` accommodates it. `duration_minutes` accommodates it.
## Window Splitting
`AvailabilitySlot::splitByDuration()` chunks a submitted window into consecutive
`duration_minutes` slots; `AvailabilityRepository::createFromWindow()` persists
one row per chunk. A trailing remainder shorter than the lesson length is
dropped. Windows must start and end on the same day and fit at least one lesson
(REST responds `400 invalid_window` otherwise; the admin form is a no-op).
`AvailabilityRepository::splitOversizedWindows()` is a data migration (run by
`Installer` on activation or version change) that rewrites pre-split rows.
## Weekly-Recurring Windows ## Weekly-Recurring Windows
Instructors may generate a window weekly across a date range. Each lesson-length Instructors may generate a window weekly across a date range. Each occurrence is a
chunk becomes its own weekly series: occurrences of the same time-of-day share separate row sharing one `recurrence_group` id, so a recurring set can be added or
one `recurrence_group` id, so a recurring set can be added or removed together removed together while individual occurrences are still booked independently.
while individual occurrences are still booked independently.
## Admin Interface ## Admin Interface
Instructors access **My Availability** in wp-admin (`?page=us-availability`). Instructors access **My Availability** in wp-admin (`?page=us-availability`).
- Add availability: provide a same-day start/end window, lesson length, and (optionally) a linked private-lesson offering - Add a slot: provide start/end datetime, duration, and (optionally) a linked private-lesson offering
- Add a weekly series: tick weekly repeat and choose the number of weeks - Add a weekly series: provide the weekday/time plus a date range
- Delete a slot: only allowed if `is_booked = 0` - Delete a slot: only allowed if `is_booked = 0`
- Bulk delete: the list view has a checkbox per unbooked slot (with a select-all header checkbox) and a **Delete selected** button (`usc_action=bulk_delete`, `slot_ids[]`); each id is ownership-checked, and booked slots are refused at the repository level
- Current slots can be shown as a **weekly calendar** (the default, navigated with `usc_week=Y-m-d`) or a **list** (`usc_view=list`); the grid honours the site's `start_of_week` option via `Availability\WeekCalendar`
## Public Calendar ## Public Calendar
The front-end booking shortcode renders open slots from `GET /availability` The front-end booking shortcode renders a month/week calendar of open windows,
either as an agenda-style list grouped by day or as a **weekly calendar** with populated from `GET /availability`. Students can filter by instructor and by
previous/next-week navigation (toggle rendered by `assets/js/booking.js`; the offering/duration before selecting a slot to register for.
site's `start_of_week` option is passed through the `usScheduler` JS config).
## REST API ## REST API
| Method | Endpoint | Permission | | Method | Endpoint | Permission |
@@ -58,28 +45,13 @@ site's `start_of_week` option is passed through the `usScheduler` JS config).
| `DELETE` | `/wp-json/us-scheduler/v1/availability/{id}` | `manage_availability` + slot owner | | `DELETE` | `/wp-json/us-scheduler/v1/availability/{id}` | `manage_availability` + slot owner |
`GET` supports query params: `instructor_id`, `offering_id`, `duration_minutes`, `from` (datetime), `to` (datetime). `GET` supports query params: `instructor_id`, `offering_id`, `duration_minutes`, `from` (datetime), `to` (datetime).
Slots whose start has already passed are never returned.
`POST` validates `start_dt`/`end_dt` (admin form and REST alike) via
`AvailabilitySlot::normalizeDateTime()`: the canonical `Y-m-d H:i[:s]` and HTML
`datetime-local` (`Y-m-d\TH:i[:s]`) forms are normalised to `Y-m-d H:i:s`;
anything else — or an end not after the start — is rejected (REST responds
`400 invalid_datetime`; the admin form is a no-op). A valid window is stored as
lesson-length slots and `201` returns `{ "ids": [...] }` for every row created.
Times are displayed in 12-hour AM/PM form in the booking calendar and wp-admin
lists.
## Implementation ## Implementation
- Repository: `Unsupervised\Schedular\Availability\AvailabilityRepository` - Repository: `Unsupervised\Schedular\Availability\AvailabilityRepository`
- Model: `Unsupervised\Schedular\Availability\AvailabilitySlot` - Model: `Unsupervised\Schedular\Availability\AvailabilitySlot`
- Week bucketing: `Unsupervised\Schedular\Availability\WeekCalendar`
- Admin controller: `Unsupervised\Schedular\Availability\AvailabilityController` - Admin controller: `Unsupervised\Schedular\Availability\AvailabilityController`
- REST endpoint: `Unsupervised\Schedular\Availability\AvailabilityEndpoint` - REST endpoint: `Unsupervised\Schedular\Availability\AvailabilityEndpoint`
## Tests ## Tests
- `tests/Unit/Availability/AvailabilityControllerTest.php`
- `tests/Unit/Availability/AvailabilityRepositoryTest.php` - `tests/Unit/Availability/AvailabilityRepositoryTest.php`
- `tests/Unit/Availability/AvailabilitySlotTest.php` - `tests/Unit/Availability/AvailabilitySlotTest.php`
- `tests/Unit/Availability/AvailabilityEndpointTest.php`
- `tests/Unit/Availability/WeekCalendarTest.php`
-69
View File
@@ -1,69 +0,0 @@
# 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)
-99
View File
@@ -1,99 +0,0 @@
# Editor Blocks
Gutenberg dynamic-block wrappers for the plugin's four front-end shortcodes,
so the pages can be previewed and styled inside the block editor instead of
appearing as grey shortcode text.
## Blocks
| Block | Wraps shortcode | Front-end renderer |
|---|---|---|
| `us-scheduler/booking` | `[us_booking]` | `Booking\BookingPage::render()` |
| `us-scheduler/student-login` | `[us_student_login]` | `Auth\LoginPage::render()` |
| `us-scheduler/student-register` | `[us_student_register]` | `Auth\RegistrationPage::render()` |
| `us-scheduler/group-classes` | `[us_group_classes]` | `GroupClass\GroupClassPage::render()` |
The shortcodes remain registered for back-compat; blocks and shortcodes share
the same page objects (constructed once in `Plugin::boot()`), so front-end
output is identical either way. Pasting a shortcode into the block editor
auto-converts it to the matching block via a `transforms.from` shortcode
transform.
## Block options
Four blocks have sidebar (inspector) options:
| Block | Attribute | Default | Effect |
|---|---|---|---|
| `us-scheduler/booking` | `loginPageId` (number) | `0` | Page the "log in to book a lesson" link points to for logged-out visitors. `0` = the WordPress login screen (with a redirect back to the current page). |
| `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. |
| `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. |
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page the "Sign in to your account" link points to after a student confirms their email. `0` = the WordPress login screen. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. `0` = browse all classes. Shortcode equivalent: `[us_group_classes offering="…"]`. |
The page selects list all published pages; if a chosen page is later deleted,
the blocks fall back to their defaults. The group-classes block's class
select is a dropdown of active group classes fetched from
`GET /us-scheduler/v1/offerings?kind=group_class`; a stored class that is no
longer offered shows as "Unavailable class #N" rather than silently falling
back to all classes. The link targets are also available
to the shortcodes as `[us_booking login_page_id="…"]` and
`[us_student_login booking_page_id="…"]`; auto-redirect is block-only.
Auto-redirect cannot happen during block rendering (output has already
started, so a `Location` header cannot be sent). Instead
`BlockRegistrar::maybeAutoRedirect()` runs on `template_redirect`, parses the
queried singular post's content for the block (including inside nested
blocks), and redirects when the block opts in. A block whose target is its
own page is ignored to avoid a redirect loop.
## How it works
- **`BlockRegistrar`** (`src/BlockRegistrar.php`) hooks `init` and registers
each block with `register_block_type()`: a `render_callback` per block, the
shared editor script (`assets/js/blocks.js`, handle
`us-scheduler-blocks`), and the front-end stylesheet
(`assets/css/frontend.css`, handle `us-scheduler`) as the block `style` so
it also loads inside the editor and previews pick up theme styling.
- **`assets/js/blocks.js`** (vanilla JS, no build step) registers the client
side of each block — title, icon, keywords, shortcode transform — and
renders the editor preview with `wp.serverSideRender`, which fetches the
server-rendered markup via the `/wp/v2/block-renderer` REST route.
- **`BlockPreview`** (`src/BlockPreview.php`) supplies static, script-free
markup for editor previews. `BlockRegistrar::isEditorPreview()` detects the
block-renderer context via the `REST_REQUEST` constant (front-end template
rendering never happens inside a REST request) and renders the preview
instead of the live page.
## Editor preview behaviour
Live pages cannot run in the editor: booking and group classes are populated
by JavaScript making authenticated REST calls (and may load Stripe.js),
registration requires a valid invite token, and login short-circuits for
logged-in users (the editing admin always is). Each preview therefore
reproduces the live wrapper elements and CSS classes with representative
placeholder content:
- **Booking** — `#us-booking-app` with sample `.us-day` / `.us-slot` rows and
disabled Book buttons.
- **Group classes** — `#us-group-app` with a sample `.us-class` card and a
disabled Enrol button.
- **Login** — the real `templates/frontend/login-page.php` template (it has
no request-state dependencies).
- **Registration** — a disabled sample of the `.us-register-form` fields.
Each preview starts with a `.us-editor-note` paragraph explaining what the
published page shows instead. The note class only appears in editor previews.
## Tests
- `tests/Unit/BlockRegistrarTest.php` — hook registration, block/asset
registration, attribute schemas, front-end delegation to the page objects,
preview-mode routing, auto-redirect behaviour.
- `tests/Unit/Booking/BookingPageTest.php` — logged-out login-link targets
and fallbacks.
- `tests/Unit/Auth/LoginPageTest.php` — logged-in booking-link targets and
fallbacks.
- `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS
classes/ids and includes the editor note.
+10 -103
View File
@@ -3,8 +3,6 @@
## Overview ## 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). 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` ## Data Model — `{prefix}us_group_enrollments`
| Column | Type | Notes | | Column | Type | Notes |
@@ -17,27 +15,7 @@ A group class can be marked **invite-only** (`us_offerings.access_mode = invite_
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` | | `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
| `enrolled_at` | DATETIME | Insertion time | | `enrolled_at` | DATETIME | Insertion time |
## 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 ## Enrolment Flow
The class list is loaded together with the student's own enrolments
(`GET /enrollments`); a class the student already has an `active` enrolment in
shows "You are enrolled in this class." instead of the Enrol button (the
server would reject the duplicate with `409 already_enrolled` regardless — a
cancelled enrolment does not block re-enrolling).
1. Student opens a group class from the offering catalog. 1. Student opens a group class from the offering catalog.
2. Student answers the offering's questions (`GET /offerings/{id}/questions`). 2. Student answers the offering's questions (`GET /offerings/{id}/questions`).
3. Student accepts the current published policy versions (`GET /policies`) — required to continue. 3. Student accepts the current published policy versions (`GET /policies`) — required to continue.
@@ -55,100 +33,29 @@ a class at capacity rejects further enrolments.
| `POST` | `/wp-json/us-scheduler/v1/enrollments` | `book_lesson` | | `POST` | `/wp-json/us-scheduler/v1/enrollments` | `book_lesson` |
`POST /enrollments` body: `offering_id`, `answers[]` (`question_id` → value), `POST /enrollments` body: `offering_id`, `answers[]` (`question_id` → value),
`accepted_policy_version_ids[]`, and payment data (see `payments.md`). The `accepted_policy_version_ids[]`, and payment data (see `payments.md`).
response includes `id`, `status`, and `payment` — a `{id, method, status}`
summary, or `null` when the class is free (the front end then skips the
payment step).
`GET /enrollments` returns the caller's own enrolments, or all enrolments for the `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. 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 ## Admin Interface
- **Group Classes** (`view_all_lessons` / studio admin): a per-class summary across - **Group Classes** (`manage_options` / studio admin): all enrolments across instructors
instructors — each class with its instructor, when it meets, and its active-enrolment - Instructors see enrolments for their own group classes under **My Lessons**
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 ## Implementation
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates) - 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` - Model: `Unsupervised\Schedular\GroupClass\Enrollment`
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` `renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`) - Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_lessons`)
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint` - REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute) - Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode)
- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`) - Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
> **Payment:** a priced enrolment creates a payment via `Payment\PaymentService` > **Payment seam:** payment is deferred to #7. An enrolment is created with
> (`registration_type = enrollment`) and links it as `payment_id`; unpriced > `status = active` and `payment_id = null`; the pay→confirm + receipt step plugs
> enrolments return `payment: null` and skip the payment step. See `payments.md` > in later. Instructor-specific enrolment views (the spec's "under My Lessons")
> for the card/e-transfer/comp flows. > are a follow-up — this iteration ships the studio-admin **Group Classes** page
> (`view_all_lessons`) plus per-student/per-instructor REST queries.
## Tests ## Tests
- `tests/Unit/GroupClass/GroupClassControllerTest.php` (roster + add/make-available/invite actions)
- `tests/Unit/GroupClass/EnrollmentTest.php` - `tests/Unit/GroupClass/EnrollmentTest.php`
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.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)
+13 -45
View File
@@ -20,72 +20,41 @@ Students register for a private lesson by choosing an offering, picking a time (
| `created_at` | DATETIME | Insertion time | | `created_at` | DATETIME | Insertion time |
## Registration Flow ## Registration Flow
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form). 1. Student opens the page with the `[us_booking]` shortcode and browses the calendar.
2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot. Every booking requires an offering — a generic slot with no fitting offering cannot be booked online. 2. Student picks an **offering** (a 30 or 60-minute private-lesson type) and a slot.
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term. 3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
4. Student answers the offering's questions (`GET /offerings/{id}/questions`). 4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
5. Student accepts the current published policy versions (`GET /policies`) — required to continue. 5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
6. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`. 6. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately. 7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment.
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed. 8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`. 9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) with a per-lesson status badge (pending payment / confirmed) and a **Cancel** button.
## Cancellation
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
Cancelling marks the lesson `cancelled`, frees the availability slot for
rebooking, and voids a still-pending payment (marked `failed` so it leaves the
admin confirmation queue). A `paid` payment is never touched — refunds are a
manual, admin-side decision. Instructors cancelling via
`PATCH /bookings/{id}/status` get the same slot release and payment voiding;
reinstating a cancelled lesson re-claims its slot and fails with `409
slot_taken` if the freed time was booked by someone else in the meantime.
## Weekly Reservations ## Weekly Reservations
A weekly reservation creates one `series_id` shared across N lesson rows (one per A weekly reservation creates one `series_id` shared across N lesson rows (one per
week in the term) and reserves the matching availability windows. It is billed week in the term) and reserves the matching availability windows. It is billed
**upfront as a single payment** linked to the series' first (anchor) lesson: **full-term upfront** as a single payment (`billing_mode = full_term` on the
- `billing_mode = full_term` — the offering's price already covers the term and is charged once. offering).
- `billing_mode = one_time` — the per-lesson price is charged **once per occurrence actually claimed** (price × N).
Settling that payment (Stripe webhook, e-transfer confirmation, comp) confirms
**every non-cancelled lesson in the series**
(`BookingRepository::updateStatusForSeries()`), not just the anchor row.
## REST API ## REST API
| Method | Endpoint | Permission | | Method | Endpoint | Permission |
|-----------|-------------------------------------------------|--------------------------------| |-----------|-------------------------------------------------|--------------------------------|
| `GET` | `/wp-json/us-scheduler/v1/bookings` | Any logged-in user | | `GET` | `/wp-json/us-scheduler/v1/bookings` | Any logged-in user |
| `POST` | `/wp-json/us-scheduler/v1/bookings` | `book_lesson` | | `POST` | `/wp-json/us-scheduler/v1/bookings` | `book_lesson` |
| `POST` | `/wp-json/us-scheduler/v1/bookings/{id}/cancel` | Logged-in owner of the lesson |
| `PATCH` | `/wp-json/us-scheduler/v1/bookings/{id}/status` | `manage_availability` or admin | | `PATCH` | `/wp-json/us-scheduler/v1/bookings/{id}/status` | `manage_availability` or admin |
`POST /bookings` body: `offering_id`, `slot_id`, `recurrence`, `answers[]` `POST /bookings` body: `offering_id`, `slot_id`, `recurrence`, `answers[]`
(`question_id` → value), `accepted_policy_version_ids[]`, and payment data (`question_id` → value), `accepted_policy_version_ids[]`, and payment data
(see `payments.md`). The response includes `ids`, the resulting lesson (see `payments.md`).
`status`, and `payment` — a `{id, method, status}` summary, or `null` when
nothing is owed (the front end then skips the payment step).
An offering is always required (`400 offering_required` otherwise): a slot tied `GET /bookings` returns the caller's own lessons (student view) or upcoming lessons for the instructor if the caller has `manage_availability`.
to an offering uses that offering regardless of the request, while a generic
slot uses the student's `offering_id`, which must be one of the instructor's
active `private_lesson` offerings whose `duration_minutes` matches the slot.
`GET /bookings` returns the caller's upcoming, non-cancelled lessons (their own
for students; the instructor's for callers with `manage_availability`), each
with the slot's `start_dt`/`end_dt`.
Group classes follow the same registration flow but enrol against an offering of Group classes follow the same registration flow but enrol against an offering of
kind `group_class`; see `group-classes.md`. kind `group_class`; see `group-classes.md`.
## Admin Interface ## Admin Interface
- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors - **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors
- **My Lessons** (`view_own_lessons`): upcoming lessons for the logged-in instructor. Hidden for users who also hold `view_all_lessons` — Scheduler is a superset, so the menu item would only duplicate it. - **My Lessons** (`view_own_lessons`): upcoming lessons for the logged-in instructor
Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week`
query params, same pattern as the availability page, bucketed via
`Availability\WeekCalendar`), with the original table available as the **List**
view — the list is where the per-lesson HST and e-transfer edit forms live.
## Frontend Shortcodes ## Frontend Shortcodes
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability - `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability
@@ -99,12 +68,11 @@ view — the list is where the per-lesson HST and e-transfer edit forms live.
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint` - REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage` - Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
> **Payment seam:** a priced booking is created with `status = pending` and its > **Payment seam:** payment is deferred to the Payments feature (#7). For now a
> payment linked via `payment_id`; the lesson is confirmed when the payment is > booking is created with `status = pending` and `payment_id = null`; the
> settled (see `payments.md`) or manually via `PATCH /bookings/{id}/status`. > instructor confirms via `PATCH /bookings/{id}/status`. When payments land, the
> Unpriced bookings skip the seam entirely and are confirmed at creation. > pay→confirm + receipt step plugs into this seam. `GET /policies?scope=booking`
> `GET /policies?scope=booking` returns just the booking-gate policies the form > returns just the booking-gate policies the form must collect.
> must collect.
## Tests ## Tests
- `tests/Unit/Booking/BookingRepositoryTest.php` - `tests/Unit/Booking/BookingRepositoryTest.php`
+2 -44
View File
@@ -20,10 +20,7 @@ An offering is anything a student can register for: a private-lesson type (30 or
| `capacity` | SMALLINT | Group only — max enrolments; NULL for private | | `capacity` | SMALLINT | Group only — max enrolments; NULL for private |
| `term_start` | DATE | Group / term offerings — first day; NULL otherwise | | `term_start` | DATE | Group / term offerings — first day; NULL otherwise |
| `term_end` | DATE | Group / term offerings — last 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"| | `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 | | `is_active` | TINYINT(1) | 0 = hidden from registration, 1 = bookable |
| `created_at` | DATETIME | Insertion time | | `created_at` | DATETIME | Insertion time |
@@ -31,46 +28,11 @@ An offering is anything a student can register for: a private-lesson type (30 or
- `one_time` — charged once at booking (a single private lesson). - `one_time` — charged once at booking (a single private lesson).
- `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`. - `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`.
## Term Dates
Group classes carry a term: `term_start` is the date of the first class and
`term_end` the last. The add-offering form takes a start date plus a sessions
control — **one-off** (the term ends the day it starts) or **weekly for N
sessions** (`term_end = term_start + (N1) weeks`, computed by
`Offering::weeklyTermEnd()`). Dates are validated by `Offering::normalizeDate()`
(strict `Y-m-d`); an invalid start date leaves both term columns NULL. The
student-facing class card shows the date (one-off) or the date range with the
weekly session count.
## 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 ## Admin Interface
Studio admin and instructors manage offerings under **Offerings** in wp-admin. Studio admin and instructors manage offerings under **Offerings** in wp-admin.
- Studio admin (`manage_offerings`) manages offerings for any instructor. - Studio admin (`manage_offerings`) manages offerings for any instructor.
- Instructor (`manage_offerings`) manages only their own. - Instructor (`manage_offerings`) manages only their own.
- Each offering's intake questions are edited from the offering screen (see `registration-questions.md`). - Each offering's intake questions are edited from the offering screen (see `registration-questions.md`).
- The offerings list shows each offering's ID (needed for `[us_group_classes offering="…"]`) and its term dates.
- **Edit** on a row reloads the page (`?usc_edit=<id>`) with the form prefilled; saving posts `usc_action=update`. Owner and currency are always preserved on update, so a form submission can never reassign an offering. Non-admin instructors can only load and update their own offerings.
- The form includes a **description** textarea and an **Active — open for registration** checkbox (unchecking hides the offering from students without deleting it — the admin-UI counterpart of the REST `is_active` flag).
## REST API ## REST API
| Method | Endpoint | Permission | | Method | Endpoint | Permission |
@@ -84,14 +46,10 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
## Implementation ## Implementation
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository` - Repository: `Unsupervised\Schedular\Offering\OfferingRepository`
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`) - Model: `Unsupervised\Schedular\Offering\Offering`
- Admin controller: `Unsupervised\Schedular\Offering\OfferingController` - Admin controller: `Unsupervised\Schedular\Offering\OfferingController`
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` (public listing includes `instructor_name`) - REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint`
- Availability reconciliation: `Unsupervised\Schedular\Offering\ClassSlotReconciler` (uses `Availability\AvailabilityRepository::findOverlapping`)
## Tests ## Tests
- `tests/Unit/Offering/OfferingControllerTest.php`
- `tests/Unit/Offering/OfferingRepositoryTest.php` - `tests/Unit/Offering/OfferingRepositoryTest.php`
- `tests/Unit/Offering/OfferingTest.php` - `tests/Unit/Offering/OfferingTest.php`
- `tests/Unit/Offering/OfferingEndpointTest.php`
- `tests/Unit/Offering/ClassSlotReconcilerTest.php`
-5
View File
@@ -54,11 +54,6 @@ and `instructor_id` query params as the page and returns `text/csv` with a
`Content-Disposition: attachment` header. Instructor requests are scoped to `Content-Disposition: attachment` header. Instructor requests are scoped to
their own rows regardless of `instructor_id`. their own rows regardless of `instructor_id`.
Fields that a spreadsheet would interpret as a formula (leading `=`, `+`, `-`,
`@`, tab, or CR — e.g. a hostile student display name) are prefixed with an
apostrophe so the export can never carry CSV formula injection into Excel or
Google Sheets.
## Implementation ## Implementation
- Report aggregator (pure totals + CSV): `Unsupervised\Schedular\Payment\PaymentReport` - Report aggregator (pure totals + CSV): `Unsupervised\Schedular\Payment\PaymentReport`
+2 -4
View File
@@ -6,9 +6,7 @@ falls back to **e-transfer** — a pending payment a studio admin marks received
so everything works without any credentials. When Stripe **is** configured the so everything works without any credentials. When Stripe **is** configured the
default rail becomes the **credit card**. The studio admin can override any default rail becomes the **credit card**. The studio admin can override any
student's method (card / e-transfer / comp). Single bookings are charged once; student's method (card / e-transfer / comp). Single bookings are charged once;
weekly reservations and group classes are charged the full term upfront (a weekly reservations and group classes are charged the full term upfront. A
`full_term` price once, or a per-lesson `one_time` price × the occurrences
reserved — see `lesson-booking.md`). A
numbered receipt is emailed automatically when a payment is marked paid. numbered receipt is emailed automatically when a payment is marked paid.
> **Implemented:** the payment ledger, studio settings, method resolution > **Implemented:** the payment ledger, studio settings, method resolution
@@ -96,7 +94,7 @@ After booking, the destination on a payment can be corrected per booking:
| `paid_at` | DATETIME | When marked `paid`; NULL otherwise | | `paid_at` | DATETIME | When marked `paid`; NULL otherwise |
## Payment Flow ## Payment Flow
1. During registration the front-end calls `POST /payments/intent` — but only when the registration response carried a `payment` summary (unpriced registrations return `payment: null` and skip the payment step). The intent call creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.) 1. During registration the front-end calls `POST /payments/intent`, which creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.)
2. The browser confirms the card payment with Stripe. 2. The browser confirms the card payment with Stripe.
3. Stripe calls `POST /payments/webhook`; on `payment_intent.succeeded` the payment is marked `paid`, `paid_at` is stamped, and the linked lesson/enrolment is `confirmed`. 3. Stripe calls `POST /payments/webhook`; on `payment_intent.succeeded` the payment is marked `paid`, `paid_at` is stamped, and the linked lesson/enrolment is `confirmed`.
4. On transition to `paid`, `ReceiptMailer` assigns a `receipt_number`, emails the student a receipt, and stamps `receipt_sent_at`. 4. On transition to `paid`, `ReceiptMailer` assigns a `receipt_number`, emails the student a receipt, and stamps `receipt_sent_at`.
-73
View File
@@ -1,73 +0,0 @@
# Feature: Plugin Self-Update from Gitea Releases
## Overview
WordPress sites running this plugin receive updates directly from the Gitea
repository's releases — no wordpress.org listing and no manual zip uploads.
Publishing a release is the whole deploy: bump the version, merge to `main`,
tag `vX.Y.Z` in Gitea. Every site sees the update on its next check and can
install it with one click, or unattended if the site admin enables
auto-updates for the plugin.
## How It Works
### Release side (`.gitea/workflows/release.yml`)
Pushing a `v*` tag (including tags created through Gitea's "New Release" UI)
triggers the release workflow, which:
1. Fails if the tag does not match the `Version:` plugin header — a mismatch
would make sites see a phantom update forever, or never see a real one.
2. Runs the test suite.
3. Builds the distributable zip via `composer build` (`bin/build-zip.sh`):
a single top-level `unsupervised-schedular/` folder with a production
(no-dev) Composer autoloader.
4. Creates the release for the tag (or reuses one created via the UI) and
attaches the zip as a release asset. Versions containing a hyphen
(e.g. `1.2.3-rc.1`) are flagged as pre-releases.
The attached asset — not Gitea's auto-generated source archive — is the
update package. Source archives have the wrong top-level folder name and no
`vendor/` directory, so WordPress could not install them.
### Site side (`src/Update/UpdateChecker.php`)
The plugin header declares:
```
Update URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
```
Since WP 5.8 that header both blocks wordpress.org from ever serving an
update for a same-slug plugin and makes core fire the
`update_plugins_git.unsupervised.ca` filter during update checks.
`UpdateChecker` (registered in `Plugin::boot()`) answers that filter:
1. Fetches `GET /api/v1/repos/Unsupervised/unsupervised-scheduler/releases/latest`
(anonymous — the repo is public). The `/latest` endpoint excludes drafts
and pre-releases, so `-rc` builds are never offered to sites.
2. Caches the result (including failures) in the
`us_schedular_latest_release` transient for 6 hours.
3. Strips the leading `v` from the tag and compares against `USC_VERSION`
with `version_compare`; PHP orders `1.0.0-rc.2 < 1.0.0` correctly.
4. When newer, returns the release's first `.zip` asset as the update
package. Core takes over from there: Plugins-screen notice, one-click
update, and WP-Cron auto-updates if enabled.
Any API failure, malformed response, or asset-less release degrades to
"no update available" — never an error surfaced to the site.
## Cutting a Release
1. Bump the version in `unsupervised-schedular.php` (both the `Version:`
header and the `USC_VERSION` constant) and merge to `main`.
2. Tag the merge commit `vX.Y.Z` — via Gitea's New Release UI or
`git tag vX.Y.Z && git push origin vX.Y.Z`.
3. The release workflow attaches the zip; sites pick the update up on their
next check (twice daily via cron, or immediately from
Dashboard → Updates → Check again).
## Classes
| Class | Responsibility |
|---|---|
| `Update\UpdateChecker` | Answers core's `update_plugins_{hostname}` filter from the Gitea releases API |
## Tests
- `tests/Unit/Update/UpdateCheckerTest.php`
+15 -42
View File
@@ -1,30 +1,19 @@
# Feature: Registration Questions # Feature: Registration Questions
## Overview ## Overview
Questions come in two **scopes**: 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.
- **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` ## Data Model — `{prefix}us_questions`
| Column | Type | Notes | | Column | Type | Notes |
|---------------|------------------|-------------------------------------------------------------| |---------------|------------------|-------------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key | | `id` | BIGINT UNSIGNED | Primary key |
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` for offering-scoped questions; NULL for account-scoped | | `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` — questions are scoped per offering |
| `scope` | VARCHAR(20) | `offering` (default) or `account` |
| `label` | VARCHAR(255) | The question text shown to the registrant | | `label` | VARCHAR(255) | The question text shown to the registrant |
| `field_type` | VARCHAR(20) | `text` / `textarea` / `select` / `checkbox` | | `field_type` | VARCHAR(20) | `text` / `textarea` / `select` / `checkbox` |
| `options` | TEXT | JSON array of choices (for `select`); NULL otherwise | | `options` | TEXT | JSON array of choices (for `select`); NULL otherwise |
| `is_required` | TINYINT(1) | 1 = registrant must answer to continue | | `is_required` | TINYINT(1) | 1 = registrant must answer to continue |
| `sort_order` | INT | Display order within the scope | | `sort_order` | INT | Display order within the offering |
| `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form | | `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form |
| `created_at` | DATETIME | Insertion time | | `created_at` | DATETIME | Insertion time |
@@ -34,37 +23,27 @@ and the same authoring page (**Offerings → Questions**).
|---------------------|------------------|--------------------------------------------------------| |---------------------|------------------|--------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key | | `id` | BIGINT UNSIGNED | Primary key |
| `question_id` | BIGINT UNSIGNED | FK → `us_questions.id` | | `question_id` | BIGINT UNSIGNED | FK → `us_questions.id` |
| `registration_type` | VARCHAR(20) | `lesson`, `enrollment`, or `account` | | `registration_type` | VARCHAR(20) | `lesson` or `enrollment` |
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id`, `us_group_enrollments.id`, or the user ID (account scope) | | `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.id` |
| `student_id` | BIGINT UNSIGNED | WordPress user ID (denormalised for fast lookup) | | `student_id` | BIGINT UNSIGNED | WordPress user ID (denormalised for fast lookup) |
| `answer_value` | TEXT | The submitted answer (checkbox stored as `0`/`1`) | | `answer_value` | TEXT | The submitted answer (checkbox stored as `0`/`1`) |
| `created_at` | DATETIME | Insertion time | | `created_at` | DATETIME | Insertion time |
The `registration_type` + `registration_id` pair is a polymorphic reference shared The `registration_type` + `registration_id` pair is a polymorphic reference shared
with `us_policy_acceptances` (see `policies.md`), letting answers attach to a private with `us_policy_acceptances` (see `policies.md`), letting answers attach to either a
lesson, a group enrolment, or an account signup (`account` + the user ID). private lesson or a group enrolment.
## Offering-scope Flow ## Flow
1. On the booking form, the front-end calls `GET /offerings/{id}/questions`. 1. On the registration form, the front-end calls `GET /offerings/{id}/questions`.
2. Required questions block submission until answered. 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. 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 ## Admin Interface
Both scopes are edited from **Offerings → Questions** (`Registration\QuestionController`): Questions are edited from each offering's screen (**Offerings → Questions**).
- Pick an offering to edit its questions, or **"Account signup (all registrations)"** for the account-scope questions. - Studio admin (`manage_questions`) edits questions on any offering.
- 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.
- Instructor (`manage_questions`) edits questions only on their own offerings; the account-scope option is hidden.
## REST API ## 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 | | Method | Endpoint | Permission |
|----------|---------------------------------------------------|----------------------| |----------|---------------------------------------------------|----------------------|
| `GET` | `/wp-json/us-scheduler/v1/offerings/{id}/questions`| Public | | `GET` | `/wp-json/us-scheduler/v1/offerings/{id}/questions`| Public |
@@ -73,18 +52,12 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
| `DELETE` | `/wp-json/us-scheduler/v1/questions/{id}` | `manage_questions` + owner | | `DELETE` | `/wp-json/us-scheduler/v1/questions/{id}` | `manage_questions` + owner |
## Implementation ## Implementation
- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository` (`findByOffering`, `findByScope`), `Unsupervised\Schedular\Registration\AnswerRepository` - Repositories: `Unsupervised\Schedular\Registration\QuestionRepository`, `Unsupervised\Schedular\Registration\AnswerRepository`
- Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`) - Models: `Unsupervised\Schedular\Registration\Question`, `Unsupervised\Schedular\Registration\Answer`
- Admin controller: `Unsupervised\Schedular\Registration\QuestionController` - Admin controller: `Unsupervised\Schedular\Registration\QuestionController`
- REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint` (offering scope only) - REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint`
- 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
- `tests/Unit/Registration/QuestionRepositoryTest.php` - `tests/Unit/Registration/QuestionRepositoryTest.php`
- `tests/Unit/Registration/AnswerRepositoryTest.php` - `tests/Unit/Registration/AnswerRepositoryTest.php`
- `tests/Unit/Registration/QuestionTest.php` - `tests/Unit/Registration/QuestionTest.php`
- `tests/Unit/Registration/AnswerTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php`
- `tests/Unit/Auth/StudentHistoryTest.php`
+6 -42
View File
@@ -1,21 +1,15 @@
# Feature: Student Administration # Feature: Student Administration
## Overview ## Overview
A studio-admin area to browse students, drill into one student's history and A read-only studio-admin area to browse students and drill into one student's
upcoming activity — lessons and group-class enrolments — and act on their history and upcoming activity — lessons and group-class enrolments — without
behalf: cancel a lesson, withdraw them from a group class, or fix their account digging through individual records.
details.
## Data Model ## Data Model
No new tables. The views are composed from existing data: No new tables. The views are composed from existing data:
- Students are WordPress users with the `us_student` role (`get_users`, `get_userdata`). - Students are WordPress users with the `us_student` role (`get_users`, `get_userdata`).
- Lessons come from `{prefix}us_lessons` (with `{prefix}us_availability` for slot times). - Lessons come from `{prefix}us_lessons` (with `{prefix}us_availability` for slot times).
- Group-class enrolments come from `{prefix}us_group_enrollments`. - Group-class enrolments come from `{prefix}us_group_enrollments`.
- Policy acceptances come from `{prefix}us_policy_acceptances` (with the policy
and version tables for titles/numbers).
- Intake answers come from `{prefix}us_question_answers` (with `{prefix}us_questions`
for labels).
- Payments come from `{prefix}us_payments`.
## Admin Interface ## Admin Interface
**Students** in wp-admin (`manage_students`, studio admin only): **Students** in wp-admin (`manage_students`, studio admin only):
@@ -28,25 +22,10 @@ No new tables. The views are composed from existing data:
- **Upcoming lessons** and **Past lessons** — split by the linked availability - **Upcoming lessons** and **Past lessons** — split by the linked availability
slot's `start_dt`; each shows date/time, offering, instructor, and status. slot's `start_dt`; each shows date/time, offering, instructor, and status.
- **Group-class enrolments** — active/past, with offering title and status. - **Group-class enrolments** — active/past, with offering title and status.
- **Policy acceptances** — every acceptance the student has recorded, newest - *(Later)* policy-acceptance history, intake answers, and payment history once
first: policy title, version, context (account signup / lesson / enrolment), Payments lands.
and when it was accepted.
- **Intake answers** — every registration-question answer, newest first:
question label, answer, and the registration it was given for.
- **Payment history** (`manage_billing` only) — every payment, newest first:
date, context, method, status, subtotal, HST, total, and receipt number.
### Admin actions (detail view) Read-only in this iteration; cancel/edit actions are a possible follow-up.
All actions are nonce-protected POSTs handled on the detail page:
- **Edit account** — display name and email. The email must be valid and not in
use by another account.
- **Cancel lesson** — on any non-cancelled upcoming lesson. Uses the same path
as student-initiated cancellation: the lesson is marked `cancelled`, the
availability slot is freed for rebooking, and a still-pending payment is
voided. Paid lessons keep their payment — refunds stay a manual decision (#72).
- **Withdraw** — on an active group-class enrolment: marked `cancelled` (freeing
its capacity seat), with the same pending-payment voiding.
## Capabilities ## Capabilities
- `manage_students` — studio admin (administrators inherit it via the - `manage_students` — studio admin (administrators inherit it via the
@@ -59,15 +38,6 @@ All actions are nonce-protected POSTs handled on the detail page:
`Availability\AvailabilityRepository::findById`, `Availability\AvailabilityRepository::findById`,
`Offering\OfferingRepository::findById`, `Offering\OfferingRepository::findById`,
`GroupClass\EnrollmentRepository::findByStudent` + `countActiveForStudent` `GroupClass\EnrollmentRepository::findByStudent` + `countActiveForStudent`
- History sections: `Auth\StudentHistory` builds the display rows from
`Policy\AcceptanceRepository::findByStudent`,
`Registration\AnswerRepository::findByStudent`, and
`Payment\PaymentRepository::findByStudent`, resolving policy/version titles and
question labels (unit-tested with mocked repositories).
- Actions: `Auth\StudentActions` — cancel lesson / withdraw enrolment (both
refuse records that don't belong to the student, and reuse
`Payment\PaymentService::voidPending`) and account updates via
`wp_update_user` (unit-tested with mocked repositories).
- Upcoming/past split: `Auth\StudentSchedule::partition()` (pure, unit-tested) - Upcoming/past split: `Auth\StudentSchedule::partition()` (pure, unit-tested)
- The upcoming/past split is extracted into a small pure helper so it is - The upcoming/past split is extracted into a small pure helper so it is
unit-testable (the controller itself follows the repo convention of not being unit-testable (the controller itself follows the repo convention of not being
@@ -75,9 +45,3 @@ All actions are nonce-protected POSTs handled on the detail page:
## Tests ## Tests
- `tests/Unit/Auth/StudentScheduleTest.php` (the pure upcoming/past split helper) - `tests/Unit/Auth/StudentScheduleTest.php` (the pure upcoming/past split helper)
- `tests/Unit/Auth/StudentHistoryTest.php` (history display rows + fallbacks)
- `tests/Unit/Auth/StudentActionsTest.php` (cancel/withdraw guards + side
effects, account validation)
- `findByStudent` coverage in `tests/Unit/Policy/AcceptanceRepositoryTest.php`,
`tests/Unit/Registration/AnswerRepositoryTest.php`, and
`tests/Unit/Payment/PaymentRepositoryTest.php`
+1 -26
View File
@@ -44,32 +44,7 @@
</properties> </properties>
</rule> </rule>
<!--
Val::* type-narrowing helpers (src/Val.php) wrap superglobal reads so
PHPStan level 10 sees a typed value, e.g.
`absint( Val::int( $_GET['id'] ?? 0 ) )`. The sniff walks wrapping
calls innermost-out and aborts at the first unrecognised function
name, so the Val method names must be registered for it to look past
them. Because they are static calls (`::`), the sniff never credits
them as sanitizers themselves — it skips them and still requires a
real sanitizing function around the read.
-->
<rule ref="WordPress.Security.ValidatedSanitizedInput">
<properties>
<property name="customUnslashingSanitizingFunctions" type="array">
<element value="int"/>
<element value="intOrNull"/>
<element value="float"/>
<element value="bool"/>
</property>
<property name="customSanitizingFunctions" type="array">
<element value="string"/>
<element value="stringOrNull"/>
</property>
</properties>
</rule>
<!-- PHP 8.1+ minimum — allow modern syntax. --> <!-- PHP 8.1+ minimum — allow modern syntax. -->
<config name="minimum_supported_wp_version" value="6.2"/> <config name="minimum_supported_wp_version" value="6.0"/>
<config name="testVersion" value="8.1-"/> <config name="testVersion" value="8.1-"/>
</ruleset> </ruleset>
+1 -1
View File
@@ -2,7 +2,7 @@ includes:
- vendor/szepeviktor/phpstan-wordpress/extension.neon - vendor/szepeviktor/phpstan-wordpress/extension.neon
parameters: parameters:
level: 10 level: 6
paths: paths:
- src - src
bootstrapFiles: bootstrapFiles:
+24 -59
View File
@@ -8,19 +8,13 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\AccessSettings; use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Auth\InstructorController; use Unsupervised\Schedular\Auth\InstructorController;
use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationApprovalController;
use Unsupervised\Schedular\Auth\RegistrationController; use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\StudentActions;
use Unsupervised\Schedular\Auth\StudentController; use Unsupervised\Schedular\Auth\StudentController;
use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonController; use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController; use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\BillingMethodResolver;
@@ -29,12 +23,10 @@ use Unsupervised\Schedular\Payment\PaymentReportController;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyController; use Unsupervised\Schedular\Policy\PolicyController;
use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyService; use Unsupervised\Schedular\Policy\PolicyService;
use Unsupervised\Schedular\Policy\PolicyVersionRepository; use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\QuestionController; use Unsupervised\Schedular\Registration\QuestionController;
use Unsupervised\Schedular\Registration\QuestionRepository; use Unsupervised\Schedular\Registration\QuestionRepository;
@@ -46,7 +38,6 @@ class AdminMenu {
private QuestionController $questionController; private QuestionController $questionController;
private PolicyController $policyController; private PolicyController $policyController;
private RegistrationController $registrationController; private RegistrationController $registrationController;
private RegistrationApprovalController $registrationApprovalController;
private GroupClassController $groupClassController; private GroupClassController $groupClassController;
private StudentController $studentController; private StudentController $studentController;
private InstructorController $instructorController; private InstructorController $instructorController;
@@ -55,21 +46,20 @@ class AdminMenu {
private PaymentController $paymentController; private PaymentController $paymentController;
private PaymentReportController $paymentReportController; 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, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer ) { public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings ); $this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments, $availability ); $this->lessonController = new LessonController( $bookings, $payments );
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) ); $this->offeringController = new OfferingController( $offerings );
$this->questionController = new QuestionController( $questions, $offerings ); $this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites ); $this->registrationController = new RegistrationController( $invites );
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer ); $this->groupClassController = new GroupClassController( $enrollments, $offerings );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer ); $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) ); $this->instructorController = new InstructorController();
$this->instructorController = new InstructorController(); $this->settings = $settings;
$this->settings = $settings; $this->accessSettings = new AccessSettings();
$this->accessSettings = new AccessSettings(); $this->paymentController = new PaymentController( $payments, $paymentService );
$this->paymentController = new PaymentController( $payments, $paymentService ); $this->paymentReportController = new PaymentReportController( $payments );
$this->paymentReportController = new PaymentReportController( $payments );
} }
public function register(): void { public function register(): void {
@@ -178,16 +168,6 @@ class AdminMenu {
35 35
); );
// Studio admin: approve or reject self-signup students (open registration).
add_submenu_page(
'us-students',
__( 'Pending Students', 'unsupervised-schedular' ),
__( 'Pending Students', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_STUDENTS,
RegistrationApprovalController::PAGE_SLUG,
[ $this->registrationApprovalController, 'renderPage' ]
);
// Studio admin: confirm pending (e-transfer) payments. // Studio admin: confirm pending (e-transfer) payments.
add_menu_page( add_menu_page(
__( 'Payments', 'unsupervised-schedular' ), __( 'Payments', 'unsupervised-schedular' ),
@@ -235,31 +215,16 @@ class AdminMenu {
30.5 30.5
); );
// Instructor: view their upcoming lessons. Hidden for anyone who can // Instructor: view their upcoming lessons.
// already see the Scheduler — it shows every instructor's lessons add_menu_page(
// (including their own, with the same payment edit forms), so the two __( 'My Lessons', 'unsupervised-schedular' ),
// menu items would just duplicate each other for an owner-operator. __( 'My Lessons', 'unsupervised-schedular' ),
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) { RoleManager::CAP_VIEW_LESSONS,
add_menu_page( 'us-my-lessons',
__( 'My Lessons', 'unsupervised-schedular' ), [ $this->lessonController, 'renderInstructorLessons' ],
__( 'My Lessons', 'unsupervised-schedular' ), 'dashicons-welcome-learn-more',
RoleManager::CAP_VIEW_LESSONS, 42
'us-my-lessons', );
[ $this->lessonController, 'renderInstructorLessons' ],
'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' ]
);
}
} }
/** /**
+1 -3
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth; namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/** /**
* Site-owner toggles for whether WordPress administrators automatically receive * Site-owner toggles for whether WordPress administrators automatically receive
* the studio-admin and/or instructor capabilities. * the studio-admin and/or instructor capabilities.
@@ -41,7 +39,7 @@ class AccessSettings {
* single-account behaviour. * single-account behaviour.
*/ */
private function flag( string $option ): bool { private function flag( string $option ): bool {
return '0' !== Val::string( get_option( $option, '1' ) ); return '0' !== (string) get_option( $option, '1' );
} }
public function renderPage(): void { public function renderPage(): void {
-143
View File
@@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Val;
/**
* Handles the self-signup email-confirmation link, and keeps WordPress's own
* registration form from being used to bypass the studio's policy-accepting
* registration page while open registration is enabled.
*/
class EmailConfirmationHandler {
public function __construct(
private StudioSettings $settings,
private RegistrationMailer $mailer,
) {}
public function register(): void {
add_action( 'template_redirect', [ $this, 'maybeConfirm' ] );
add_filter( 'register_url', [ $this, 'registerUrl' ] );
// login_init fires at the top of wp-login.php for every request (GET form
// display AND a direct POST) before any registration processing, so it is
// the reliable choke point; registration_errors is a fail-safe in case a
// POST ever reaches register_new_user().
add_action( 'login_init', [ $this, 'blockNativeRegistration' ] );
add_filter( 'registration_errors', [ $this, 'blockRegistrationErrors' ], 10, 1 );
}
/**
* Confirm a self-signup's email when the emailed `?us_confirm=<token>` link
* is opened, then redirect back to the registration page with a result flag.
*/
public function maybeConfirm(): void {
if ( is_admin() ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the token is itself the capability-bearing secret (like a password-reset key); nonces do not apply to an emailed link.
$rawToken = sanitize_text_field( Val::string( wp_unslash( $_GET['us_confirm'] ?? '' ) ) );
if ( '' === $rawToken ) {
return;
}
$base = $this->registrationPageUrl();
$userId = RegistrationStatus::userIdForToken( $rawToken );
if ( null === $userId || RegistrationStatus::isTokenExpired( $userId, gmdate( 'Y-m-d H:i:s' ) ) ) {
wp_safe_redirect( add_query_arg( 'us_confirmed', 'expired', $base ) );
exit;
}
RegistrationStatus::confirmEmail( $userId );
$user = get_user_by( 'id', $userId );
// Group invite link signups skip the admin review queue: confirming the
// email approves the account on the spot, so the student can sign in
// immediately instead of waiting for a studio admin.
if ( RegistrationStatus::isAutoApprove( $userId ) ) {
RegistrationStatus::approve( $userId );
if ( $user instanceof \WP_User ) {
$this->mailer->sendApproved( $user );
}
wp_safe_redirect( add_query_arg( 'us_confirmed', 'ready', $base ) );
exit;
}
if ( $user instanceof \WP_User ) {
$this->mailer->notifyAdminsPending( $user );
}
wp_safe_redirect( add_query_arg( 'us_confirmed', '1', $base ) );
exit;
}
/**
* Point WordPress's own "Register" links at the studio registration page
* while open registration is on and a page is configured.
*/
public function registerUrl( string $url ): string {
if ( ! $this->settings->openRegistrationEnabled() ) {
return $url;
}
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
return $pageId > 0 ? (string) get_permalink( $pageId ) : $url;
}
/**
* Redirect any `wp-login.php?action=register` request (GET or POST) to the
* studio registration page, so the bare native form — which cannot collect
* required policy acceptances — is never used.
*/
public function blockNativeRegistration(): void {
if ( ! $this->settings->openRegistrationEnabled() ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing decision; no state is changed here.
$action = sanitize_key( Val::string( wp_unslash( $_REQUEST['action'] ?? '' ) ) );
if ( 'register' !== $action ) {
return;
}
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
if ( $pageId <= 0 ) {
return;
}
wp_safe_redirect( (string) get_permalink( $pageId ) );
exit;
}
/**
* Fail-safe: reject any native registration attempt while open registration
* is on, so `register_new_user()` can never create a policy-less account.
*
* @param \WP_Error $errors Accumulated registration errors.
* @return \WP_Error
*/
public function blockRegistrationErrors( \WP_Error $errors ): \WP_Error {
if ( $this->settings->openRegistrationEnabled() ) {
$errors->add(
'us_registration_redirect',
esc_html__( 'Please register on the studio registration page.', 'unsupervised-schedular' )
);
}
return $errors;
}
private function registrationPageUrl(): string {
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
return $pageId > 0 ? (string) get_permalink( $pageId ) : home_url( '/' );
}
}
+12 -18
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth; namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/** /**
* Studio-admin **Instructors** page: create instructor accounts and toggle each * Studio-admin **Instructors** page: create instructor accounts and toggle each
* instructor's managed capabilities. Gated on `manage_instructors`. A studio * instructor's managed capabilities. Gated on `manage_instructors`. A studio
@@ -26,7 +24,7 @@ class InstructorController {
} }
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only instructor selector. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only instructor selector.
$instructorId = absint( Val::int( $_GET['instructor_id'] ?? 0 ) ); $instructorId = absint( $_GET['instructor_id'] ?? 0 );
$instructor = $instructorId > 0 ? get_userdata( $instructorId ) : false; $instructor = $instructorId > 0 ? get_userdata( $instructorId ) : false;
if ( $instructor && in_array( RoleManager::INSTRUCTOR, (array) $instructor->roles, true ) ) { if ( $instructor && in_array( RoleManager::INSTRUCTOR, (array) $instructor->roles, true ) ) {
@@ -52,15 +50,12 @@ class InstructorController {
'email' => $user->user_email, 'email' => $user->user_email,
'registered' => $user->user_registered, 'registered' => $user->user_registered,
], ],
array_filter( get_users(
get_users( [
[ 'role' => RoleManager::INSTRUCTOR,
'role' => RoleManager::INSTRUCTOR, 'orderby' => 'display_name',
'orderby' => 'display_name', 'order' => 'ASC',
'order' => 'ASC', ]
]
),
static fn( mixed $user ): bool => $user instanceof \WP_User
) )
); );
@@ -71,7 +66,7 @@ class InstructorController {
private function handleFormAction(): string { private function handleFormAction(): string {
// Nonce is verified by the caller (renderPage) before this method runs. // Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
if ( 'create' === $action ) { if ( 'create' === $action ) {
@@ -87,8 +82,8 @@ class InstructorController {
private function createInstructor(): string { private function createInstructor(): string {
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) ); $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
$name = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) ); $name = sanitize_text_field( wp_unslash( $_POST['display_name'] ?? '' ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
if ( ! is_email( $email ) ) { if ( ! is_email( $email ) ) {
@@ -130,9 +125,8 @@ class InstructorController {
private function updateCaps(): string { private function updateCaps(): string {
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$instructorId = absint( Val::int( $_POST['instructor_id'] ?? 0 ) ); $instructorId = absint( $_POST['instructor_id'] ?? 0 );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each capability key is sanitized with sanitize_key() in the array_map callback. $submitted = array_map( 'sanitize_key', (array) wp_unslash( $_POST['capabilities'] ?? [] ) );
$submitted = array_values( array_map( static fn( mixed $cap ): string => sanitize_key( Val::string( $cap ) ), (array) wp_unslash( $_POST['capabilities'] ?? [] ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
$instructor = $instructorId > 0 ? get_userdata( $instructorId ) : false; $instructor = $instructorId > 0 ? get_userdata( $instructorId ) : false;
+15 -57
View File
@@ -3,20 +3,12 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth; namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
class Invite { class Invite {
public const STATUS_PENDING = 'pending'; public const STATUS_PENDING = 'pending';
public const STATUS_ACCEPTED = 'accepted'; public const STATUS_ACCEPTED = 'accepted';
public const STATUS_REVOKED = 'revoked'; public const STATUS_REVOKED = 'revoked';
/** Single-use invite addressed to one email. */
public const KIND_PERSONAL = 'personal';
/** Multi-use shareable link (e.g. for a newsletter) with an explicit expiry. */
public const KIND_GROUP = 'group';
/** /**
* All valid invite statuses. * All valid invite statuses.
* *
@@ -30,16 +22,6 @@ class Invite {
*/ */
public const EXPIRY_DAYS = 14; public const EXPIRY_DAYS = 14;
/**
* Hash a raw invitation token for storage and lookup. Only the hash is
* persisted, so a database leak (backup, SQL injection elsewhere) cannot be
* used to redeem pending invites; the raw token exists only in the emailed
* link and is shown to the admin once, at creation.
*/
public static function hashToken( string $rawToken ): string {
return hash( 'sha256', $rawToken );
}
public function __construct( public function __construct(
public readonly string $email, public readonly string $email,
public readonly string $token, public readonly string $token,
@@ -49,61 +31,40 @@ class Invite {
public readonly ?int $acceptedUserId = null, public readonly ?int $acceptedUserId = null,
public readonly ?string $acceptedAt = null, public readonly ?string $acceptedAt = null,
public readonly ?string $createdAt = null, 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, public readonly ?int $id = null,
) {} ) {}
public static function fromRow( \stdClass $row ): self { public static function fromRow( object $row ): self {
return new self( return new self(
email: Val::string( $row->email ), email: $row->email,
token: Val::string( $row->token ), token: $row->token,
role: Val::string( $row->role ), role: $row->role,
status: Val::string( $row->status ), status: $row->status,
invitedBy: Val::intOrNull( $row->invited_by ), invitedBy: null !== $row->invited_by ? (int) $row->invited_by : null,
acceptedUserId: Val::intOrNull( $row->accepted_user_id ), acceptedUserId: null !== $row->accepted_user_id ? (int) $row->accepted_user_id : null,
acceptedAt: Val::stringOrNull( $row->accepted_at ), acceptedAt: $row->accepted_at,
createdAt: Val::stringOrNull( $row->created_at ?? null ), createdAt: $row->created_at ?? null,
kind: '' !== Val::string( $row->kind ?? '' ) ? Val::string( $row->kind ) : self::KIND_PERSONAL, id: (int) $row->id,
expiresAt: Val::stringOrNull( $row->expires_at ?? null ),
offeringId: Val::intOrNull( $row->offering_id ?? null ),
id: Val::int( $row->id ),
); );
} }
public function isGroup(): bool {
return self::KIND_GROUP === $this->kind;
}
public function isPending(): bool { public function isPending(): bool {
return self::STATUS_PENDING === $this->status; return self::STATUS_PENDING === $this->status;
} }
/** /**
* Whether the invite has expired, measured against the supplied current * Whether the invite was created more than {@see EXPIRY_DAYS} ago, measured
* `Y-m-d H:i:s` timestamp. An explicit `expires_at` (set on every group * against the supplied current `Y-m-d H:i:s` timestamp. An invite with no
* link) wins; otherwise a personal invite expires {@see EXPIRY_DAYS} after * known creation time is treated as not expired.
* creation. An invite with neither timestamp is treated as not expired.
*/ */
public function isExpired( string $now ): bool { public function isExpired( string $now ): bool {
$current = strtotime( $now );
if ( false === $current ) {
return false;
}
if ( null !== $this->expiresAt ) {
$expires = strtotime( $this->expiresAt );
return false !== $expires && $current > $expires;
}
if ( null === $this->createdAt ) { if ( null === $this->createdAt ) {
return false; return false;
} }
$created = strtotime( $this->createdAt ); $created = strtotime( $this->createdAt );
if ( false === $created ) { $current = strtotime( $now );
if ( false === $created || false === $current ) {
return false; return false;
} }
@@ -128,13 +89,10 @@ class Invite {
'email' => $this->email, 'email' => $this->email,
'token' => $this->token, 'token' => $this->token,
'role' => $this->role, 'role' => $this->role,
'kind' => $this->kind,
'status' => $this->status, 'status' => $this->status,
'invited_by' => $this->invitedBy, 'invited_by' => $this->invitedBy,
'accepted_user_id' => $this->acceptedUserId, 'accepted_user_id' => $this->acceptedUserId,
'accepted_at' => $this->acceptedAt, 'accepted_at' => $this->acceptedAt,
'expires_at' => $this->expiresAt,
'offering_id' => $this->offeringId,
]; ];
} }
} }
+7 -16
View File
@@ -11,35 +11,28 @@ class InviteRepository {
$this->table = $db->prefix . 'us_invites'; $this->table = $db->prefix . 'us_invites';
} }
/**
* Persist an invite. Returns the new row id, or 0 when the insert failed —
* callers must not hand out a registration link for an unstored token.
*/
public function insert( Invite $invite ): int { public function insert( Invite $invite ): int {
$result = $this->db->insert( $this->db->insert(
$this->table, $this->table,
[ [
'email' => $invite->email, 'email' => $invite->email,
'token' => $invite->token, 'token' => $invite->token,
'role' => $invite->role, 'role' => $invite->role,
'kind' => $invite->kind,
'offering_id' => $invite->offeringId,
'status' => $invite->status, 'status' => $invite->status,
'invited_by' => $invite->invitedBy, 'invited_by' => $invite->invitedBy,
'accepted_user_id' => $invite->acceptedUserId, 'accepted_user_id' => $invite->acceptedUserId,
'created_at' => current_time( 'mysql' ), 'created_at' => current_time( 'mysql' ),
'accepted_at' => $invite->acceptedAt, 'accepted_at' => $invite->acceptedAt,
'expires_at' => $invite->expiresAt,
], ],
[ '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s' ] [ '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s' ]
); );
return false === $result ? 0 : $this->db->insert_id; return $this->db->insert_id;
} }
public function findByToken( string $token ): ?Invite { public function findByToken( string $token ): ?Invite {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE token = %s', $this->table, $token ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE token = %s", $token )
); );
return $row ? Invite::fromRow( $row ) : null; return $row ? Invite::fromRow( $row ) : null;
@@ -47,7 +40,7 @@ class InviteRepository {
public function findById( int $id ): ?Invite { public function findById( int $id ): ?Invite {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
); );
return $row ? Invite::fromRow( $row ) : null; return $row ? Invite::fromRow( $row ) : null;
@@ -59,8 +52,7 @@ class InviteRepository {
public function findPendingByEmail( string $email ): ?Invite { public function findPendingByEmail( string $email ): ?Invite {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE email = %s AND status = %s ORDER BY id DESC LIMIT 1', "SELECT * FROM {$this->table} WHERE email = %s AND status = %s ORDER BY id DESC LIMIT 1",
$this->table,
$email, $email,
Invite::STATUS_PENDING Invite::STATUS_PENDING
) )
@@ -77,8 +69,7 @@ class InviteRepository {
public function findPending(): array { public function findPending(): array {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE status = %s ORDER BY created_at DESC', "SELECT * FROM {$this->table} WHERE status = %s ORDER BY created_at DESC",
$this->table,
Invite::STATUS_PENDING Invite::STATUS_PENDING
) )
); );
+8 -27
View File
@@ -3,36 +3,32 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth; namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
class LoginPage { class LoginPage {
/** /**
* Renders the student login shortcode/block output. * Renders the student login shortcode output.
* *
* @param array<int|string, mixed> $atts Block attributes (`bookingPageId`) or * @param array<string, string> $atts Shortcode attributes (unused — reserved for future options).
* shortcode attributes (`booking_page_id`).
*/ */
public function render( array $atts ): string { public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$bookingPageId = Val::int( $atts['bookingPageId'] ?? $atts['booking_page_id'] ?? 0 );
if ( is_user_logged_in() ) { if ( is_user_logged_in() ) {
$redirect = esc_url( (string) get_permalink() );
return sprintf( return sprintf(
'<p>%s <a href="%s">%s</a>.</p>', '<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'You are already logged in.', 'unsupervised-schedular' ), esc_html__( 'You are already logged in.', 'unsupervised-schedular' ),
esc_url( $this->bookingUrl( $bookingPageId ) ?? (string) get_permalink() ), $redirect,
esc_html__( 'View available lessons', 'unsupervised-schedular' ) esc_html__( 'View available lessons', 'unsupervised-schedular' )
); );
} }
$error = ''; $error = '';
$redirect = sanitize_url( $this->bookingUrl( $bookingPageId ) ?? (string) get_permalink() ); $redirect = sanitize_url( (string) get_permalink() );
if ( isset( $_POST['us_login'] ) && check_admin_referer( 'us_student_login' ) ) { if ( isset( $_POST['us_login'] ) && check_admin_referer( 'us_student_login' ) ) {
$credentials = [ $credentials = [
'user_login' => sanitize_user( Val::string( wp_unslash( $_POST['log'] ?? '' ) ) ), 'user_login' => sanitize_user( wp_unslash( $_POST['log'] ?? '' ) ),
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized.
'user_password' => Val::string( wp_unslash( $_POST['pwd'] ?? '' ) ), 'user_password' => wp_unslash( $_POST['pwd'] ?? '' ),
'remember' => isset( $_POST['rememberme'] ), 'remember' => isset( $_POST['rememberme'] ),
]; ];
@@ -50,19 +46,4 @@ class LoginPage {
include USC_PLUGIN_DIR . 'templates/frontend/login-page.php'; include USC_PLUGIN_DIR . 'templates/frontend/login-page.php';
return (string) ob_get_clean(); return (string) ob_get_clean();
} }
/**
* Permalink of the configured booking page, or null when no page is
* chosen (or the chosen page no longer exists). Logged-in visitors are
* linked (and redirected after login) there instead of the current page.
*/
public function bookingUrl( int $bookingPageId ): ?string {
if ( $bookingPageId <= 0 ) {
return null;
}
$url = get_permalink( $bookingPageId );
return is_string( $url ) ? $url : null;
}
} }
-102
View File
@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Admin page (Students → Pending Students) for reviewing self-signup accounts:
* approve a confirmed applicant into a full student, or reject (delete) them.
* Only relevant while open registration is enabled.
*/
class RegistrationApprovalController {
public const PAGE_SLUG = 'us-pending-students';
public const NONCE_ACTION = 'usc_registration_approval';
public function __construct( private RegistrationMailer $mailer ) {}
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_STUDENTS ) ) {
wp_die( esc_html__( 'You do not have permission to manage student registrations.', 'unsupervised-schedular' ) );
}
if ( isset( $_POST['usc_action'] ) && check_admin_referer( self::NONCE_ACTION ) ) {
$this->handleAction();
}
$awaitingApproval = [];
$awaitingConfirmation = [];
foreach ( $this->pendingUsers() as $user ) {
if ( RegistrationStatus::emailConfirmed( (int) $user->ID ) ) {
$awaitingApproval[] = $user;
} else {
$awaitingConfirmation[] = $user;
}
}
include USC_PLUGIN_DIR . 'templates/admin/registrations.php';
}
/**
* Approve or reject the posted user. Approval clears the pending flags and
* emails the student; rejection emails them, then hard-deletes the account so
* the email is freed to re-apply.
*/
private function handleAction(): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
$userId = absint( Val::int( $_POST['user_id'] ?? 0 ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( $userId <= 0 || ! RegistrationStatus::isAwaitingApproval( $userId ) ) {
return;
}
if ( 'approve' === $action ) {
RegistrationStatus::approve( $userId );
$user = get_user_by( 'id', $userId );
if ( $user instanceof \WP_User ) {
$this->mailer->sendApproved( $user );
}
return;
}
if ( 'reject' === $action ) {
$user = get_user_by( 'id', $userId );
$email = $user instanceof \WP_User ? (string) $user->user_email : '';
if ( '' !== $email ) {
$this->mailer->sendRejected( $email );
}
if ( ! function_exists( 'wp_delete_user' ) ) {
require_once ABSPATH . 'wp-admin/includes/user.php';
}
wp_delete_user( $userId );
}
}
/**
* Every account still awaiting approval (confirmed or not).
*
* @return list<\WP_User>
*/
private function pendingUsers(): array {
return array_values(
array_filter(
get_users(
[
'meta_key' => RegistrationStatus::META_AWAITING_APPROVAL, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'number' => 500,
'orderby' => 'user_registered',
'order' => 'ASC',
]
),
static fn( mixed $user ): bool => $user instanceof \WP_User
)
);
}
}
+17 -101
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth; namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
class RegistrationController { class RegistrationController {
/** /**
@@ -19,132 +17,50 @@ class RegistrationController {
wp_die( esc_html__( 'You do not have permission to manage invites.', 'unsupervised-schedular' ) ); wp_die( esc_html__( 'You do not have permission to manage invites.', 'unsupervised-schedular' ) );
} }
$newInviteUrl = '';
$inviteError = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_invite_action' ) ) { if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_invite_action' ) ) {
[ $newInviteUrl, $inviteError ] = $this->handleFormAction(); $this->handleFormAction();
} }
$pendingInvites = $this->invites->findPending(); $pendingInvites = $this->invites->findPending();
$registrationPageId = Val::int( get_option( self::OPTION_PAGE, 0 ) ); $registrationPageId = (int) get_option( self::OPTION_PAGE, 0 );
$registrationPageUrl = $registrationPageId > 0 ? (string) get_permalink( $registrationPageId ) : ''; $registrationPageUrl = $registrationPageId > 0 ? (string) get_permalink( $registrationPageId ) : '';
include USC_PLUGIN_DIR . 'templates/admin/invites.php'; include USC_PLUGIN_DIR . 'templates/admin/invites.php';
} }
/** private function handleFormAction(): void {
* Handle a posted admin action. Returns `[link, error]`: the registration
* link for a freshly created invite — the only time it can be shown, since
* just the token's hash is stored — or an error message when creation
* failed; both empty for every other action.
*
* @return array{string, string}
*/
private function handleFormAction(): array {
// Nonce is verified by the caller (renderPage) before this method runs. // Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
if ( 'set_page' === $action ) { if ( 'set_page' === $action ) {
update_option( self::OPTION_PAGE, absint( Val::int( $_POST['registration_page_id'] ?? 0 ) ) ); update_option( self::OPTION_PAGE, absint( $_POST['registration_page_id'] ?? 0 ) );
} }
if ( 'invite' === $action ) { if ( 'invite' === $action ) {
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) ); $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
if ( if (
! is_email( $email ) is_email( $email )
|| false !== email_exists( $email ) && false === email_exists( $email )
|| null !== $this->invites->findPendingByEmail( $email ) && null === $this->invites->findPendingByEmail( $email )
) { ) {
return [ '', esc_html__( 'Could not create the invite: enter a valid email address that has no account and no pending invite.', 'unsupervised-schedular' ) ]; $this->invites->insert(
new Invite(
email: $email,
token: wp_generate_password( 32, false ),
invitedBy: get_current_user_id(),
)
);
} }
$rawToken = wp_generate_password( 32, false );
$id = $this->invites->insert(
new Invite(
email: $email,
token: Invite::hashToken( $rawToken ),
invitedBy: get_current_user_id(),
)
);
return $this->linkOrError( $id, $rawToken );
}
if ( 'group_invite' === $action ) {
$expiresAt = $this->normalizeExpiry( sanitize_text_field( Val::string( wp_unslash( $_POST['expires_at'] ?? '' ) ) ) );
if ( null === $expiresAt ) {
return [ '', esc_html__( 'Could not create the group link: choose an expiry date of today or later.', 'unsupervised-schedular' ) ];
}
$rawToken = wp_generate_password( 32, false );
$id = $this->invites->insert(
new Invite(
email: '',
token: Invite::hashToken( $rawToken ),
invitedBy: get_current_user_id(),
kind: Invite::KIND_GROUP,
expiresAt: $expiresAt,
)
);
return $this->linkOrError( $id, $rawToken );
} }
if ( 'revoke' === $action ) { if ( 'revoke' === $action ) {
$inviteId = absint( Val::int( $_POST['invite_id'] ?? 0 ) ); $inviteId = absint( $_POST['invite_id'] ?? 0 );
if ( $inviteId > 0 ) { if ( $inviteId > 0 ) {
$this->invites->revoke( $inviteId ); $this->invites->revoke( $inviteId );
} }
} }
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
return [ '', '' ];
}
/**
* The registration link for a stored invite, or an error when the insert
* failed — a link must never be shown for a token that was not persisted,
* since it could only ever dead-end as "invalid or expired".
*
* @return array{string, string}
*/
private function linkOrError( int $insertedId, string $rawToken ): array {
if ( $insertedId <= 0 ) {
return [ '', esc_html__( 'Could not save the invite. Deactivate and reactivate the plugin to update the database, then try again.', 'unsupervised-schedular' ) ];
}
return [ $this->registrationLink( $rawToken ), '' ];
}
/**
* Validate a submitted group-link expiry date (strict `Y-m-d`, today or
* later) and expand it to the end of that day; null when invalid or past.
*/
private function normalizeExpiry( string $date ): ?string {
$day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date );
if ( false === $day || $day->format( 'Y-m-d' ) !== $date ) {
return null;
}
if ( $date < Val::string( current_time( 'Y-m-d' ) ) ) {
return null;
}
return $date . ' 23:59:59';
}
/**
* Build the registration URL for a raw invite token.
*/
private function registrationLink( string $rawToken ): string {
$pageId = Val::int( get_option( self::OPTION_PAGE, 0 ) );
$linkBase = $pageId > 0 ? (string) get_permalink( $pageId ) : '';
return add_query_arg( 'us_invite', rawurlencode( $rawToken ), '' !== $linkBase ? $linkBase : home_url( '/' ) );
} }
} }
-61
View File
@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* Enforces the pending state of self-signup accounts:
* - an account whose email is not yet confirmed cannot log in at all;
* - a confirmed-but-unapproved account may log in, but its booking capability
* is withheld so it only reaches the "awaiting approval" screen.
*
* Both checks key solely off the pending user meta, so invite- and
* admin-created students (which carry none of it) are unaffected.
*/
class RegistrationLoginGate {
public function register(): void {
add_filter( 'wp_authenticate_user', [ $this, 'blockUnconfirmed' ], 10, 1 );
add_filter( 'user_has_cap', [ $this, 'withholdBookingWhilePending' ], 10, 4 );
}
/**
* Block authentication for a self-signup that has not yet confirmed its
* email. Runs after password verification.
*
* @param \WP_User|\WP_Error $user Authenticating user, or an earlier error.
* @return \WP_User|\WP_Error
*/
public function blockUnconfirmed( $user ) {
if (
$user instanceof \WP_User
&& RegistrationStatus::isAwaitingApproval( (int) $user->ID )
&& ! RegistrationStatus::emailConfirmed( (int) $user->ID )
) {
return new \WP_Error(
'us_email_unconfirmed',
esc_html__( 'Please confirm your email address before logging in — check your inbox for the confirmation link.', 'unsupervised-schedular' )
);
}
return $user;
}
/**
* Strip the booking capability from any account still awaiting approval, so a
* confirmed-but-unapproved student cannot book until a studio admin approves.
*
* @param array<string, bool> $allcaps All capabilities currently held.
* @param array<int, string> $caps Required capabilities (unused).
* @param array<int, mixed> $args Callback args (unused).
* @param mixed $user The user being checked (a WP_User in practice).
* @return array<string, bool>
*/
public function withholdBookingWhilePending( array $allcaps, array $caps, array $args, mixed $user ): array {
if ( $user instanceof \WP_User && RegistrationStatus::isAwaitingApproval( (int) $user->ID ) ) {
unset( $allcaps[ RoleManager::CAP_BOOK_LESSON ] );
}
return $allcaps;
}
}
-163
View File
@@ -1,163 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Transactional emails for the self-approval registration flow: the email
* confirmation link, the studio-admin heads-up that someone is ready to
* approve, and the approval / rejection notices to the student.
*/
class RegistrationMailer {
/**
* Email the new student a link to confirm their address. Returns false when
* there is no recipient.
*/
public function sendConfirmation( \WP_User $user, string $confirmUrl ): bool {
if ( '' === (string) $user->user_email ) {
return false;
}
$subject = sprintf(
/* translators: %s: site name */
__( 'Confirm your email for %s', 'unsupervised-schedular' ),
$this->siteName()
);
$body = sprintf(
/* translators: 1: site name, 2: confirmation URL */
__( "Thanks for signing up at %1\$s.\n\nPlease confirm your email address by opening this link:\n%2\$s\n\nOnce confirmed, a studio admin will review and approve your account. You'll get another email when it's ready.", 'unsupervised-schedular' ),
$this->siteName(),
$confirmUrl
);
return (bool) wp_mail( $user->user_email, $subject, $body );
}
/**
* Tell the studio admins a self-signup has confirmed their email and is
* waiting for approval. Sent to the site admin email.
*/
public function notifyAdminsPending( \WP_User $user ): bool {
$adminEmail = Val::string( get_option( 'admin_email', '' ) );
if ( '' === $adminEmail ) {
return false;
}
$subject = __( 'A new student is awaiting approval', 'unsupervised-schedular' );
$body = sprintf(
/* translators: 1: student name, 2: student email */
__( "%1\$s (%2\$s) has confirmed their email and is awaiting approval.\n\nReview them under Students → Pending Students in wp-admin.", 'unsupervised-schedular' ),
(string) $user->display_name,
(string) $user->user_email
);
return (bool) wp_mail( $adminEmail, $subject, $body );
}
/**
* Tell the student their account has been approved. Returns false when there
* is no recipient.
*/
public function sendApproved( \WP_User $user ): bool {
if ( '' === (string) $user->user_email ) {
return false;
}
$subject = sprintf(
/* translators: %s: site name */
__( 'Your %s account is approved', 'unsupervised-schedular' ),
$this->siteName()
);
$body = sprintf(
/* translators: 1: site name, 2: login URL */
__( "Good news — your account at %1\$s has been approved. You can now log in and book:\n%2\$s", 'unsupervised-schedular' ),
$this->siteName(),
wp_login_url()
);
return (bool) wp_mail( $user->user_email, $subject, $body );
}
/**
* Tell an applicant their registration was declined. Takes the email address
* directly, since the account is deleted as part of rejection.
*/
public function sendRejected( string $email ): bool {
if ( '' === $email ) {
return false;
}
$subject = sprintf(
/* translators: %s: site name */
__( 'Your %s registration', 'unsupervised-schedular' ),
$this->siteName()
);
$body = sprintf(
/* translators: %s: site name */
__( 'Thank you for your interest in %s. We are unable to approve your registration at this time. Please contact the studio if you have any questions.', 'unsupervised-schedular' ),
$this->siteName()
);
return (bool) wp_mail( $email, $subject, $body );
}
/**
* 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' );
return '' !== $name ? $name : __( 'the studio', 'unsupervised-schedular' );
}
}
+30 -202
View File
@@ -3,95 +3,49 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth; namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy; use Unsupervised\Schedular\Policy\Policy;
use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository; 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 { class RegistrationPage {
/** Success signal: an invited student was created and logged in. */
private const RESULT_INVITE = 'invite';
/** Success signal: a self-signup was created and must confirm their email. */
private const RESULT_CONFIRM = 'confirm';
/**
* Success signal: a group-link signup was created and must confirm their
* email — confirming approves the account immediately (no admin review).
*/
private const RESULT_CONFIRM_GROUP = 'confirm_group';
public function __construct( public function __construct(
private InviteRepository $invites, private InviteRepository $invites,
private PolicyRepository $policies, private PolicyRepository $policies,
private PolicyVersionRepository $versions, private PolicyVersionRepository $versions,
private AcceptanceRepository $acceptances, private AcceptanceRepository $acceptances,
private StudioSettings $settings,
private RegistrationMailer $mailer,
private QuestionRepository $questions,
private AnswerRepository $answers,
private GroupAccessRepository $access,
) {} ) {}
/** /**
* Renders the student registration shortcode output. * Renders the student registration shortcode output.
* *
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or * @param array<string, string> $atts Shortcode attributes (unused — reserved for future options).
* shortcode attributes (`login_page_id`).
*/ */
public function render( array $atts ): string { public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( is_user_logged_in() ) { if ( is_user_logged_in() ) {
return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>'; return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>';
} }
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked below. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked below.
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) ); $token = sanitize_text_field( wp_unslash( $_REQUEST['us_invite'] ?? '' ) );
// Only the token's hash is stored, so hash the submitted token for lookup. $invite = '' !== $token ? $this->invites->findByToken( $token ) : null;
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
$open = $this->settings->openRegistrationEnabled();
// Only a redeemable invite fixes the form's email to the invited address. $error = '';
// A stale token (expired / accepted / revoked) with open registration on $success = false;
// must fall back to the normal editable email field, not show — and then
// fail to submit — the stale invite's address.
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
$error = '';
$successType = '';
if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) { if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) {
$result = $this->handleSubmit( $invite, $open ); $result = $this->handleSubmit( $invite );
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) { if ( true === $result ) {
$successType = $result; $success = true;
} else { } else {
$error = $result; $error = $result;
} }
} }
// Result of an email-confirmation link (set by EmailConfirmationHandler's redirect). $policyForms = $this->signupPolicies();
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, not a state change. $canRegister = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
$confirmResult = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
// Where the post-confirmation prompt sends students to sign in.
$loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
$policyForms = $this->signupPolicies();
$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(); ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php'; include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
@@ -109,12 +63,12 @@ class RegistrationPage {
} }
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only token used only to build the redirect target. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only token used only to build the redirect target.
$token = sanitize_text_field( Val::string( wp_unslash( $_GET['us_invite'] ?? '' ) ) ); $token = sanitize_text_field( wp_unslash( $_GET['us_invite'] ?? '' ) );
if ( '' === $token ) { if ( '' === $token ) {
return; return;
} }
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) ); $pageId = (int) get_option( RegistrationController::OPTION_PAGE, 0 );
if ( $pageId <= 0 || is_page( $pageId ) ) { if ( $pageId <= 0 || is_page( $pageId ) ) {
return; return;
} }
@@ -124,44 +78,26 @@ class RegistrationPage {
} }
/** /**
* Process the submitted registration. Returns a success signal * Process the submitted registration. Returns true on success or an error
* ({@see RESULT_INVITE} or {@see RESULT_CONFIRM}) or an error message string * message string on failure.
* on failure.
*
* The invite branch is tried first, so an invited student always completes
* signup regardless of whether open registration is enabled.
*/ */
private function handleSubmit( ?Invite $invite, bool $open ): string { private function handleSubmit( ?Invite $invite ): string|bool {
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) ); if ( null === $invite || ! $invite->isAcceptable( current_time( 'mysql' ) ) ) {
if ( ! $inviteValid && ! $open ) {
return esc_html__( 'This invitation is invalid, expired, or has already been used.', 'unsupervised-schedular' ); return esc_html__( 'This invitation is invalid, expired, or has already been used.', 'unsupervised-schedular' );
} }
// The submit nonce is verified by the caller (render) before this runs. // The submit nonce is verified by the caller (render) before this runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized.
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) ); $password = (string) wp_unslash( $_POST['password'] ?? '' );
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) ); $displayName = sanitize_text_field( wp_unslash( $_POST['display_name'] ?? '' ) );
if ( strlen( $password ) < 8 ) { if ( strlen( $password ) < 8 ) {
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' ); return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
} }
// The email is fixed by a personal invite; group-link signups and
// self-signups supply their own.
if ( $inviteValid && ! $invite->isGroup() ) {
$email = $invite->email;
} else {
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
if ( ! is_email( $email ) ) {
return esc_html__( 'Please enter a valid email address.', 'unsupervised-schedular' );
}
}
$policyForms = $this->signupPolicies(); $policyForms = $this->signupPolicies();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion. $accepted = array_map( 'absint', (array) ( $_POST['accept'] ?? [] ) );
$accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
foreach ( $policyForms as $form ) { foreach ( $policyForms as $form ) {
@@ -170,28 +106,17 @@ class RegistrationPage {
} }
} }
// Account-signup questions (step two) — validate before creating the user so if ( email_exists( $invite->email ) ) {
// 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' ); return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
} }
$userId = wp_insert_user( $userId = wp_insert_user(
[ [
'user_login' => $email, 'user_login' => $invite->email,
'user_email' => $email, 'user_email' => $invite->email,
'user_pass' => $password, 'user_pass' => $password,
'display_name' => '' !== $displayName ? $displayName : $email, 'display_name' => '' !== $displayName ? $displayName : $invite->email,
'role' => $inviteValid ? $invite->role : RoleManager::STUDENT, 'role' => $invite->role,
] ]
); );
@@ -200,109 +125,12 @@ class RegistrationPage {
} }
$this->recordAcceptances( $policyForms, (int) $userId ); $this->recordAcceptances( $policyForms, (int) $userId );
$this->recordAnswers( $accountQuestions, $answers, (int) $userId ); $this->invites->markAccepted( (int) $invite->id, (int) $userId );
if ( $inviteValid && ! $invite->isGroup() ) { wp_set_current_user( (int) $userId );
$this->invites->markAccepted( (int) $invite->id, (int) $userId ); wp_set_auth_cookie( (int) $userId );
// A personal invite may carry a group-class grant (invited by email); return true;
// 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 );
return self::RESULT_INVITE;
}
// Group-link signups and self-signups both stay pending until they
// confirm their email; the group link is multi-use so it is never marked
// accepted. A group signup auto-approves on confirmation — no admin
// review — while a self-signup then waits for studio approval.
$autoApprove = $inviteValid && $invite->isGroup();
$rawToken = RegistrationStatus::markPending( (int) $userId, $autoApprove );
$user = get_user_by( 'id', (int) $userId );
if ( $user instanceof \WP_User ) {
$this->mailer->sendConfirmation( $user, $this->confirmUrl( $rawToken ) );
}
return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM;
}
/**
* URL the post-confirmation sign-in link points to: the chosen login page
* when one is configured (and still exists), otherwise the WordPress login
* screen.
*/
private function loginUrl( int $loginPageId ): string {
if ( $loginPageId > 0 ) {
$url = get_permalink( $loginPageId );
if ( is_string( $url ) ) {
return $url;
}
}
return wp_login_url();
}
/**
* Build the email-confirmation URL for a raw token: the configured
* registration page (falling back to the home page) with `?us_confirm=`.
*/
private function confirmUrl( string $rawToken ): string {
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
$base = $pageId > 0 ? (string) get_permalink( $pageId ) : home_url( '/' );
return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base );
}
/**
* 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,
)
);
}
} }
/** /**
@@ -312,7 +140,7 @@ class RegistrationPage {
*/ */
private function recordAcceptances( array $policyForms, int $userId ): void { private function recordAcceptances( array $policyForms, int $userId ): void {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP is stored verbatim for audit. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP is stored verbatim for audit.
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) ); $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
foreach ( $policyForms as $form ) { foreach ( $policyForms as $form ) {
$this->acceptances->insert( $this->acceptances->insert(
-156
View File
@@ -1,156 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* The account lifecycle for a self-signup student, expressed entirely as user
* meta so it lives alongside the WordPress user and needs no extra table.
*
* States (see {@see docs/features/account-registration.md}):
* - Email unconfirmed — `us_awaiting_approval='1'`, no `us_email_confirmed`, a
* hashed confirmation token + expiry set. Login is blocked.
* - Confirmed, awaiting approval — `us_awaiting_approval='1'`,
* `us_email_confirmed='1'`, token/expiry cleared. Login allowed but the
* booking capability is withheld.
* - Approved / active — `us_awaiting_approval` deleted; a normal student.
*
* Invite- and admin-created students carry none of these metas, so they behave
* exactly as before.
*/
class RegistrationStatus {
public const META_AWAITING_APPROVAL = 'us_awaiting_approval';
public const META_EMAIL_CONFIRMED = 'us_email_confirmed';
public const META_CONFIRM_TOKEN = 'us_email_confirm_token';
public const META_CONFIRM_EXPIRES = 'us_email_confirm_expires';
/**
* Set on accounts created via a group invite link: confirming the email
* approves the account immediately instead of queueing it for admin review.
*/
public const META_AUTO_APPROVE = 'us_auto_approve';
/**
* Hours a self-signup email-confirmation link stays valid after the account
* is created. Limits the window in which a leaked link can be redeemed.
*/
public const EMAIL_CONFIRM_EXPIRY_HOURS = 48;
/**
* Hash a raw confirmation token for storage and lookup. Only the hash is
* persisted (mirrors {@see Invite::hashToken()}), so a database leak cannot
* be used to confirm an account — the raw token exists only in the email.
*/
public static function hashToken( string $rawToken ): string {
return hash( 'sha256', $rawToken );
}
/**
* Put a freshly created user into the pending state and issue an email
* confirmation token. Returns the raw token to embed in the emailed link.
* With `$autoApprove` (group invite links) confirming the email approves
* the account immediately — no admin review step.
*/
public static function markPending( int $userId, bool $autoApprove = false ): string {
$rawToken = wp_generate_password( 32, false );
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
update_user_meta( $userId, self::META_CONFIRM_TOKEN, self::hashToken( $rawToken ) );
update_user_meta(
$userId,
self::META_CONFIRM_EXPIRES,
gmdate( 'Y-m-d H:i:s', time() + self::EMAIL_CONFIRM_EXPIRY_HOURS * 3600 )
);
if ( $autoApprove ) {
update_user_meta( $userId, self::META_AUTO_APPROVE, '1' );
}
return $rawToken;
}
/**
* Mark the account's email confirmed and discard the (now spent) token. The
* account stays awaiting approval.
*/
public static function confirmEmail( int $userId ): void {
update_user_meta( $userId, self::META_EMAIL_CONFIRMED, '1' );
delete_user_meta( $userId, self::META_CONFIRM_TOKEN );
delete_user_meta( $userId, self::META_CONFIRM_EXPIRES );
}
/**
* Approve the account: clear the pending flag and any leftover token so the
* student becomes a normal, active student.
*/
public static function approve( int $userId ): void {
delete_user_meta( $userId, self::META_AWAITING_APPROVAL );
delete_user_meta( $userId, self::META_CONFIRM_TOKEN );
delete_user_meta( $userId, self::META_CONFIRM_EXPIRES );
delete_user_meta( $userId, self::META_AUTO_APPROVE );
}
public static function isAwaitingApproval( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_AWAITING_APPROVAL, true ) );
}
/**
* Whether confirming this account's email should approve it immediately
* (group invite link signups).
*/
public static function isAutoApprove( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_AUTO_APPROVE, true ) );
}
public static function emailConfirmed( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_EMAIL_CONFIRMED, true ) );
}
/**
* Find the user awaiting confirmation whose stored hash matches the supplied
* raw token, or null when none matches.
*/
public static function userIdForToken( string $rawToken ): ?int {
if ( '' === $rawToken ) {
return null;
}
$users = get_users(
[
'meta_key' => self::META_CONFIRM_TOKEN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => self::hashToken( $rawToken ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'number' => 1,
'fields' => 'ID',
]
);
if ( [] === $users ) {
return null;
}
return Val::int( $users[0] );
}
/**
* Whether the confirmation token for a user has passed its expiry, measured
* against the supplied `Y-m-d H:i:s` (UTC) timestamp. A user with no stored
* expiry is treated as expired (there is nothing valid to confirm).
*/
public static function isTokenExpired( int $userId, string $now ): bool {
$expires = Val::string( get_user_meta( $userId, self::META_CONFIRM_EXPIRES, true ) );
if ( '' === $expires ) {
return true;
}
$expiresTs = strtotime( $expires );
$nowTs = strtotime( $now );
if ( false === $expiresTs || false === $nowTs ) {
return true;
}
return $nowTs > $expiresTs;
}
}
-92
View File
@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
/**
* Studio-admin actions on a single student from the student detail view:
* cancelling a lesson, withdrawing a group-class enrolment, and editing basic
* account details. Mutations go through the same paths as the student-facing
* flows so slot release and pending-payment voiding stay consistent.
*/
class StudentActions {
public function __construct(
private BookingRepository $bookings,
private AvailabilityRepository $availability,
private EnrollmentRepository $enrollments,
private PaymentService $payments,
) {}
/**
* Cancel a lesson on the student's behalf: marks it cancelled, frees the
* slot for rebooking, and voids a still-pending payment. Paid lessons keep
* their payment — refunds are a manual, admin-side decision.
*/
public function cancelLesson( int $lessonId, int $studentId ): bool {
$lesson = $this->bookings->findById( $lessonId );
if ( null === $lesson || $lesson->studentId !== $studentId || Lesson::STATUS_CANCELLED === $lesson->status ) {
return false;
}
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CANCELLED );
$this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId );
return true;
}
/**
* Withdraw the student from a group class: marks the active enrolment
* cancelled (freeing its capacity seat) and voids a still-pending payment.
*/
public function withdrawEnrollment( int $enrollmentId, int $studentId ): bool {
$enrollment = $this->enrollments->findById( $enrollmentId );
if ( null === $enrollment || $enrollment->studentId !== $studentId || Enrollment::STATUS_ACTIVE !== $enrollment->status ) {
return false;
}
$this->enrollments->updateStatus( $enrollmentId, Enrollment::STATUS_CANCELLED );
$this->payments->voidPending( $enrollment->paymentId );
return true;
}
/**
* Update the student's display name and email. The email must be valid and
* not belong to another user.
*/
public function updateAccount( int $studentId, string $displayName, string $email ): bool|\WP_Error {
if ( '' === $displayName ) {
return new \WP_Error( 'empty_name', __( 'Display name cannot be empty.', 'unsupervised-schedular' ) );
}
if ( ! is_email( $email ) ) {
return new \WP_Error( 'invalid_email', __( 'Please enter a valid email address.', 'unsupervised-schedular' ) );
}
$existing = email_exists( $email );
if ( false !== $existing && (int) $existing !== $studentId ) {
return new \WP_Error( 'email_taken', __( 'Another account already uses this email address.', 'unsupervised-schedular' ) );
}
$result = wp_update_user(
[
'ID' => $studentId,
'display_name' => $displayName,
'user_email' => $email,
]
);
return $result instanceof \WP_Error ? $result : true;
}
}
+10 -65
View File
@@ -11,7 +11,6 @@ use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Val;
class StudentController { class StudentController {
@@ -21,8 +20,6 @@ class StudentController {
private OfferingRepository $offerings, private OfferingRepository $offerings,
private EnrollmentRepository $enrollments, private EnrollmentRepository $enrollments,
private BillingMethodResolver $resolver, private BillingMethodResolver $resolver,
private StudentHistory $history,
private StudentActions $actions,
) {} ) {}
public function renderPage(): void { public function renderPage(): void {
@@ -31,7 +28,7 @@ class StudentController {
} }
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only student selector. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only student selector.
$studentId = absint( Val::int( $_GET['student_id'] ?? 0 ) ); $studentId = absint( $_GET['student_id'] ?? 0 );
$student = $studentId > 0 ? get_userdata( $studentId ) : false; $student = $studentId > 0 ? get_userdata( $studentId ) : false;
if ( $student && in_array( RoleManager::STUDENT, (array) $student->roles, true ) ) { if ( $student && in_array( RoleManager::STUDENT, (array) $student->roles, true ) ) {
@@ -48,15 +45,12 @@ class StudentController {
'upcoming' => $this->bookings->countUpcomingForStudent( (int) $user->ID ), 'upcoming' => $this->bookings->countUpcomingForStudent( (int) $user->ID ),
'enrolments' => $this->enrollments->countActiveForStudent( (int) $user->ID ), 'enrolments' => $this->enrollments->countActiveForStudent( (int) $user->ID ),
], ],
array_filter( get_users(
get_users( [
[ 'role' => RoleManager::STUDENT,
'role' => RoleManager::STUDENT, 'orderby' => 'display_name',
'orderby' => 'display_name', 'order' => 'ASC',
'order' => 'ASC', ]
]
),
static fn( mixed $user ): bool => $user instanceof \WP_User
) )
); );
@@ -67,15 +61,9 @@ class StudentController {
private function renderDetail( \WP_User $student ): void { private function renderDetail( \WP_User $student ): void {
$canBilling = current_user_can( RoleManager::CAP_MANAGE_BILLING ); $canBilling = current_user_can( RoleManager::CAP_MANAGE_BILLING );
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- routing only; each action below verifies its own nonce. if ( $canBilling && isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_student_billing' ) ) {
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
$notice = '';
$error = '';
if ( $canBilling && 'set_billing' === $action && check_admin_referer( 'usc_student_billing' ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
$method = sanitize_key( Val::string( wp_unslash( $_POST['payment_method'] ?? '' ) ) ); $method = sanitize_key( wp_unslash( $_POST['payment_method'] ?? '' ) );
if ( in_array( $method, Payment::VALID_METHODS, true ) ) { if ( in_array( $method, Payment::VALID_METHODS, true ) ) {
update_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, $method ); update_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, $method );
} else { } else {
@@ -83,43 +71,7 @@ class StudentController {
} }
} }
if ( 'update_account' === $action && check_admin_referer( 'usc_student_actions' ) ) { $billingOverride = (string) get_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, true );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
$email = sanitize_email( Val::string( wp_unslash( $_POST['user_email'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
$result = $this->actions->updateAccount( (int) $student->ID, $displayName, $email );
if ( $result instanceof \WP_Error ) {
$error = $result->get_error_message();
} else {
$notice = __( 'Account details updated.', 'unsupervised-schedular' );
$fresh = get_userdata( (int) $student->ID );
$student = $fresh instanceof \WP_User ? $fresh : $student;
}
}
if ( 'cancel_lesson' === $action && check_admin_referer( 'usc_student_actions' ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
$lessonId = absint( Val::int( $_POST['lesson_id'] ?? 0 ) );
if ( $this->actions->cancelLesson( $lessonId, (int) $student->ID ) ) {
$notice = __( 'Lesson cancelled.', 'unsupervised-schedular' );
} else {
$error = __( 'This lesson could not be cancelled.', 'unsupervised-schedular' );
}
}
if ( 'withdraw_enrollment' === $action && check_admin_referer( 'usc_student_actions' ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
$enrollmentId = absint( Val::int( $_POST['enrollment_id'] ?? 0 ) );
if ( $this->actions->withdrawEnrollment( $enrollmentId, (int) $student->ID ) ) {
$notice = __( 'Enrolment withdrawn.', 'unsupervised-schedular' );
} else {
$error = __( 'This enrolment could not be withdrawn.', 'unsupervised-schedular' );
}
}
$billingOverride = Val::string( get_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, true ) );
$billingDefault = $this->resolver->defaultMethod(); $billingDefault = $this->resolver->defaultMethod();
$now = current_time( 'mysql' ); $now = current_time( 'mysql' );
@@ -137,7 +89,6 @@ class StudentController {
$offering = $this->offerings->findById( $enrollment->offeringId ); $offering = $this->offerings->findById( $enrollment->offeringId );
return [ return [
'id' => (int) $enrollment->id,
'offering' => $offering ? $offering->title : (string) $enrollment->offeringId, 'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
'status' => $enrollment->status, 'status' => $enrollment->status,
]; ];
@@ -145,11 +96,6 @@ class StudentController {
$this->enrollments->findByStudent( (int) $student->ID ) $this->enrollments->findByStudent( (int) $student->ID )
); );
$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 ) : [];
$backUrl = admin_url( 'admin.php?page=us-students' ); $backUrl = admin_url( 'admin.php?page=us-students' );
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php'; include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
} }
@@ -165,7 +111,6 @@ class StudentController {
$instructor = get_userdata( $lesson->instructorId ); $instructor = get_userdata( $lesson->instructorId );
return [ return [
'id' => (int) $lesson->id,
'start_dt' => $slot ? $slot->startDt : '', 'start_dt' => $slot ? $slot->startDt : '',
'end_dt' => $slot ? $slot->endDt : '', 'end_dt' => $slot ? $slot->endDt : '',
'offering' => $offering ? $offering->title : '—', 'offering' => $offering ? $offering->title : '—',
-149
View File
@@ -1,149 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
/**
* Builds the display rows for the history sections of the admin student detail
* view: policy acceptances, intake answers, and payments.
*/
class StudentHistory {
public function __construct(
private AcceptanceRepository $acceptances,
private PolicyRepository $policies,
private PolicyVersionRepository $policyVersions,
private AnswerRepository $answers,
private QuestionRepository $questions,
private PaymentRepository $payments,
) {}
/**
* Every policy acceptance the student has recorded, newest first.
*
* @return list<array{policy: string, version: string, context: string, accepted_at: string}>
*/
public function policyAcceptances( int $studentId ): array {
return array_map(
function ( PolicyAcceptance $acceptance ): array {
$version = $this->policyVersions->findById( $acceptance->policyVersionId );
$policy = $version ? $this->policies->findById( $version->policyId ) : null;
return [
'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ),
'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
'context' => $this->contextLabel( $acceptance->registrationType, $acceptance->registrationId ),
'accepted_at' => $acceptance->acceptedAt ?? '',
];
},
$this->acceptances->findByStudent( $studentId )
);
}
/**
* 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 {
$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 );
return [
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
'answer' => $answer->answerValue ?? '—',
'context' => $this->contextLabel( $answer->registrationType, $answer->registrationId ),
];
},
$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 )
);
}
/**
* Every payment for the student, newest first.
*
* @return list<array{created_at: string, context: string, method: string, status: string, amount: float, tax_amount: float, total: float, currency: string, receipt: string}>
*/
public function payments( int $studentId ): array {
return array_map(
fn( Payment $payment ): array => [
'created_at' => $payment->createdAt ?? '',
'context' => $this->contextLabel( $payment->registrationType, $payment->registrationId ),
'method' => $payment->method,
'status' => $payment->status,
'amount' => $payment->amount,
'tax_amount' => $payment->taxAmount,
'total' => $payment->total(),
'currency' => $payment->currency,
'receipt' => $payment->receiptNumber ?? '—',
],
$this->payments->findByStudent( $studentId )
);
}
/**
* Human label for a polymorphic registration target.
*/
private function contextLabel( string $registrationType, int $registrationId ): string {
switch ( $registrationType ) {
case PolicyAcceptance::REG_ACCOUNT:
return __( 'Account signup', 'unsupervised-schedular' );
case PolicyAcceptance::REG_LESSON:
/* translators: %d: the lesson id */
return sprintf( __( 'Lesson #%d', 'unsupervised-schedular' ), $registrationId );
case PolicyAcceptance::REG_ENROLLMENT:
/* translators: %d: the group-class enrolment id */
return sprintf( __( 'Enrolment #%d', 'unsupervised-schedular' ), $registrationId );
default:
return sprintf( '%s #%d', $registrationType, $registrationId );
}
}
}
+5 -7
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth; namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/** /**
* Pure helper for splitting a student's dated rows into upcoming and past. * Pure helper for splitting a student's dated rows into upcoming and past.
*/ */
@@ -23,7 +21,7 @@ class StudentSchedule {
$past = []; $past = [];
foreach ( $rows as $row ) { foreach ( $rows as $row ) {
$start = Val::string( $row['start_dt'] ?? '' ); $start = (string) ( $row['start_dt'] ?? '' );
if ( '' !== $start && $start >= $now ) { if ( '' !== $start && $start >= $now ) {
$upcoming[] = $row; $upcoming[] = $row;
} else { } else {
@@ -31,12 +29,12 @@ class StudentSchedule {
} }
} }
usort( $upcoming, static fn( array $a, array $b ): int => strcmp( Val::string( $a['start_dt'] ?? '' ), Val::string( $b['start_dt'] ?? '' ) ) ); usort( $upcoming, static fn( array $a, array $b ): int => strcmp( (string) ( $a['start_dt'] ?? '' ), (string) ( $b['start_dt'] ?? '' ) ) );
usort( $past, static fn( array $a, array $b ): int => strcmp( Val::string( $b['start_dt'] ?? '' ), Val::string( $a['start_dt'] ?? '' ) ) ); usort( $past, static fn( array $a, array $b ): int => strcmp( (string) ( $b['start_dt'] ?? '' ), (string) ( $a['start_dt'] ?? '' ) ) );
return [ return [
'upcoming' => $upcoming, 'upcoming' => array_values( $upcoming ),
'past' => $past, 'past' => array_values( $past ),
]; ];
} }
} }
-35
View File
@@ -1,35 +0,0 @@
<?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 : '';
}
}
+18 -50
View File
@@ -6,7 +6,6 @@ namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
class AvailabilityController { class AvailabilityController {
@@ -29,76 +28,43 @@ class AvailabilityController {
$slots = $this->repository->findByInstructor( $instructorId ); $slots = $this->repository->findByInstructor( $instructorId );
$offeringChoices = $this->offerings->findAll( $instructorId, Offering::KIND_PRIVATE_LESSON, true ); $offeringChoices = $this->offerings->findAll( $instructorId, Offering::KIND_PRIVATE_LESSON, true );
// View-state query params only (which view, which week) — nothing is
// mutated from them, so no nonce applies.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$view = 'list' === sanitize_key( Val::string( wp_unslash( $_GET['usc_view'] ?? '' ) ) ) ? 'list' : 'week';
$requestedWeek = sanitize_text_field( Val::string( wp_unslash( $_GET['usc_week'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$weekStart = WeekCalendar::weekStart( $requestedWeek, Val::int( get_option( 'start_of_week', 1 ) ), current_time( 'Y-m-d' ) );
$weekDays = WeekCalendar::days( $weekStart, $slots );
$prevWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '-7 days' )->format( 'Y-m-d' );
$nextWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '+7 days' )->format( 'Y-m-d' );
include USC_PLUGIN_DIR . 'templates/admin/availability.php'; include USC_PLUGIN_DIR . 'templates/admin/availability.php';
} }
private function handleFormAction( int $instructorId ): void { private function handleFormAction( int $instructorId ): void {
// Nonce is verified by the caller (renderPage) before this method runs. // Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
if ( 'add' === $action ) { if ( 'add' === $action ) {
$this->addSlot( $instructorId ); $this->addSlot( $instructorId );
} }
if ( 'delete' === $action ) { if ( 'delete' === $action ) {
$this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId ); $slotId = absint( $_POST['slot_id'] ?? 0 );
} if ( $slotId > 0 ) {
$slot = $this->repository->findById( $slotId );
if ( 'bulk_delete' === $action ) { if ( $slot && $slot->instructorId === $instructorId ) {
// The array itself carries no data; each element is coerced and $this->repository->delete( $slotId );
// absint-sanitized individually below. }
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
$rawIds = $_POST['slot_ids'] ?? [];
foreach ( is_array( $rawIds ) ? $rawIds : [] as $rawId ) {
$this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId );
} }
} }
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
} }
/**
* Delete a slot only when it exists and belongs to the given instructor.
* The repository additionally refuses to delete booked slots.
*/
private function deleteOwnSlot( int $slotId, int $instructorId ): void {
if ( $slotId <= 0 ) {
return;
}
$slot = $this->repository->findById( $slotId );
if ( $slot && $slot->instructorId === $instructorId ) {
$this->repository->delete( $slotId );
}
}
private function addSlot( int $instructorId ): void { private function addSlot( int $instructorId ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ) ); $startDt = sanitize_text_field( wp_unslash( $_POST['start_dt'] ?? '' ) );
$endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ) ); $endDt = sanitize_text_field( wp_unslash( $_POST['end_dt'] ?? '' ) );
// A window must start and end on the same day (weekly repeat covers longer if ( '' === $startDt || '' === $endDt ) {
// ranges) and fit at least one lesson; it is stored as lesson-length slots.
if ( null === $startDt || null === $endDt || $endDt <= $startDt || substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
return; return;
} }
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) ); $offeringId = absint( $_POST['offering_id'] ?? 0 );
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) ); $duration = absint( $_POST['duration_minutes'] ?? 0 );
$window = new AvailabilitySlot( $slot = new AvailabilitySlot(
instructorId: $instructorId, instructorId: $instructorId,
startDt: $startDt, startDt: $startDt,
endDt: $endDt, endDt: $endDt,
@@ -106,10 +72,12 @@ class AvailabilityController {
offeringId: $offeringId > 0 ? $offeringId : null, offeringId: $offeringId > 0 ? $offeringId : null,
); );
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ); if ( 'weekly' === sanitize_key( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ) {
$weeks = absint( Val::int( $_POST['weeks'] ?? 1 ) ); $this->repository->createWeeklySeries( $slot, absint( $_POST['weeks'] ?? 1 ) );
return;
}
$this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks ); $this->repository->insert( $slot );
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
} }
} }
+17 -36
View File
@@ -5,7 +5,6 @@ namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
class AvailabilityEndpoint { class AvailabilityEndpoint {
@@ -14,11 +13,6 @@ class AvailabilityEndpoint {
private OfferingRepository $offerings, private OfferingRepository $offerings,
) {} ) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void { public function registerRoutes( string $route_namespace ): void {
register_rest_route( register_rest_route(
$route_namespace, $route_namespace,
@@ -102,11 +96,11 @@ class AvailabilityEndpoint {
public function index( \WP_REST_Request $request ): \WP_REST_Response { public function index( \WP_REST_Request $request ): \WP_REST_Response {
$slots = $this->repository->findAvailable( $slots = $this->repository->findAvailable(
Val::int( $request->get_param( 'instructor_id' ) ), (int) $request->get_param( 'instructor_id' ),
Val::int( $request->get_param( 'offering_id' ) ), (int) $request->get_param( 'offering_id' ),
Val::int( $request->get_param( 'duration_minutes' ) ), (int) $request->get_param( 'duration_minutes' ),
Val::string( $request->get_param( 'from' ) ), (string) $request->get_param( 'from' ),
Val::string( $request->get_param( 'to' ) ), (string) $request->get_param( 'to' ),
); );
return new \WP_REST_Response( array_map( fn( AvailabilitySlot $s ) => $s->toArray(), $slots ), 200 ); return new \WP_REST_Response( array_map( fn( AvailabilitySlot $s ) => $s->toArray(), $slots ), 200 );
@@ -114,8 +108,8 @@ class AvailabilityEndpoint {
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$instructorId = get_current_user_id(); $instructorId = get_current_user_id();
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) ); $offeringId = absint( $request->get_param( 'offering_id' ) );
$duration = absint( Val::int( $request->get_param( 'duration_minutes' ) ) ); $duration = absint( $request->get_param( 'duration_minutes' ) );
// A slot may only be tied to an offering the instructor owns, so it can // A slot may only be tied to an offering the instructor owns, so it can
// never inherit another instructor's price or payment routing at booking. // never inherit another instructor's price or payment routing at booking.
@@ -126,40 +120,27 @@ class AvailabilityEndpoint {
} }
} }
$startDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'start_dt' ) ) ); $slot = new AvailabilitySlot(
$endDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'end_dt' ) ) );
if ( null === $startDt || null === $endDt || $endDt <= $startDt ) {
return new \WP_Error( 'invalid_datetime', __( 'Provide a valid start and end, with the end after the start.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
return new \WP_Error( 'invalid_window', __( 'Availability must start and end on the same day. Use the weekly repeat to cover multiple weeks.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$window = new AvailabilitySlot(
instructorId: $instructorId, instructorId: $instructorId,
startDt: $startDt, startDt: (string) $request->get_param( 'start_dt' ),
endDt: $endDt, endDt: (string) $request->get_param( 'end_dt' ),
durationMinutes: $duration > 0 ? $duration : 60, durationMinutes: $duration > 0 ? $duration : 60,
offeringId: $offeringId > 0 ? $offeringId : null, offeringId: $offeringId > 0 ? $offeringId : null,
); );
if ( [] === $window->splitByDuration() ) { if ( 'weekly' === $request->get_param( 'recurrence' ) ) {
return new \WP_Error( 'invalid_window', __( 'The availability window is shorter than the lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); $ids = $this->repository->createWeeklySeries( $slot, absint( $request->get_param( 'weeks' ) ) );
return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
} }
$ids = $this->repository->createFromWindow( $id = $this->repository->insert( $slot );
$window,
'weekly' === $request->get_param( 'recurrence' ),
absint( Val::int( $request->get_param( 'weeks' ) ) )
);
return new \WP_REST_Response( [ 'ids' => $ids ], 201 ); return new \WP_REST_Response( [ 'id' => $id ], 201 );
} }
public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) ); $id = absint( $request->get_param( 'id' ) );
$slot = $this->repository->findById( $id ); $slot = $this->repository->findById( $id );
if ( null === $slot ) { if ( null === $slot ) {
+9 -109
View File
@@ -30,26 +30,6 @@ class AvailabilityRepository {
return $this->db->insert_id; return $this->db->insert_id;
} }
/**
* Persist an availability window as individually bookable lesson-length slots.
* The window is split into consecutive `duration_minutes` chunks; each chunk
* becomes its own row (and, when weekly, its own weekly series) so students can
* book any open lesson-length block within the window.
*
* @return list<int> Inserted slot IDs.
*/
public function createFromWindow( AvailabilitySlot $window, bool $weekly = false, int $weeks = 1 ): array {
$ids = [];
foreach ( $window->splitByDuration() as $slot ) {
$ids = $weekly
? array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) )
: [ ...$ids, $this->insert( $slot ) ];
}
return $ids;
}
/** /**
* Create a weekly-recurring series from a template slot. Each occurrence is a * Create a weekly-recurring series from a template slot. Each occurrence is a
* separate row one week apart, all sharing a `recurrence_group` (the id of the * separate row one week apart, all sharing a `recurrence_group` (the id of the
@@ -107,10 +87,8 @@ class AvailabilityRepository {
* @return list<AvailabilitySlot> * @return list<AvailabilitySlot>
*/ */
public function findAvailable( int $instructorId = 0, int $offeringId = 0, int $durationMinutes = 0, string $from = '', string $to = '' ): array { public function findAvailable( int $instructorId = 0, int $offeringId = 0, int $durationMinutes = 0, string $from = '', string $to = '' ): array {
// A slot whose start has passed can no longer be booked, so it is never $where = [ 'is_booked = 0' ];
// "available" regardless of the requested range. $params = [];
$where = [ 'is_booked = 0', 'start_dt >= %s' ];
$params = [ current_time( 'mysql' ) ];
if ( $instructorId > 0 ) { if ( $instructorId > 0 ) {
$where[] = 'instructor_id = %d'; $where[] = 'instructor_id = %d';
@@ -138,11 +116,11 @@ class AvailabilityRepository {
} }
$whereClause = implode( ' AND ', $where ); $whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY start_dt ASC"; $sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC";
$rows = $this->db->get_results( $rows = $params
$this->db->prepare( $sql, array_merge( [ $this->table ], $params ) ) ? $this->db->get_results( $this->db->prepare( $sql, $params ) )
); : $this->db->get_results( $sql );
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] ); return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
} }
@@ -155,8 +133,7 @@ class AvailabilityRepository {
public function findByInstructor( int $instructorId ): array { public function findByInstructor( int $instructorId ): array {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE instructor_id = %d ORDER BY start_dt ASC', "SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY start_dt ASC",
$this->table,
$instructorId $instructorId
) )
); );
@@ -172,8 +149,7 @@ class AvailabilityRepository {
public function findUnbookedInGroup( int $recurrenceGroup ): array { public function findUnbookedInGroup( int $recurrenceGroup ): array {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE recurrence_group = %d AND is_booked = 0 ORDER BY start_dt ASC', "SELECT * FROM {$this->table} WHERE recurrence_group = %d AND is_booked = 0 ORDER BY start_dt ASC",
$this->table,
$recurrenceGroup $recurrenceGroup
) )
); );
@@ -181,31 +157,9 @@ class AvailabilityRepository {
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] ); 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 { public function findById( int $id ): ?AvailabilitySlot {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
); );
return $row ? AvailabilitySlot::fromRow( $row ) : null; return $row ? AvailabilitySlot::fromRow( $row ) : null;
@@ -233,60 +187,6 @@ class AvailabilityRepository {
return 1 === $updated; return 1 === $updated;
} }
/**
* Free a slot whose lesson was cancelled so the time can be booked again.
*/
public function release( int $id ): bool {
return false !== $this->db->update(
$this->table,
[ 'is_booked' => 0 ],
[ 'id' => $id ],
[ '%d' ],
[ '%d' ]
);
}
/**
* One-time upgrade for rows created before windows were split on save: a
* window stored as a single row (e.g. 09:0016:00 with 60-minute lessons)
* showed to students as one giant slot. Rewrites every unbooked same-day
* window longer than its lesson length as lesson-length rows: the original
* row is trimmed to the first chunk (keeping its id and any recurrence
* group), and the remaining chunks are inserted as one-off rows.
*/
public function splitOversizedWindows(): void {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i
WHERE is_booked = 0
AND DATE(start_dt) = DATE(end_dt)
AND TIMESTAMPDIFF(MINUTE, start_dt, end_dt) > duration_minutes',
$this->table
)
);
foreach ( $rows ?? [] as $row ) {
$window = AvailabilitySlot::fromRow( $row );
$chunks = $window->splitByDuration();
if ( [] === $chunks ) {
continue;
}
$this->db->update(
$this->table,
[ 'end_dt' => $chunks[0]->endDt ],
[ 'id' => $window->id ],
[ '%s' ],
[ '%d' ]
);
foreach ( array_slice( $chunks, 1 ) as $chunk ) {
$this->insert( $chunk );
}
}
}
/** /**
* Delete an unbooked slot. Returns false if the slot is already booked. * Delete an unbooked slot. Returns false if the slot is already booked.
*/ */
+9 -66
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Availability; namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Val;
class AvailabilitySlot { class AvailabilitySlot {
public function __construct( public function __construct(
@@ -18,71 +16,16 @@ class AvailabilitySlot {
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
/** public static function fromRow( object $row ): self {
* Normalise a submitted slot datetime to canonical `Y-m-d H:i:s`, or null when
* it is not a real datetime. Accepts the HTML `datetime-local` form
* (`Y-m-d\TH:i`, optionally with seconds) and the canonical form (optionally
* without seconds). Anything else — including strings PHP would "helpfully"
* coerce — is rejected so garbage never reaches the DATETIME column or throws
* inside the weekly-series date arithmetic.
*/
public static function normalizeDateTime( string $value ): ?string {
foreach ( [ 'Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i' ] as $format ) {
$dt = \DateTimeImmutable::createFromFormat( '!' . $format, $value );
if ( false !== $dt && $dt->format( $format ) === $value ) {
return $dt->format( 'Y-m-d H:i:s' );
}
}
return null;
}
/**
* Split this window into consecutive lesson-length slots: 09:0016:00 with
* 60-minute lessons yields seven bookable slots. A trailing remainder shorter
* than the lesson length is dropped, and an empty list is returned when the
* window cannot fit a single lesson.
*
* @return list<self>
*/
public function splitByDuration(): array {
if ( $this->durationMinutes <= 0 ) {
return [];
}
$end = new \DateTimeImmutable( $this->endDt );
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
$cursor = new \DateTimeImmutable( $this->startDt );
$chunkEnd = $cursor->add( $step );
$slots = [];
while ( $chunkEnd <= $end ) {
$slots[] = new self(
instructorId: $this->instructorId,
startDt: $cursor->format( 'Y-m-d H:i:s' ),
endDt: $chunkEnd->format( 'Y-m-d H:i:s' ),
durationMinutes: $this->durationMinutes,
offeringId: $this->offeringId,
);
$cursor = $chunkEnd;
$chunkEnd = $cursor->add( $step );
}
return $slots;
}
public static function fromRow( \stdClass $row ): self {
return new self( return new self(
instructorId: Val::int( $row->instructor_id ), instructorId: (int) $row->instructor_id,
startDt: Val::string( $row->start_dt ), startDt: $row->start_dt,
endDt: Val::string( $row->end_dt ), endDt: $row->end_dt,
durationMinutes: Val::int( $row->duration_minutes ), durationMinutes: (int) $row->duration_minutes,
offeringId: Val::intOrNull( $row->offering_id ), offeringId: null !== $row->offering_id ? (int) $row->offering_id : null,
isBooked: Val::bool( $row->is_booked ), isBooked: (bool) $row->is_booked,
recurrenceGroup: Val::intOrNull( $row->recurrence_group ), recurrenceGroup: null !== $row->recurrence_group ? (int) $row->recurrence_group : null,
id: Val::int( $row->id ), id: (int) $row->id,
); );
} }
-87
View File
@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
/**
* Pure helpers for the weekly calendar views: resolving which week to show and
* bucketing slots into that week's seven days.
*/
class WeekCalendar {
/**
* Resolve a requested week anchor to the date of the first day of its week.
* `$requested` may be any date (`Y-m-d`) inside the wanted week; anything
* unparseable falls back to `$today`. `$startOfWeek` follows WordPress's
* `start_of_week` option (0 = Sunday … 6 = Saturday).
*/
public static function weekStart( string $requested, int $startOfWeek, string $today ): string {
$anchor = self::parseDay( $requested ) ?? self::parseDay( $today ) ?? new \DateTimeImmutable( 'today' );
$shift = ( (int) $anchor->format( 'w' ) - $startOfWeek + 7 ) % 7;
return $anchor->modify( '-' . $shift . ' days' )->format( 'Y-m-d' );
}
/**
* Bucket slots into the seven days of the week starting at `$weekStart`
* (`Y-m-d`). Every day is present, empty or not, in calendar order.
*
* @param list<AvailabilitySlot> $slots
* @return list<array{date: string, slots: list<AvailabilitySlot>}>
*/
public static function days( string $weekStart, array $slots ): array {
$start = self::parseDay( $weekStart ) ?? new \DateTimeImmutable( 'today' );
$byDay = [];
foreach ( $slots as $slot ) {
$byDay[ substr( $slot->startDt, 0, 10 ) ][] = $slot;
}
$days = [];
for ( $i = 0; $i < 7; $i++ ) {
$date = $start->modify( '+' . $i . ' days' )->format( 'Y-m-d' );
$days[] = [
'date' => $date,
'slots' => $byDay[ $date ] ?? [],
];
}
return $days;
}
/**
* Bucket arbitrary items into the seven days of the week starting at
* `$weekStart` (`Y-m-d`), using `$dayOf` to extract each item's `Y-m-d` day.
* Every day is present, empty or not, in calendar order.
*
* @template T
* @param list<T> $items
* @param callable(T): string $dayOf
* @return list<array{date: string, items: list<T>}>
*/
public static function bucket( string $weekStart, array $items, callable $dayOf ): array {
$start = self::parseDay( $weekStart ) ?? new \DateTimeImmutable( 'today' );
$byDay = [];
foreach ( $items as $item ) {
$byDay[ $dayOf( $item ) ][] = $item;
}
$days = [];
for ( $i = 0; $i < 7; $i++ ) {
$date = $start->modify( '+' . $i . ' days' )->format( 'Y-m-d' );
$days[] = [
'date' => $date,
'items' => $byDay[ $date ] ?? [],
];
}
return $days;
}
private static function parseDay( string $value ): ?\DateTimeImmutable {
$day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
return false !== $day && $day->format( 'Y-m-d' ) === $value ? $day : null;
}
}
-114
View File
@@ -1,114 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular;
/**
* Static, script-free markup for the editor previews of the front-end blocks.
*
* The booking and group-class pages are populated by JavaScript on the live
* site, and the registration page requires a valid invite token — none of
* which exist inside the block editor. These previews reproduce the same
* wrapper elements and CSS classes the live pages use, filled with
* representative placeholder content, so themes can be styled against
* realistic markup without firing REST calls, redirects, or Stripe.js.
*/
class BlockPreview {
public static function booking(): string {
$days = [
[
'label' => __( 'Monday', 'unsupervised-schedular' ),
'slots' => [
[ '4:00 PM4:30 PM', 30 ],
[ '4:30 PM5:00 PM', 30 ],
],
],
[
'label' => __( 'Wednesday', 'unsupervised-schedular' ),
'slots' => [
[ '5:00 PM5:45 PM', 45 ],
],
],
];
$dayHtml = '';
foreach ( $days as $day ) {
$slotHtml = '';
foreach ( $day['slots'] as $slot ) {
$slotHtml .= sprintf(
'<div class="us-slot"><span>%s (%d min)</span><button type="button" class="us-book-btn" disabled>%s</button></div>',
esc_html( $slot[0] ),
(int) $slot[1],
esc_html__( 'Book', 'unsupervised-schedular' )
);
}
$dayHtml .= sprintf(
'<div class="us-day"><h3 class="us-day-heading">%s</h3>%s</div>',
esc_html( $day['label'] ),
$slotHtml
);
}
return sprintf(
'<div id="us-booking-app">%s<div id="us-slot-list">%s</div></div>',
self::note( __( 'Editor preview — students see live availability on the published page.', 'unsupervised-schedular' ) ),
$dayHtml
);
}
public static function groupClasses(): string {
return sprintf(
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p>%s</p><p>%s</p><p>25.00 CAD</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
self::note( __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' ) ),
esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ),
esc_html__( 'Saturdays 10:00 AM11:00 AM', 'unsupervised-schedular' ),
esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ),
esc_html__( 'Enrol', 'unsupervised-schedular' )
);
}
/**
* The live login form renders fine without any request state, so the
* preview includes the real template (the editing user is logged in, which
* would otherwise short-circuit to an "already logged in" message).
*/
public static function login(): string {
$error = '';
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/login-page.php';
return self::note( __( 'Editor preview — logged-in visitors are offered a link to the booking page instead.', 'unsupervised-schedular' ) ) . (string) ob_get_clean();
}
public static function registration(): string {
$fields = sprintf(
'<p><label for="us-reg-email">%s</label><input type="email" id="us-reg-email" value="[email protected]" readonly></p>',
esc_html__( 'Email', 'unsupervised-schedular' )
);
$fields .= sprintf(
'<p><label for="us-reg-name">%s</label><input type="text" id="us-reg-name"></p>',
esc_html__( 'Your name', 'unsupervised-schedular' )
);
$fields .= sprintf(
'<p><label for="us-reg-pass">%s</label><input type="password" id="us-reg-pass"></p>',
esc_html__( 'Password', 'unsupervised-schedular' )
);
$fields .= sprintf(
'<p><input type="submit" value="%s" disabled></p>',
esc_attr__( 'Create Account', 'unsupervised-schedular' )
);
return sprintf(
'<div class="us-register-form">%s<form>%s</form></div>',
self::note( __( 'Editor preview — the live form requires a valid invite link and lists signup policies.', 'unsupervised-schedular' ) ),
$fields
);
}
private static function note( string $text ): string {
return '<p class="us-editor-note">' . esc_html( $text ) . '</p>';
}
}
-267
View File
@@ -1,267 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
/**
* Registers Gutenberg dynamic-block wrappers for the front-end shortcodes so
* the pages can be previewed and styled inside the block editor.
*
* On the front end each block delegates to the same page object its shortcode
* uses, so output is identical either way. Inside the editor (the
* block-renderer REST preview used by wp.serverSideRender) a static preview
* from BlockPreview is rendered instead same markup and CSS classes, no
* live REST calls, redirects, or Stripe.js.
*/
class BlockRegistrar {
public const SCRIPT_HANDLE = 'us-scheduler-blocks';
public const STYLE_HANDLE = 'us-scheduler';
public function __construct(
private BookingPage $bookingPage,
private LoginPage $loginPage,
private RegistrationPage $registrationPage,
private GroupClassPage $groupClassPage,
) {}
public function register(): void {
add_action( 'init', [ $this, 'registerBlocks' ] );
add_action( 'template_redirect', [ $this, 'maybeAutoRedirect' ] );
}
public function registerBlocks(): void {
// The editor script registers the client side of each block (title,
// icon, shortcode transform, inspector controls) and previews it via
// wp.serverSideRender.
wp_register_script(
self::SCRIPT_HANDLE,
USC_PLUGIN_URL . 'assets/js/blocks.js',
[ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-core-data', 'wp-server-side-render', 'wp-i18n', 'wp-api-fetch' ],
USC_VERSION,
true
);
// The front-end stylesheet doubles as the block style so editor
// previews look like the published page. ShortcodeRegistrar registers
// the same handle on the front end, hence the guard.
if ( ! wp_style_is( self::STYLE_HANDLE, 'registered' ) ) {
wp_register_style( self::STYLE_HANDLE, USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
}
foreach ( $this->blocks() as $name => $config ) {
register_block_type(
$name,
[
'api_version' => '3',
'editor_script' => self::SCRIPT_HANDLE,
'style' => self::STYLE_HANDLE,
'attributes' => $config['attributes'],
'render_callback' => $config['render'],
]
);
}
}
/**
* Block definitions: render callback plus the attribute schema. The
* schema must be declared server-side too, or the block-renderer preview
* endpoint rejects the attributes wp.serverSideRender sends.
*
* @return array<string, array{render: callable(array<string, mixed>=): string, attributes: array<string, array{type: string, default: mixed}>}>
*/
private function blocks(): array {
$redirectToggle = [
'type' => 'boolean',
'default' => false,
];
return [
'us-scheduler/booking' => [
'render' => [ $this, 'renderBooking' ],
'attributes' => [
'loginPageId' => [
'type' => 'number',
'default' => 0,
],
'autoRedirect' => $redirectToggle,
],
],
'us-scheduler/student-login' => [
'render' => [ $this, 'renderLogin' ],
'attributes' => [
'bookingPageId' => [
'type' => 'number',
'default' => 0,
],
'autoRedirect' => $redirectToggle,
],
],
'us-scheduler/student-register' => [
'render' => [ $this, 'renderRegistration' ],
'attributes' => [
'loginPageId' => [
'type' => 'number',
'default' => 0,
],
],
],
'us-scheduler/group-classes' => [
'render' => [ $this, 'renderGroupClasses' ],
'attributes' => [
'offeringId' => [
'type' => 'number',
'default' => 0,
],
],
],
];
}
/**
* Renders the booking block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderBooking( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::booking() : $this->bookingPage->render( $attributes );
}
/**
* Renders the student-login block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderLogin( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::login() : $this->loginPage->render( $attributes );
}
/**
* Renders the student-registration block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderRegistration( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::registration() : $this->registrationPage->render( $attributes );
}
/**
* Renders the group-classes block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderGroupClasses( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::groupClasses() : $this->groupClassPage->render( $attributes );
}
/**
* Server-side auto-redirect for blocks that opt in via their autoRedirect
* attribute: logged-out visitors on a page containing the booking block
* are sent to its login page, and logged-in visitors on a page containing
* the student-login block are sent to its booking page. Hooked on
* `template_redirect` because block rendering happens after output has
* started, too late to send a Location header.
*/
public function maybeAutoRedirect(): void {
if ( is_admin() || ! is_singular() ) {
return;
}
$post = get_post();
if ( ! $post instanceof \WP_Post ) {
return;
}
if ( is_user_logged_in() ) {
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-login' );
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
return;
}
$bookingPageId = Val::int( $attrs['bookingPageId'] ?? 0 );
if ( $bookingPageId === $post->ID ) {
return; // Redirecting the page to itself would loop.
}
$url = $this->loginPage->bookingUrl( $bookingPageId );
if ( null !== $url ) {
$this->redirect( $url );
}
return;
}
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/booking' );
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
return;
}
$loginPageId = Val::int( $attrs['loginPageId'] ?? 0 );
if ( $loginPageId === $post->ID ) {
return; // Redirecting the page to itself would loop.
}
$this->redirect( $this->bookingPage->loginUrl( $loginPageId ) );
}
/**
* Attributes of the first occurrence of the named block in the content,
* searching inner blocks so blocks nested inside groups or columns are
* still found. Null when the block is absent. Attributes equal to their
* schema default are omitted from the serialized block, so callers must
* apply defaults themselves.
*
* @return array<mixed>|null
*/
private function firstBlockAttrs( string $content, string $blockName ): ?array {
if ( ! has_block( $blockName, $content ) ) {
return null;
}
$queue = parse_blocks( $content );
while ( [] !== $queue ) {
$block = array_shift( $queue );
if ( ! is_array( $block ) ) {
continue;
}
if ( ( $block['blockName'] ?? null ) === $blockName ) {
$attrs = $block['attrs'] ?? null;
return is_array( $attrs ) ? $attrs : [];
}
$inner = $block['innerBlocks'] ?? null;
if ( is_array( $inner ) && [] !== $inner ) {
$queue = array_merge( $queue, array_values( $inner ) );
}
}
return null;
}
/**
* Issues the redirect and stops the request. Split out so tests can
* observe redirects without the process exiting.
*/
protected function redirect( string $url ): void {
wp_safe_redirect( $url );
exit;
}
/**
* Whether this render is the editor's block-renderer REST preview rather
* than a real front-end page render. Front-end template rendering never
* happens inside a REST request, so REST_REQUEST is a reliable signal.
*/
protected function isEditorPreview(): bool {
return defined( 'REST_REQUEST' ) && (bool) constant( 'REST_REQUEST' );
}
}
+19 -157
View File
@@ -5,13 +5,11 @@ namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate; use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Val;
class BookingEndpoint { class BookingEndpoint {
@@ -27,14 +25,8 @@ class BookingEndpoint {
private OfferingRepository $offerings, private OfferingRepository $offerings,
private RegistrationGate $gate, private RegistrationGate $gate,
private PaymentService $payments, private PaymentService $payments,
private CancellationPolicy $cancellationPolicy,
) {} ) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void { public function registerRoutes( string $route_namespace ): void {
register_rest_route( register_rest_route(
$route_namespace, $route_namespace,
@@ -81,18 +73,6 @@ class BookingEndpoint {
] ]
); );
register_rest_route(
$route_namespace,
'/bookings/(?P<id>\d+)/cancel',
[
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'cancel' ],
'permission_callback' => [ $this, 'isLoggedIn' ],
],
]
);
register_rest_route( register_rest_route(
$route_namespace, $route_namespace,
'/bookings/(?P<id>\d+)/status', '/bookings/(?P<id>\d+)/status',
@@ -117,28 +97,13 @@ class BookingEndpoint {
$userId = get_current_user_id(); $userId = get_current_user_id();
$lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) $lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY )
? $this->bookings->findUpcomingForInstructor( $userId ) ? $this->bookings->findUpcomingForInstructor( $userId )
: $this->bookings->findUpcomingForStudent( $userId ); : $this->bookings->findByStudent( $userId );
return new \WP_REST_Response( array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ), 200 ); return new \WP_REST_Response( array_map( fn( Lesson $l ) => $l->toArray(), $lessons ), 200 );
}
/**
* A lesson's array form plus its slot's start/end times, so front-end lists
* can show when the session happens without a second request.
*
* @return array<string, mixed>
*/
private function lessonWithTimes( Lesson $lesson ): array {
$slot = $this->availability->findById( $lesson->slotId );
return $lesson->toArray() + [
'start_dt' => $slot?->startDt,
'end_dt' => $slot?->endDt,
];
} }
public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$slotId = Val::int( $request->get_param( 'slot_id' ) ); $slotId = (int) $request->get_param( 'slot_id' );
$slot = $this->availability->findById( $slotId ); $slot = $this->availability->findById( $slotId );
if ( null === $slot ) { if ( null === $slot ) {
@@ -155,7 +120,7 @@ class BookingEndpoint {
// used must belong to the slot's instructor. This prevents substituting a // used must belong to the slot's instructor. This prevents substituting a
// cheaper/free offering to dodge payment, or another instructor's offering // cheaper/free offering to dodge payment, or another instructor's offering
// to misroute it. // to misroute it.
$requestedOfferingId = absint( Val::int( $request->get_param( 'offering_id' ) ) ); $requestedOfferingId = absint( $request->get_param( 'offering_id' ) );
$slotOfferingId = (int) ( $slot->offeringId ?? 0 ); $slotOfferingId = (int) ( $slot->offeringId ?? 0 );
if ( $slotOfferingId > 0 ) { if ( $slotOfferingId > 0 ) {
@@ -167,37 +132,17 @@ class BookingEndpoint {
$offeringId = $requestedOfferingId; $offeringId = $requestedOfferingId;
} }
// Every lesson books against an offering: it carries the price, intake $offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
// questions, and payment routing. Without one the booking would silently if ( $offeringId > 0 && null === $offering ) {
// be free and unquestioned, so generic slots require the student's choice.
if ( $offeringId <= 0 ) {
return new \WP_Error( 'offering_required', __( 'Choose a lesson type to book this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering ) {
return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
} }
if ( $offering->instructorId !== $slot->instructorId ) { if ( null !== $offering && $offering->instructorId !== $slot->instructorId ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
} }
// A slot-tied offering was the instructor's explicit choice and is honoured
// as-is; a student-chosen one must be something the catalog actually offers
// for this slot: an active private-lesson type whose length fits the slot.
if ( 0 === $slotOfferingId ) {
if ( ! $offering->isActive || Offering::KIND_PRIVATE_LESSON !== $offering->kind ) {
return new \WP_Error( 'invalid_offering', __( 'That offering cannot be booked as a private lesson.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( null !== $offering->durationMinutes && $offering->durationMinutes !== $slot->durationMinutes ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering does not match this slot\'s lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
}
$answers = $this->answers( $request ); $answers = $this->answers( $request );
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) ); $acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) );
$gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds ); $gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds );
if ( $gateError instanceof \WP_Error ) { if ( $gateError instanceof \WP_Error ) {
@@ -205,7 +150,7 @@ class BookingEndpoint {
} }
$studentId = get_current_user_id(); $studentId = get_current_user_id();
$notes = Val::string( $request->get_param( 'notes' ) ); $notes = (string) $request->get_param( 'notes' );
$recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) $recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' )
? Lesson::RECURRENCE_WEEKLY ? Lesson::RECURRENCE_WEEKLY
: Lesson::RECURRENCE_SINGLE; : Lesson::RECURRENCE_SINGLE;
@@ -214,7 +159,7 @@ class BookingEndpoint {
slotId: $slotId, slotId: $slotId,
studentId: $studentId, studentId: $studentId,
instructorId: $slot->instructorId, instructorId: $slot->instructorId,
offeringId: $offeringId, offeringId: $offeringId > 0 ? $offeringId : null,
recurrence: $recurrence, recurrence: $recurrence,
notes: '' !== $notes ? $notes : null, notes: '' !== $notes ? $notes : null,
); );
@@ -246,37 +191,14 @@ class BookingEndpoint {
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); $this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
$payment = null; if ( null !== $offering && $offering->price > 0.0 ) {
$status = Lesson::STATUS_PENDING; $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
if ( $offering->price > 0.0 ) {
// A full-term price already covers the whole reservation; a per-lesson
// (one_time) price is owed once per occurrence actually claimed, so a
// weekly reservation cannot hold a term while paying for one week.
$amount = Offering::BILLING_FULL_TERM === $offering->billingMode
? $offering->price
: $offering->price * count( $ids );
$payment = $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $amount, $offering->currency, $offering->etransferEmail );
if ( null !== $payment && $payment->isPaid() ) {
$status = Lesson::STATUS_CONFIRMED;
}
} else {
// Free offering: there is no payment step that would confirm these
// lessons later, so they are confirmed at booking time.
foreach ( $ids as $lessonId ) {
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
}
$status = Lesson::STATUS_CONFIRMED;
} }
// `payment: null` tells the front end to skip the payment step entirely.
return new \WP_REST_Response( return new \WP_REST_Response(
[ [
'ids' => $ids, 'ids' => $ids,
'status' => $status, 'status' => Lesson::STATUS_PENDING,
'payment' => $payment?->toSummaryArray(),
], ],
201 201
); );
@@ -290,7 +212,7 @@ class BookingEndpoint {
private function answers( \WP_REST_Request $request ): array { private function answers( \WP_REST_Request $request ): array {
$out = []; $out = [];
foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) { foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) {
$out[ (int) $questionId ] = sanitize_text_field( Val::string( $value ) ); $out[ (int) $questionId ] = sanitize_text_field( (string) $value );
} }
return $out; return $out;
@@ -298,62 +220,13 @@ class BookingEndpoint {
private function clientIp(): ?string { private function clientIp(): ?string {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) ); $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
return '' !== $ip ? $ip : null; return '' !== $ip ? $ip : null;
} }
/**
* Student-initiated cancellation of their own lesson: marks it cancelled,
* frees the slot for rebooking, and voids any still-pending payment. Paid
* lessons keep their payment refunds are a manual, admin-side decision.
*/
public function cancel( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$lesson = $this->bookings->findById( $id );
if ( null === $lesson ) {
return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( get_current_user_id() !== $lesson->studentId ) {
return new \WP_Error( 'forbidden', __( 'You cannot cancel this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( Lesson::STATUS_CANCELLED !== $lesson->status ) {
$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 );
}
return new \WP_REST_Response(
[
'id' => $id,
'status' => Lesson::STATUS_CANCELLED,
],
200
);
}
public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) ); $id = absint( $request->get_param( 'id' ) );
$lesson = $this->bookings->findById( $id ); $lesson = $this->bookings->findById( $id );
if ( null === $lesson ) { if ( null === $lesson ) {
@@ -364,23 +237,12 @@ class BookingEndpoint {
return new \WP_Error( 'forbidden', __( 'You cannot update this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); return new \WP_Error( 'forbidden', __( 'You cannot update this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
} }
$status = Val::string( $request->get_param( 'status' ) ); $this->bookings->updateStatus( $id, (string) $request->get_param( 'status' ) );
if ( Lesson::STATUS_CANCELLED === $status && Lesson::STATUS_CANCELLED !== $lesson->status ) {
$this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId );
} elseif ( Lesson::STATUS_CANCELLED === $lesson->status && Lesson::STATUS_CANCELLED !== $status && ! $this->availability->claim( $lesson->slotId ) ) {
// Reinstating a cancelled lesson must re-reserve its slot, and
// someone else may have booked the freed time in the meantime.
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$this->bookings->updateStatus( $id, $status );
return new \WP_REST_Response( return new \WP_REST_Response(
[ [
'id' => $id, 'id' => $id,
'status' => $status, 'status' => $request->get_param( 'status' ),
], ],
200 200
); );
+4 -32
View File
@@ -3,34 +3,25 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking; namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class BookingPage { class BookingPage {
/** /**
* Renders the booking shortcode/block output. * Renders the booking shortcode output.
* *
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or * @param array<string, string> $atts Shortcode attributes (unused reserved for future options).
* shortcode attributes (`login_page_id`).
*/ */
public function render( array $atts ): string { public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( ! is_user_logged_in() ) { if ( ! is_user_logged_in() ) {
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
return sprintf( return sprintf(
'<p>%s <a href="%s">%s</a>.</p>', '<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'Please', 'unsupervised-schedular' ), esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url( $this->loginUrl( $loginPageId ) ), esc_url( wp_login_url( get_permalink() ) ),
esc_html__( 'log in to book a lesson', 'unsupervised-schedular' ) esc_html__( 'log in to book a lesson', 'unsupervised-schedular' )
); );
} }
if ( RegistrationStatus::isAwaitingApproval( get_current_user_id() ) ) {
return '<p>' . esc_html__( 'Your account is awaiting studio approval. You will be able to book once a studio admin approves it.', 'unsupervised-schedular' ) . '</p>';
}
if ( ! current_user_can( RoleManager::CAP_BOOK_LESSON ) ) { if ( ! current_user_can( RoleManager::CAP_BOOK_LESSON ) ) {
return '<p>' . esc_html__( 'This page is for students only.', 'unsupervised-schedular' ) . '</p>'; return '<p>' . esc_html__( 'This page is for students only.', 'unsupervised-schedular' ) . '</p>';
} }
@@ -42,23 +33,4 @@ class BookingPage {
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php'; include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
return (string) ob_get_clean(); return (string) ob_get_clean();
} }
/**
* URL the logged-out prompt sends visitors to: the chosen login page when
* one is configured (and still exists), otherwise the WordPress login
* screen with a redirect back to the current page.
*/
public function loginUrl( int $loginPageId ): string {
if ( $loginPageId > 0 ) {
$url = get_permalink( $loginPageId );
if ( is_string( $url ) ) {
return $url;
}
}
$permalink = get_permalink();
return wp_login_url( false === $permalink ? '' : $permalink );
}
} }
+11 -65
View File
@@ -80,7 +80,7 @@ class BookingRepository {
public function findById( int $id ): ?Lesson { public function findById( int $id ): ?Lesson {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
); );
return $row ? Lesson::fromRow( $row ) : null; return $row ? Lesson::fromRow( $row ) : null;
@@ -96,14 +96,12 @@ class BookingRepository {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT l.* FROM %i l "SELECT l.* FROM {$this->table} l
JOIN %i a ON a.id = l.slot_id JOIN {$avTable} a ON a.id = l.slot_id
WHERE l.instructor_id = %d WHERE l.instructor_id = %d
AND l.status != %s AND l.status != %s
AND a.start_dt >= %s AND a.start_dt >= %s
ORDER BY a.start_dt ASC', ORDER BY a.start_dt ASC",
$this->table,
$avTable,
$instructorId, $instructorId,
Lesson::STATUS_CANCELLED, Lesson::STATUS_CANCELLED,
current_time( 'mysql' ) current_time( 'mysql' )
@@ -113,33 +111,6 @@ class BookingRepository {
return array_map( Lesson::fromRow( ... ), $rows ?? [] ); return array_map( Lesson::fromRow( ... ), $rows ?? [] );
} }
/**
* Upcoming lessons for a student (status != cancelled, slot in the future).
*
* @return list<Lesson>
*/
public function findUpcomingForStudent( int $studentId ): array {
$avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT l.* FROM %i l
JOIN %i a ON a.id = l.slot_id
WHERE l.student_id = %d
AND l.status != %s
AND a.start_dt >= %s
ORDER BY a.start_dt ASC',
$this->table,
$avTable,
$studentId,
Lesson::STATUS_CANCELLED,
current_time( 'mysql' )
)
);
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
}
/** /**
* Count a student's upcoming, non-cancelled lessons (slot in the future). * Count a student's upcoming, non-cancelled lessons (slot in the future).
*/ */
@@ -148,13 +119,11 @@ class BookingRepository {
return (int) $this->db->get_var( return (int) $this->db->get_var(
$this->db->prepare( $this->db->prepare(
'SELECT COUNT(*) FROM %i l "SELECT COUNT(*) FROM {$this->table} l
JOIN %i a ON a.id = l.slot_id JOIN {$avTable} a ON a.id = l.slot_id
WHERE l.student_id = %d WHERE l.student_id = %d
AND l.status != %s AND l.status != %s
AND a.start_dt >= %s', AND a.start_dt >= %s",
$this->table,
$avTable,
$studentId, $studentId,
Lesson::STATUS_CANCELLED, Lesson::STATUS_CANCELLED,
current_time( 'mysql' ) current_time( 'mysql' )
@@ -170,8 +139,7 @@ class BookingRepository {
public function findByStudent( int $studentId ): array { public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d ORDER BY created_at DESC', "SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY created_at DESC",
$this->table,
$studentId $studentId
) )
); );
@@ -189,13 +157,11 @@ class BookingRepository {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT l.* FROM %i l "SELECT l.* FROM {$this->table} l
JOIN %i a ON a.id = l.slot_id JOIN {$avTable} a ON a.id = l.slot_id
WHERE l.status != %s WHERE l.status != %s
AND a.start_dt >= %s AND a.start_dt >= %s
ORDER BY a.start_dt ASC', ORDER BY a.start_dt ASC",
$this->table,
$avTable,
Lesson::STATUS_CANCELLED, Lesson::STATUS_CANCELLED,
current_time( 'mysql' ) current_time( 'mysql' )
) )
@@ -214,26 +180,6 @@ class BookingRepository {
); );
} }
/**
* Update every non-cancelled lesson in a weekly series at once e.g.
* confirming the whole reservation when its single upfront payment settles.
*/
public function updateStatusForSeries( int $seriesId, string $status ): bool {
if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) {
return false;
}
$sql = $this->db->prepare(
'UPDATE %i SET status = %s WHERE series_id = %d AND status != %s',
$this->table,
$status,
$seriesId,
Lesson::STATUS_CANCELLED
);
return null !== $sql && false !== $this->db->query( $sql );
}
public function updateStatus( int $id, string $status ): bool { public function updateStatus( int $id, string $status ): bool {
if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) { if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) {
return false; return false;
-69
View File
@@ -1,69 +0,0 @@
<?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 );
}
}
+11 -13
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking; namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Val;
class Lesson { class Lesson {
public const STATUS_PENDING = 'pending'; public const STATUS_PENDING = 'pending';
@@ -41,18 +39,18 @@ class Lesson {
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
public static function fromRow( \stdClass $row ): self { public static function fromRow( object $row ): self {
return new self( return new self(
slotId: Val::int( $row->slot_id ), slotId: (int) $row->slot_id,
studentId: Val::int( $row->student_id ), studentId: (int) $row->student_id,
instructorId: Val::int( $row->instructor_id ), instructorId: (int) $row->instructor_id,
offeringId: Val::intOrNull( $row->offering_id ), offeringId: null !== $row->offering_id ? (int) $row->offering_id : null,
recurrence: Val::string( $row->recurrence ), recurrence: $row->recurrence,
seriesId: Val::intOrNull( $row->series_id ), seriesId: null !== $row->series_id ? (int) $row->series_id : null,
status: Val::string( $row->status ), status: $row->status,
paymentId: Val::intOrNull( $row->payment_id ), paymentId: null !== $row->payment_id ? (int) $row->payment_id : null,
notes: Val::stringOrNull( $row->notes ), notes: $row->notes,
id: Val::int( $row->id ), id: (int) $row->id,
); );
} }
+6 -50
View File
@@ -4,19 +4,14 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking; namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Availability\WeekCalendar;
use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Val;
class LessonController { class LessonController {
public function __construct( public function __construct(
private BookingRepository $repository, private BookingRepository $repository,
private PaymentRepository $payments, private PaymentRepository $payments,
private AvailabilityRepository $availability,
) {} ) {}
public function renderAdminDashboard(): void { public function renderAdminDashboard(): void {
@@ -28,7 +23,7 @@ class LessonController {
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() ); $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() );
$this->renderLessonsPage( $rows, 'us-scheduler' ); include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
} }
public function renderInstructorLessons(): void { public function renderInstructorLessons(): void {
@@ -40,29 +35,6 @@ class LessonController {
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) ); $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
$this->renderLessonsPage( $rows, 'us-my-lessons' );
}
/**
* Render the lessons template with its calendar view state: week (default)
* or list, plus which week the week view shows.
*
* @param list<array<string, mixed>> $rows
*/
private function renderLessonsPage( array $rows, string $pageSlug ): void {
// View-state query params only (which view, which week) — nothing is
// mutated from them, so no nonce applies.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$view = 'list' === sanitize_key( Val::string( wp_unslash( $_GET['usc_view'] ?? '' ) ) ) ? 'list' : 'week';
$requestedWeek = sanitize_text_field( Val::string( wp_unslash( $_GET['usc_week'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$weekStart = WeekCalendar::weekStart( $requestedWeek, Val::int( get_option( 'start_of_week', 1 ) ), current_time( 'Y-m-d' ) );
$weekDays = WeekCalendar::bucket( $weekStart, $rows, static fn( array $row ): string => Val::string( $row['day'] ) );
$prevWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '-7 days' )->format( 'Y-m-d' );
$nextWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '+7 days' )->format( 'Y-m-d' );
$baseUrl = admin_url( 'admin.php?page=' . $pageSlug );
include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
} }
@@ -76,11 +48,10 @@ class LessonController {
} }
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above. // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ); $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) ); $paymentId = absint( $_POST['payment_id'] ?? 0 );
$email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ); $email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion. $taxRate = isset( $_POST['tax_rate'] ) ? max( 0.0, (float) $_POST['tax_rate'] ) : 0.0;
$taxRate = isset( $_POST['tax_rate'] ) ? max( 0.0, Val::float( $_POST['tax_rate'] ) ) : 0.0;
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
if ( $paymentId <= 0 || ! in_array( $action, [ 'set_etransfer', 'set_tax' ], true ) ) { if ( $paymentId <= 0 || ! in_array( $action, [ 'set_etransfer', 'set_tax' ], true ) ) {
@@ -110,14 +81,11 @@ class LessonController {
$student = get_userdata( $lesson->studentId ); $student = get_userdata( $lesson->studentId );
$instructor = get_userdata( $lesson->instructorId ); $instructor = get_userdata( $lesson->instructorId );
$payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null; $payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null;
$slot = $this->availability->findById( $lesson->slotId );
return [ return [
'student' => $student ? $student->display_name : (string) $lesson->studentId, 'student' => $student ? $student->display_name : (string) $lesson->studentId,
'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId, 'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId,
'time' => $slot ? $this->formatSlotTime( $slot ) : '—', 'slot_id' => (int) $lesson->slotId,
'day' => $slot ? substr( $slot->startDt, 0, 10 ) : '',
'time_short' => $slot ? Val::string( mysql2date( 'g:i A', $slot->startDt ) ) : '—',
'status' => $lesson->status, 'status' => $lesson->status,
'notes' => $lesson->notes ?? '', 'notes' => $lesson->notes ?? '',
'payment_id' => $payment ? (int) $payment->id : 0, 'payment_id' => $payment ? (int) $payment->id : 0,
@@ -131,16 +99,4 @@ class LessonController {
'tax_editable' => null !== $payment && ! $payment->isPaid(), 'tax_editable' => null !== $payment && ! $payment->isPaid(),
]; ];
} }
/**
* Format a slot's window as e.g. "Jul 6, 2026 9:00 AM10:00 AM", repeating the
* date on the end time only when the slot crosses midnight.
*/
private function formatSlotTime( AvailabilitySlot $slot ): string {
$sameDay = substr( $slot->startDt, 0, 10 ) === substr( $slot->endDt, 0, 10 );
return Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) )
. ''
. Val::string( mysql2date( $sameDay ? 'g:i A' : 'M j, Y g:i A', $slot->endDt ) );
}
} }
+7 -9
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass; namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Val;
class Enrollment { class Enrollment {
public const STATUS_ACTIVE = 'active'; public const STATUS_ACTIVE = 'active';
@@ -27,14 +25,14 @@ class Enrollment {
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
public static function fromRow( \stdClass $row ): self { public static function fromRow( object $row ): self {
return new self( return new self(
offeringId: Val::int( $row->offering_id ), offeringId: (int) $row->offering_id,
studentId: Val::int( $row->student_id ), studentId: (int) $row->student_id,
instructorId: Val::int( $row->instructor_id ), instructorId: (int) $row->instructor_id,
status: Val::string( $row->status ), status: $row->status,
paymentId: Val::intOrNull( $row->payment_id ), paymentId: null !== $row->payment_id ? (int) $row->payment_id : null,
id: Val::int( $row->id ), id: (int) $row->id,
); );
} }
+7 -29
View File
@@ -10,7 +10,6 @@ use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate; use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Val;
class EnrollmentEndpoint { class EnrollmentEndpoint {
@@ -19,14 +18,8 @@ class EnrollmentEndpoint {
private OfferingRepository $offerings, private OfferingRepository $offerings,
private RegistrationGate $gate, private RegistrationGate $gate,
private PaymentService $payments, private PaymentService $payments,
private GroupAccessRepository $access,
) {} ) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void { public function registerRoutes( string $route_namespace ): void {
register_rest_route( register_rest_route(
$route_namespace, $route_namespace,
@@ -76,7 +69,7 @@ class EnrollmentEndpoint {
} }
public function enroll( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function enroll( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) ); $offeringId = absint( $request->get_param( 'offering_id' ) );
$offering = $this->offerings->findById( $offeringId ); $offering = $this->offerings->findById( $offeringId );
if ( null === $offering || Offering::KIND_GROUP_CLASS !== $offering->kind ) { if ( null === $offering || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
@@ -89,18 +82,12 @@ class EnrollmentEndpoint {
return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); 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 ) { if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
} }
$answers = $this->answers( $request ); $answers = $this->answers( $request );
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) ); $acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) );
$gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds ); $gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds );
if ( $gateError instanceof \WP_Error ) { if ( $gateError instanceof \WP_Error ) {
@@ -117,23 +104,14 @@ class EnrollmentEndpoint {
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); $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 ) { if ( $offering->price > 0.0 ) {
$payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
} }
// `payment: null` tells the front end to skip the payment step entirely.
return new \WP_REST_Response( return new \WP_REST_Response(
[ [
'id' => $id, 'id' => $id,
'status' => Enrollment::STATUS_ACTIVE, 'status' => Enrollment::STATUS_ACTIVE,
'payment' => $payment?->toSummaryArray(),
], ],
201 201
); );
@@ -155,7 +133,7 @@ class EnrollmentEndpoint {
private function answers( \WP_REST_Request $request ): array { private function answers( \WP_REST_Request $request ): array {
$out = []; $out = [];
foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) { foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) {
$out[ (int) $questionId ] = sanitize_text_field( Val::string( $value ) ); $out[ (int) $questionId ] = sanitize_text_field( (string) $value );
} }
return $out; return $out;
@@ -163,7 +141,7 @@ class EnrollmentEndpoint {
private function clientIp(): ?string { private function clientIp(): ?string {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) ); $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
return '' !== $ip ? $ip : null; return '' !== $ip ? $ip : null;
} }
+7 -13
View File
@@ -30,7 +30,7 @@ class EnrollmentRepository {
public function findById( int $id ): ?Enrollment { public function findById( int $id ): ?Enrollment {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
); );
return $row ? Enrollment::fromRow( $row ) : null; return $row ? Enrollment::fromRow( $row ) : null;
@@ -42,8 +42,7 @@ class EnrollmentRepository {
public function countActiveForOffering( int $offeringId ): int { public function countActiveForOffering( int $offeringId ): int {
return (int) $this->db->get_var( return (int) $this->db->get_var(
$this->db->prepare( $this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND status = %s', "SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND status = %s",
$this->table,
$offeringId, $offeringId,
Enrollment::STATUS_ACTIVE Enrollment::STATUS_ACTIVE
) )
@@ -56,8 +55,7 @@ class EnrollmentRepository {
public function countActiveForStudent( int $studentId ): int { public function countActiveForStudent( int $studentId ): int {
return (int) $this->db->get_var( return (int) $this->db->get_var(
$this->db->prepare( $this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE student_id = %d AND status = %s', "SELECT COUNT(*) FROM {$this->table} WHERE student_id = %d AND status = %s",
$this->table,
$studentId, $studentId,
Enrollment::STATUS_ACTIVE Enrollment::STATUS_ACTIVE
) )
@@ -70,8 +68,7 @@ class EnrollmentRepository {
public function hasActiveEnrollment( int $offeringId, int $studentId ): bool { public function hasActiveEnrollment( int $offeringId, int $studentId ): bool {
$count = (int) $this->db->get_var( $count = (int) $this->db->get_var(
$this->db->prepare( $this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND student_id = %d AND status = %s', "SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND student_id = %d AND status = %s",
$this->table,
$offeringId, $offeringId,
$studentId, $studentId,
Enrollment::STATUS_ACTIVE Enrollment::STATUS_ACTIVE
@@ -89,8 +86,7 @@ class EnrollmentRepository {
public function findByStudent( int $studentId ): array { public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d ORDER BY enrolled_at DESC', "SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY enrolled_at DESC",
$this->table,
$studentId $studentId
) )
); );
@@ -106,8 +102,7 @@ class EnrollmentRepository {
public function findByInstructor( int $instructorId ): array { public function findByInstructor( int $instructorId ): array {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE instructor_id = %d ORDER BY enrolled_at DESC', "SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY enrolled_at DESC",
$this->table,
$instructorId $instructorId
) )
); );
@@ -123,8 +118,7 @@ class EnrollmentRepository {
public function findAllActive(): array { public function findAllActive(): array {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE status = %s ORDER BY enrolled_at DESC', "SELECT * FROM {$this->table} WHERE status = %s ORDER BY enrolled_at DESC",
$this->table,
Enrollment::STATUS_ACTIVE Enrollment::STATUS_ACTIVE
) )
); );
-67
View File
@@ -1,67 +0,0 @@
<?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
@@ -1,132 +0,0 @@
<?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' ]
);
}
}
+8 -478
View File
@@ -3,505 +3,35 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass; 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\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository; 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 { class GroupClassController {
public function __construct( public function __construct(
private EnrollmentRepository $enrollments, private EnrollmentRepository $enrollments,
private OfferingRepository $offerings, 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 { public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) { if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) ); wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
} }
$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( $rows = array_map(
function ( Offering $offering ): array { function ( Enrollment $enrollment ): array {
$offering = $this->offerings->findById( $enrollment->offeringId );
$student = get_userdata( $enrollment->studentId );
return [ return [
'id' => $offering->id, 'student' => $student ? $student->display_name : (string) $enrollment->studentId,
'title' => $offering->title, 'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
'instructor' => $this->instructorName( $offering ), 'status' => $enrollment->status,
'when' => $this->whenLabel( $offering ),
'capacity' => $offering->capacity,
'enrolled' => $this->enrollments->countActiveForOffering( (int) $offering->id ),
'invite_only' => $offering->isInviteOnly(),
]; ];
}, },
$offerings $this->enrollments->findAllActive()
); );
include USC_PLUGIN_DIR . 'templates/admin/group-classes.php'; 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( '/' ) );
}
} }
+3 -13
View File
@@ -4,28 +4,20 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass; namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class GroupClassPage { class GroupClassPage {
/** /**
* Renders the group-class enrolment shortcode output. * Renders the group-class enrolment shortcode output.
* *
* Supported attributes: `offering` (shortcode) / `offeringId` (block) an * @param array<string, string> $atts Shortcode attributes (unused reserved for future options).
* offering id that restricts the page to a single class, so the shortcode
* can be embedded on a page dedicated to that class. 0 or absent shows the
* full browsable catalog.
*
* @param array<int|string, mixed> $atts Shortcode or block attributes.
*/ */
public function render( array $atts ): string { public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( ! is_user_logged_in() ) { if ( ! is_user_logged_in() ) {
$permalink = get_permalink();
return sprintf( return sprintf(
'<p>%s <a href="%s">%s</a>.</p>', '<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'Please', 'unsupervised-schedular' ), esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url( wp_login_url( false === $permalink ? '' : $permalink ) ), esc_url( wp_login_url( get_permalink() ) ),
esc_html__( 'log in to enrol in a class', 'unsupervised-schedular' ) esc_html__( 'log in to enrol in a class', 'unsupervised-schedular' )
); );
} }
@@ -37,8 +29,6 @@ class GroupClassPage {
wp_enqueue_style( 'us-scheduler' ); wp_enqueue_style( 'us-scheduler' );
wp_enqueue_script( 'us-scheduler-group' ); wp_enqueue_script( 'us-scheduler-group' );
$offeringId = absint( Val::int( $atts['offering'] ?? $atts['offeringId'] ?? 0 ) );
ob_start(); ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php'; include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php';
return (string) ob_get_clean(); return (string) ob_get_clean();
-14
View File
@@ -4,13 +4,11 @@ declare(strict_types=1);
namespace Unsupervised\Schedular; namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
class Installer { class Installer {
public function run(): void { public function run(): void {
$this->createTables(); $this->createTables();
$this->migrateData();
( new RoleManager() )->createRoles(); ( new RoleManager() )->createRoles();
flush_rewrite_rules(); flush_rewrite_rules();
update_option( 'us_schedular_version', USC_VERSION ); update_option( 'us_schedular_version', USC_VERSION );
@@ -18,9 +16,6 @@ class Installer {
private function createTables(): void { private function createTables(): void {
global $wpdb; global $wpdb;
if ( ! $wpdb instanceof \wpdb ) {
return;
}
$charset = $wpdb->get_charset_collate(); $charset = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php'; require_once ABSPATH . 'wp-admin/includes/upgrade.php';
@@ -29,13 +24,4 @@ class Installer {
dbDelta( $sql ); dbDelta( $sql );
} }
} }
private function migrateData(): void {
global $wpdb;
if ( ! $wpdb instanceof \wpdb ) {
return;
}
( new AvailabilityRepository( $wpdb ) )->splitOversizedWindows();
}
} }
-57
View File
@@ -1,57 +0,0 @@
<?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,
];
}
}
+32 -148
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering; namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Val;
class Offering { class Offering {
public const KIND_PRIVATE_LESSON = 'private_lesson'; public const KIND_PRIVATE_LESSON = 'private_lesson';
@@ -27,19 +25,6 @@ class Offering {
*/ */
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ]; 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 function __construct(
public readonly int $instructorId, public readonly int $instructorId,
public readonly string $kind, public readonly string $kind,
@@ -53,128 +38,30 @@ class Offering {
public readonly ?int $capacity = null, public readonly ?int $capacity = null,
public readonly ?string $termStart = null, public readonly ?string $termStart = null,
public readonly ?string $termEnd = null, public readonly ?string $termEnd = null,
public readonly ?string $classTime = null,
public readonly ?string $scheduleNote = null, public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = 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 bool $isActive = true,
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
/** public static function fromRow( object $row ): self {
* 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
* strings PHP would silently coerce (e.g. `2026-02-30`) are rejected.
*/
public static function normalizeDate( string $value ): ?string {
$date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
return false !== $date && $date->format( 'Y-m-d' ) === $value ? $date->format( 'Y-m-d' ) : null;
}
/**
* Last class date of a weekly term: the start date plus `$occurrences - 1`
* weeks. A one-off class (one occurrence) ends the day it starts.
*/
public static function weeklyTermEnd( string $termStart, int $occurrences ): string {
$weeks = max( 1, $occurrences ) - 1;
return ( new \DateTimeImmutable( $termStart ) )->modify( '+' . ( 7 * $weeks ) . ' days' )->format( 'Y-m-d' );
}
/**
* 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( return new self(
instructorId: Val::int( $row->instructor_id ), instructorId: (int) $row->instructor_id,
kind: Val::string( $row->kind ), kind: $row->kind,
title: Val::string( $row->title ), title: $row->title,
price: Val::float( $row->price ), price: (float) $row->price,
currency: Val::string( $row->currency ), currency: $row->currency,
billingMode: Val::string( $row->billing_mode ), billingMode: $row->billing_mode,
description: Val::stringOrNull( $row->description ), description: $row->description,
durationMinutes: Val::intOrNull( $row->duration_minutes ), durationMinutes: null !== $row->duration_minutes ? (int) $row->duration_minutes : null,
allowWeekly: Val::bool( $row->allow_weekly ), allowWeekly: (bool) $row->allow_weekly,
capacity: Val::intOrNull( $row->capacity ), capacity: null !== $row->capacity ? (int) $row->capacity : null,
termStart: Val::stringOrNull( $row->term_start ), termStart: $row->term_start,
termEnd: Val::stringOrNull( $row->term_end ), termEnd: $row->term_end,
classTime: Val::stringOrNull( $row->class_time ?? null ), scheduleNote: $row->schedule_note,
scheduleNote: Val::stringOrNull( $row->schedule_note ), etransferEmail: $row->etransfer_email,
etransferEmail: Val::stringOrNull( $row->etransfer_email ), isActive: (bool) $row->is_active,
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ), id: (int) $row->id,
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 ),
); );
} }
@@ -189,24 +76,21 @@ class Offering {
*/ */
public function toArray( bool $includeEtransferEmail = true ): array { public function toArray( bool $includeEtransferEmail = true ): array {
$out = [ $out = [
'id' => $this->id, 'id' => $this->id,
'instructor_id' => $this->instructorId, 'instructor_id' => $this->instructorId,
'kind' => $this->kind, 'kind' => $this->kind,
'title' => $this->title, 'title' => $this->title,
'description' => $this->description, 'description' => $this->description,
'duration_minutes' => $this->durationMinutes, 'duration_minutes' => $this->durationMinutes,
'price' => $this->price, 'price' => $this->price,
'currency' => $this->currency, 'currency' => $this->currency,
'billing_mode' => $this->billingMode, 'billing_mode' => $this->billingMode,
'allow_weekly' => $this->allowWeekly, 'allow_weekly' => $this->allowWeekly,
'capacity' => $this->capacity, 'capacity' => $this->capacity,
'term_start' => $this->termStart, 'term_start' => $this->termStart,
'term_end' => $this->termEnd, 'term_end' => $this->termEnd,
'class_time' => $this->classTime, 'schedule_note' => $this->scheduleNote,
'schedule_note' => $this->scheduleNote, 'is_active' => $this->isActive,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'access_mode' => $this->accessMode,
'is_active' => $this->isActive,
]; ];
if ( $includeEtransferEmail ) { if ( $includeEtransferEmail ) {
+26 -196
View File
@@ -4,14 +4,10 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering; namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class OfferingController { class OfferingController {
public function __construct( public function __construct( private OfferingRepository $repository ) {}
private OfferingRepository $repository,
private ClassSlotReconciler $reconciler,
) {}
public function renderPage(): void { public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) ) { if ( ! current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) ) {
@@ -21,27 +17,8 @@ class OfferingController {
$instructorId = get_current_user_id(); $instructorId = get_current_user_id();
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS ); $manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_offering_action' ) ) { if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_offering_action' ) ) {
$notice = $this->handleFormAction( $instructorId, $manageAll ); $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
$editId = absint( Val::int( $_GET['usc_edit'] ?? 0 ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$editing = null;
if ( $editId > 0 ) {
$candidate = $this->repository->findById( $editId );
if ( $candidate && ( $manageAll || $candidate->instructorId === $instructorId ) ) {
$editing = $candidate;
}
} }
$offerings = $manageAll $offerings = $manageAll
@@ -51,42 +28,17 @@ class OfferingController {
include USC_PLUGIN_DIR . 'templates/admin/offerings.php'; 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. // Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
if ( 'add' === $action ) { if ( 'add' === $action ) {
$offering = $this->offeringFromPost( $instructorId, $manageAll ); $this->addOffering( $instructorId );
if ( null !== $offering ) {
$this->repository->insert( $offering );
return $this->reconcileNotice( $offering );
}
}
if ( 'update' === $action ) {
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
if ( $offeringId > 0 ) {
$existing = $this->repository->findById( $offeringId );
if ( $existing && ( $manageAll || $existing->instructorId === $instructorId ) ) {
$offering = $this->offeringFromPost( $instructorId, $manageAll, $existing );
if ( null !== $offering ) {
$this->repository->update( $offeringId, $offering );
return $this->reconcileNotice( $offering );
}
}
}
} }
if ( 'delete' === $action ) { if ( 'delete' === $action ) {
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) ); $offeringId = absint( $_POST['offering_id'] ?? 0 );
if ( $offeringId > 0 ) { if ( $offeringId > 0 ) {
$offering = $this->repository->findById( $offeringId ); $offering = $this->repository->findById( $offeringId );
if ( $offering && ( $manageAll || $offering->instructorId === $instructorId ) ) { if ( $offering && ( $manageAll || $offering->instructorId === $instructorId ) ) {
@@ -95,164 +47,42 @@ class OfferingController {
} }
} }
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
return '';
} }
/** private function addOffering( int $instructorId ): void {
* 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 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, bool $manageAll, ?Offering $existing = null ): ?Offering {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) ); $title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) );
$kind = sanitize_key( Val::string( wp_unslash( $_POST['kind'] ?? '' ) ) ); $kind = sanitize_key( wp_unslash( $_POST['kind'] ?? '' ) );
if ( '' === $title || ! in_array( $kind, Offering::VALID_KINDS, true ) ) { if ( '' === $title || ! in_array( $kind, Offering::VALID_KINDS, true ) ) {
return null; return;
} }
$billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) ); $billingMode = sanitize_key( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) );
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) { if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
$billingMode = Offering::BILLING_ONE_TIME; $billingMode = Offering::BILLING_ONE_TIME;
} }
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) ); $duration = absint( $_POST['duration_minutes'] ?? 0 );
$capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) ); $capacity = absint( $_POST['capacity'] ?? 0 );
// A blank cutoff means "use the studio default" (null); any entered value $this->repository->insert(
// (including 0 — cancel any time) is a per-offering override. new Offering(
$cutoffRaw = trim( sanitize_text_field( Val::string( wp_unslash( $_POST['cancellation_cutoff_hours'] ?? '' ) ) ) ); instructorId: $instructorId,
$cutoffHours = '' === $cutoffRaw ? null : absint( Val::int( $cutoffRaw ) ); kind: $kind,
title: $title,
// Term dates: a class either meets once (term ends the day it starts) price: max( 0.0, (float) sanitize_text_field( wp_unslash( $_POST['price'] ?? '0' ) ) ),
// or repeats weekly for a set number of sessions. billingMode: $billingMode,
$termStart = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['term_start'] ?? '' ) ) ) ); durationMinutes: $duration > 0 ? $duration : null,
$termEnd = null; allowWeekly: isset( $_POST['allow_weekly'] ),
if ( null !== $termStart ) { capacity: $capacity > 0 ? $capacity : null,
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['term_recurrence'] ?? 'single' ) ) ); scheduleNote: $this->nullableText( sanitize_text_field( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ),
$sessions = absint( Val::int( $_POST['term_sessions'] ?? 1 ) ); etransferEmail: $this->nullableText( sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ),
$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: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
kind: $kind,
title: $title,
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ),
currency: null !== $existing ? $existing->currency : 'CAD',
billingMode: $billingMode,
description: $this->nullableText( sanitize_textarea_field( Val::string( wp_unslash( $_POST['description'] ?? '' ) ) ) ),
durationMinutes: $duration > 0 ? $duration : null,
allowWeekly: isset( $_POST['allow_weekly'] ),
capacity: $capacity > 0 ? $capacity : null,
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 // 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 { private function nullableText( string $value ): ?string {
return '' === $value ? null : $value; return '' === $value ? null : $value;
} }
+22 -98
View File
@@ -4,22 +4,11 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering; namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Val;
class OfferingEndpoint { class OfferingEndpoint {
public function __construct( public function __construct( private OfferingRepository $repository ) {}
private OfferingRepository $repository,
private GroupAccessRepository $access,
) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void { public function registerRoutes( string $route_namespace ): void {
register_rest_route( register_rest_route(
$route_namespace, $route_namespace,
@@ -67,83 +56,28 @@ class OfferingEndpoint {
} }
public function index( \WP_REST_Request $request ): \WP_REST_Response { public function index( \WP_REST_Request $request ): \WP_REST_Response {
$instructorId = Val::int( $request->get_param( 'instructor_id' ) ); $offerings = $this->repository->findAll(
$kind = Val::string( $request->get_param( 'kind' ) ); (int) $request->get_param( 'instructor_id' ),
(string) $request->get_param( 'kind' ),
activeOnly: true,
);
// The public catalogue is public offerings only; invite-only classes are // Public listing: omit the private e-transfer destination email.
// hidden from it and surfaced separately to the students granted access. return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray( includeEtransferEmail: false ), $offerings ), 200 );
$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 { public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ); $title = sanitize_text_field( (string) $request->get_param( 'title' ) );
if ( '' === $title ) { if ( '' === $title ) {
return $this->invalid( __( 'A title is required.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'A title is required.', 'unsupervised-schedular' ) );
} }
$kind = Val::string( $request->get_param( 'kind' ) ); $kind = (string) $request->get_param( 'kind' );
if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) { if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) {
return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) );
} }
$billingMode = Val::string( $request->get_param( 'billing_mode' ) ?? Offering::BILLING_ONE_TIME ); $billingMode = (string) ( $request->get_param( 'billing_mode' ) ?? Offering::BILLING_ONE_TIME );
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) { if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
} }
@@ -153,7 +87,7 @@ class OfferingEndpoint {
kind: $kind, kind: $kind,
title: $title, title: $title,
price: $this->price( $request->get_param( 'price' ) ), price: $this->price( $request->get_param( 'price' ) ),
currency: sanitize_text_field( Val::string( $request->get_param( 'currency' ) ?? 'CAD' ) ), currency: sanitize_text_field( (string) ( $request->get_param( 'currency' ) ?? 'CAD' ) ),
billingMode: $billingMode, billingMode: $billingMode,
description: $this->nullableText( $request->get_param( 'description' ) ), description: $this->nullableText( $request->get_param( 'description' ) ),
durationMinutes: $this->nullableInt( $request->get_param( 'duration_minutes' ) ), durationMinutes: $this->nullableInt( $request->get_param( 'duration_minutes' ) ),
@@ -163,8 +97,6 @@ class OfferingEndpoint {
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ), termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ), scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ), 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' ), isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
); );
@@ -174,7 +106,7 @@ class OfferingEndpoint {
} }
public function update( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function update( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) ); $id = absint( $request->get_param( 'id' ) );
$existing = $this->repository->findById( $id ); $existing = $this->repository->findById( $id );
if ( null === $existing ) { if ( null === $existing ) {
@@ -185,12 +117,12 @@ class OfferingEndpoint {
return new \WP_Error( 'forbidden', __( 'You cannot edit this offering.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); return new \WP_Error( 'forbidden', __( 'You cannot edit this offering.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
} }
$kind = $request->has_param( 'kind' ) ? Val::string( $request->get_param( 'kind' ) ) : $existing->kind; $kind = $request->has_param( 'kind' ) ? (string) $request->get_param( 'kind' ) : $existing->kind;
if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) { if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) {
return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) );
} }
$billingMode = $request->has_param( 'billing_mode' ) ? Val::string( $request->get_param( 'billing_mode' ) ) : $existing->billingMode; $billingMode = $request->has_param( 'billing_mode' ) ? (string) $request->get_param( 'billing_mode' ) : $existing->billingMode;
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) { if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
} }
@@ -198,9 +130,9 @@ class OfferingEndpoint {
$offering = new Offering( $offering = new Offering(
instructorId: $existing->instructorId, instructorId: $existing->instructorId,
kind: $kind, kind: $kind,
title: $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title, title: $request->has_param( 'title' ) ? sanitize_text_field( (string) $request->get_param( 'title' ) ) : $existing->title,
price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price, price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price,
currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::string( $request->get_param( 'currency' ) ) ) : $existing->currency, currency: $request->has_param( 'currency' ) ? sanitize_text_field( (string) $request->get_param( 'currency' ) ) : $existing->currency,
billingMode: $billingMode, billingMode: $billingMode,
description: $request->has_param( 'description' ) ? $this->nullableText( $request->get_param( 'description' ) ) : $existing->description, description: $request->has_param( 'description' ) ? $this->nullableText( $request->get_param( 'description' ) ) : $existing->description,
durationMinutes: $request->has_param( 'duration_minutes' ) ? $this->nullableInt( $request->get_param( 'duration_minutes' ) ) : $existing->durationMinutes, durationMinutes: $request->has_param( 'duration_minutes' ) ? $this->nullableInt( $request->get_param( 'duration_minutes' ) ) : $existing->durationMinutes,
@@ -210,8 +142,6 @@ class OfferingEndpoint {
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd, termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote, 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, 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, isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
id: $id, id: $id,
); );
@@ -222,7 +152,7 @@ class OfferingEndpoint {
} }
public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) ); $id = absint( $request->get_param( 'id' ) );
$existing = $this->repository->findById( $id ); $existing = $this->repository->findById( $id );
if ( null === $existing ) { if ( null === $existing ) {
@@ -265,23 +195,17 @@ class OfferingEndpoint {
} }
private function price( mixed $value ): float { private function price( mixed $value ): float {
return max( 0.0, Val::float( $value ) ); return max( 0.0, (float) $value );
} }
private function nullableEmail( mixed $value ): ?string { private function nullableEmail( mixed $value ): ?string {
$email = sanitize_email( Val::string( $value ) ); $email = sanitize_email( (string) $value );
return '' !== $email ? $email : null; 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 { private function nullableInt( mixed $value ): ?int {
return ( null === $value || '' === $value ) ? null : Val::int( $value ); return ( null === $value || '' === $value ) ? null : (int) $value;
} }
private function nullableText( mixed $value ): ?string { private function nullableText( mixed $value ): ?string {
@@ -289,6 +213,6 @@ class OfferingEndpoint {
return null; return null;
} }
return sanitize_text_field( Val::string( $value ) ); return sanitize_text_field( (string) $value );
} }
} }
+24 -35
View File
@@ -14,12 +14,11 @@ class OfferingRepository {
/** /**
* Column formats aligned to {@see columns()} (instructor_id, kind, title, * Column formats aligned to {@see columns()} (instructor_id, kind, title,
* description, duration_minutes, price, currency, billing_mode, allow_weekly, * description, duration_minutes, price, currency, billing_mode, allow_weekly,
* capacity, term_start, term_end, class_time, schedule_note, etransfer_email, * capacity, term_start, term_end, schedule_note, etransfer_email, is_active).
* cancellation_cutoff_hours, access_mode, is_active).
* *
* @var list<string> * @var list<string>
*/ */
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ]; private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%d' ];
public function insert( Offering $offering ): int { public function insert( Offering $offering ): int {
$this->db->insert( $this->db->insert(
@@ -48,35 +47,30 @@ class OfferingRepository {
*/ */
private function columns( Offering $offering ): array { private function columns( Offering $offering ): array {
return [ return [
'instructor_id' => $offering->instructorId, 'instructor_id' => $offering->instructorId,
'kind' => $offering->kind, 'kind' => $offering->kind,
'title' => $offering->title, 'title' => $offering->title,
'description' => $offering->description, 'description' => $offering->description,
'duration_minutes' => $offering->durationMinutes, 'duration_minutes' => $offering->durationMinutes,
'price' => $offering->price, 'price' => $offering->price,
'currency' => $offering->currency, 'currency' => $offering->currency,
'billing_mode' => $offering->billingMode, 'billing_mode' => $offering->billingMode,
'allow_weekly' => $offering->allowWeekly ? 1 : 0, 'allow_weekly' => $offering->allowWeekly ? 1 : 0,
'capacity' => $offering->capacity, 'capacity' => $offering->capacity,
'term_start' => $offering->termStart, 'term_start' => $offering->termStart,
'term_end' => $offering->termEnd, 'term_end' => $offering->termEnd,
'class_time' => $offering->classTime, 'schedule_note' => $offering->scheduleNote,
'schedule_note' => $offering->scheduleNote, 'etransfer_email' => $offering->etransferEmail,
'etransfer_email' => $offering->etransferEmail, 'is_active' => $offering->isActive ? 1 : 0,
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
'access_mode' => $offering->accessMode,
'is_active' => $offering->isActive ? 1 : 0,
]; ];
} }
/** /**
* Find offerings, optionally filtered by instructor, kind, active state, and * Find offerings, optionally filtered by instructor, kind, and active state.
* access mode (e.g. `Offering::ACCESS_PUBLIC` to exclude invite-only classes
* from the public catalogue).
* *
* @return list<Offering> * @return list<Offering>
*/ */
public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null, ?string $accessMode = null ): array { public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null ): array {
$where = [ '1 = 1' ]; $where = [ '1 = 1' ];
$params = []; $params = [];
@@ -95,24 +89,19 @@ class OfferingRepository {
$params[] = $activeOnly ? 1 : 0; $params[] = $activeOnly ? 1 : 0;
} }
if ( null !== $accessMode ) {
$where[] = 'access_mode = %s';
$params[] = $accessMode;
}
$whereClause = implode( ' AND ', $where ); $whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY title ASC"; $sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY title ASC";
$rows = $this->db->get_results( $rows = $params
$this->db->prepare( $sql, array_merge( [ $this->table ], $params ) ) ? $this->db->get_results( $this->db->prepare( $sql, $params ) )
); : $this->db->get_results( $sql );
return array_map( Offering::fromRow( ... ), $rows ?? [] ); return array_map( Offering::fromRow( ... ), $rows ?? [] );
} }
public function findById( int $id ): ?Offering { public function findById( int $id ): ?Offering {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
); );
return $row ? Offering::fromRow( $row ) : null; return $row ? Offering::fromRow( $row ) : null;
+1 -3
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment; namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Val;
/** /**
* Resolves the billing method for a student: a per-student override if set, * Resolves the billing method for a student: a per-student override if set,
* otherwise the studio default card when Stripe is configured, e-transfer when * otherwise the studio default card when Stripe is configured, e-transfer when
@@ -17,7 +15,7 @@ class BillingMethodResolver {
public function __construct( private StudioSettings $settings ) {} public function __construct( private StudioSettings $settings ) {}
public function resolve( int $studentId ): string { public function resolve( int $studentId ): string {
$override = Val::string( get_user_meta( $studentId, self::META_METHOD, true ) ); $override = (string) get_user_meta( $studentId, self::META_METHOD, true );
if ( in_array( $override, Payment::VALID_METHODS, true ) ) { if ( in_array( $override, Payment::VALID_METHODS, true ) ) {
return $override; return $override;
} }
+17 -36
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment; namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Val;
class Payment { class Payment {
public const METHOD_CARD = 'card'; public const METHOD_CARD = 'card';
@@ -49,29 +47,27 @@ class Payment {
public readonly ?string $receiptNumber = null, public readonly ?string $receiptNumber = null,
public readonly ?string $receiptSentAt = null, public readonly ?string $receiptSentAt = null,
public readonly ?string $paidAt = null, public readonly ?string $paidAt = null,
public readonly ?string $createdAt = null,
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
public static function fromRow( \stdClass $row ): self { public static function fromRow( object $row ): self {
return new self( return new self(
studentId: Val::int( $row->student_id ), studentId: (int) $row->student_id,
instructorId: Val::int( $row->instructor_id ), instructorId: (int) $row->instructor_id,
registrationType: Val::string( $row->registration_type ), registrationType: $row->registration_type,
registrationId: Val::int( $row->registration_id ), registrationId: (int) $row->registration_id,
amount: Val::float( $row->amount ), amount: (float) $row->amount,
currency: Val::string( $row->currency ), currency: $row->currency,
method: Val::string( $row->method ), method: $row->method,
status: Val::string( $row->status ), status: $row->status,
taxRate: Val::float( $row->tax_rate ), taxRate: (float) $row->tax_rate,
taxAmount: Val::float( $row->tax_amount ), taxAmount: (float) $row->tax_amount,
etransferEmail: Val::stringOrNull( $row->etransfer_email ), etransferEmail: $row->etransfer_email,
stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ), stripePaymentIntentId: $row->stripe_payment_intent_id,
receiptNumber: Val::stringOrNull( $row->receipt_number ), receiptNumber: $row->receipt_number,
receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ), receiptSentAt: $row->receipt_sent_at,
paidAt: Val::stringOrNull( $row->paid_at ), paidAt: $row->paid_at,
createdAt: Val::stringOrNull( $row->created_at ), id: (int) $row->id,
id: Val::int( $row->id ),
); );
} }
@@ -86,20 +82,6 @@ class Payment {
return round( $this->amount + $this->taxAmount, 2 ); return round( $this->amount + $this->taxAmount, 2 );
} }
/**
* Minimal payment info embedded in registration-creation responses: enough
* for the front end to decide whether (and how) to run the payment step.
*
* @return array<string, mixed>
*/
public function toSummaryArray(): array {
return [
'id' => $this->id,
'method' => $this->method,
'status' => $this->status,
];
}
/** /**
* Returns a plain array representation of the payment. * Returns a plain array representation of the payment.
* *
@@ -122,7 +104,6 @@ class Payment {
'status' => $this->status, 'status' => $this->status,
'receipt_number' => $this->receiptNumber, 'receipt_number' => $this->receiptNumber,
'paid_at' => $this->paidAt, 'paid_at' => $this->paidAt,
'created_at' => $this->createdAt,
]; ];
} }
} }
+3 -4
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment; namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PaymentController { class PaymentController {
@@ -20,10 +19,10 @@ class PaymentController {
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_payment_action' ) ) { if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_payment_action' ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
if ( 'mark_paid' === sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) { if ( 'mark_paid' === sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ) ) {
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) ); $paymentId = absint( $_POST['payment_id'] ?? 0 );
$email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ); $email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
if ( $paymentId > 0 ) { if ( $paymentId > 0 ) {
// Record the destination it was actually sent to before confirming. // Record the destination it was actually sent to before confirming.
+3 -9
View File
@@ -4,17 +4,11 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment; namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PaymentEndpoint { class PaymentEndpoint {
public function __construct( private PaymentService $service ) {} public function __construct( private PaymentService $service ) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void { public function registerRoutes( string $route_namespace ): void {
register_rest_route( register_rest_route(
$route_namespace, $route_namespace,
@@ -70,8 +64,8 @@ class PaymentEndpoint {
* (Stripe client secret for card; display data for e-transfer/comp). * (Stripe client secret for card; display data for e-transfer/comp).
*/ */
public function createIntent( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function createIntent( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$type = Val::string( $request->get_param( 'registration_type' ) ); $type = (string) $request->get_param( 'registration_type' );
$registrationId = absint( Val::int( $request->get_param( 'registration_id' ) ) ); $registrationId = absint( $request->get_param( 'registration_id' ) );
$result = $this->service->createIntent( $type, $registrationId, get_current_user_id() ); $result = $this->service->createIntent( $type, $registrationId, get_current_user_id() );
if ( null === $result ) { if ( null === $result ) {
@@ -105,7 +99,7 @@ class PaymentEndpoint {
* Studio admin marks a pending payment (e-transfer) received. * Studio admin marks a pending payment (e-transfer) received.
*/ */
public function markPaid( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function markPaid( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) ); $id = absint( $request->get_param( 'id' ) );
if ( ! $this->service->markPaid( $id ) ) { if ( ! $this->service->markPaid( $id ) ) {
return new \WP_Error( 'not_found', __( 'Payment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); return new \WP_Error( 'not_found', __( 'Payment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
+2 -11
View File
@@ -90,22 +90,13 @@ class PaymentReport {
} }
/** /**
* Format one CSV record, quoting fields and escaping embedded quotes. Fields * Format one CSV record, quoting fields and escaping embedded quotes.
* that a spreadsheet would interpret as a formula (leading =, +, -, @, tab, or
* CR e.g. a hostile student display name) are prefixed with an apostrophe so
* they open as text, never as executable formulas.
* *
* @param list<string> $fields * @param list<string> $fields
*/ */
private function csvLine( array $fields ): string { private function csvLine( array $fields ): string {
$escaped = array_map( $escaped = array_map(
static function ( string $field ): string { static fn( string $field ): string => '"' . str_replace( '"', '""', $field ) . '"',
if ( 1 === preg_match( '/^[=+\-@\t\r]/', $field ) ) {
$field = "'" . $field;
}
return '"' . str_replace( '"', '""', $field ) . '"';
},
$fields $fields
); );
+5 -7
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment; namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PaymentReportController { class PaymentReportController {
@@ -22,8 +21,8 @@ class PaymentReportController {
} }
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only report filters, no state change. // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only report filters, no state change.
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( Val::string( wp_unslash( $_GET['month'] ) ) ) : '' ); $month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : '' );
$instructorId = isset( $_GET['instructor_id'] ) ? absint( Val::int( $_GET['instructor_id'] ) ) : 0; $instructorId = isset( $_GET['instructor_id'] ) ? absint( $_GET['instructor_id'] ) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Recommended // phpcs:enable WordPress.Security.NonceVerification.Recommended
$instructorId = $this->scopeInstructor( $instructorId ); $instructorId = $this->scopeInstructor( $instructorId );
@@ -59,8 +58,8 @@ class PaymentReportController {
check_admin_referer( self::EXPORT_ACTION ); check_admin_referer( self::EXPORT_ACTION );
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce checked above. // phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce checked above.
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( Val::string( wp_unslash( $_GET['month'] ) ) ) : '' ); $month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : '' );
$instructorId = isset( $_GET['instructor_id'] ) ? absint( Val::int( $_GET['instructor_id'] ) ) : 0; $instructorId = isset( $_GET['instructor_id'] ) ? absint( $_GET['instructor_id'] ) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Recommended // phpcs:enable WordPress.Security.NonceVerification.Recommended
$instructorId = $this->scopeInstructor( $instructorId ); $instructorId = $this->scopeInstructor( $instructorId );
@@ -93,8 +92,7 @@ class PaymentReportController {
*/ */
private function buildReport( string $month, int $instructorId ): PaymentReport { private function buildReport( string $month, int $instructorId ): PaymentReport {
$start = $month . '-01 00:00:00'; $start = $month . '-01 00:00:00';
$endTs = strtotime( $month . '-01 00:00:00 +1 month' ); $end = gmdate( 'Y-m-d H:i:s', strtotime( $month . '-01 00:00:00 +1 month' ) );
$end = false === $endTs ? $start : gmdate( 'Y-m-d H:i:s', $endTs );
$rows = array_map( $rows = array_map(
static function ( Payment $payment ): array { static function ( Payment $payment ): array {
+13 -34
View File
@@ -55,8 +55,7 @@ class PaymentRepository {
public function findByStripeIntentId( string $intentId ): ?Payment { public function findByStripeIntentId( string $intentId ): ?Payment {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1', "SELECT * FROM {$this->table} WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1",
$this->table,
$intentId $intentId
) )
); );
@@ -78,15 +77,14 @@ class PaymentRepository {
* Set a payment's tax rate and recompute the tax amount from its subtotal. * Set a payment's tax rate and recompute the tax amount from its subtotal.
*/ */
public function updateTax( int $id, float $rate ): bool { public function updateTax( int $id, float $rate ): bool {
$sql = $this->db->prepare( return false !== $this->db->query(
'UPDATE %i SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d', $this->db->prepare(
$this->table, "UPDATE {$this->table} SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d",
$rate, $rate,
$rate, $rate,
$id $id
)
); );
return null !== $sql && false !== $this->db->query( $sql );
} }
/** /**
@@ -96,8 +94,8 @@ class PaymentRepository {
* @return list<Payment> * @return list<Payment>
*/ */
public function findPaidBetween( string $from, string $to, int $instructorId = 0 ): array { public function findPaidBetween( string $from, string $to, int $instructorId = 0 ): array {
$sql = 'SELECT * FROM %i WHERE status = %s AND paid_at >= %s AND paid_at < %s'; $sql = "SELECT * FROM {$this->table} WHERE status = %s AND paid_at >= %s AND paid_at < %s";
$params = [ $this->table, Payment::STATUS_PAID, $from, $to ]; $params = [ Payment::STATUS_PAID, $from, $to ];
if ( $instructorId > 0 ) { if ( $instructorId > 0 ) {
$sql .= ' AND instructor_id = %d'; $sql .= ' AND instructor_id = %d';
@@ -113,7 +111,7 @@ class PaymentRepository {
public function findById( int $id ): ?Payment { public function findById( int $id ): ?Payment {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
); );
return $row ? Payment::fromRow( $row ) : null; return $row ? Payment::fromRow( $row ) : null;
@@ -122,8 +120,7 @@ class PaymentRepository {
public function findByRegistration( string $registrationType, int $registrationId ): ?Payment { public function findByRegistration( string $registrationType, int $registrationId ): ?Payment {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id DESC LIMIT 1', "SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id DESC LIMIT 1",
$this->table,
$registrationType, $registrationType,
$registrationId $registrationId
) )
@@ -132,23 +129,6 @@ class PaymentRepository {
return $row ? Payment::fromRow( $row ) : null; return $row ? Payment::fromRow( $row ) : null;
} }
/**
* Every payment for a student, newest first (admin payment history).
*
* @return list<Payment>
*/
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d ORDER BY created_at DESC, id DESC',
$this->table,
$studentId
)
);
return array_map( Payment::fromRow( ... ), $rows ?? [] );
}
/** /**
* Pending payments, newest first (studio-admin confirmation queue). * Pending payments, newest first (studio-admin confirmation queue).
* *
@@ -157,8 +137,7 @@ class PaymentRepository {
public function findPending(): array { public function findPending(): array {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE status = %s ORDER BY created_at DESC', "SELECT * FROM {$this->table} WHERE status = %s ORDER BY created_at DESC",
$this->table,
Payment::STATUS_PENDING Payment::STATUS_PENDING
) )
); );
+3 -29
View File
@@ -89,22 +89,6 @@ class PaymentService {
return true; return true;
} }
/**
* Void the still-pending payment of a cancelled registration so it drops
* out of the confirmation queue. Paid payments are left alone refunds
* are a manual, admin-side decision.
*/
public function voidPending( ?int $paymentId ): void {
if ( null === $paymentId ) {
return;
}
$payment = $this->payments->findById( $paymentId );
if ( null !== $payment && Payment::STATUS_PENDING === $payment->status ) {
$this->payments->updateStatus( $paymentId, Payment::STATUS_FAILED );
}
}
/** /**
* Resolve the client-side payment step for a freshly created registration. * Resolve the client-side payment step for a freshly created registration.
* For a card payment a Stripe PaymentIntent is created (or replayed * For a card payment a Stripe PaymentIntent is created (or replayed
@@ -195,20 +179,10 @@ class PaymentService {
} }
private function confirmRegistration( string $type, int $registrationId ): void { private function confirmRegistration( string $type, int $registrationId ): void {
if ( Payment::REG_LESSON !== $type ) { if ( Payment::REG_LESSON === $type ) {
// Group enrolments are already `active`; no status change on payment. $this->bookings->updateStatus( $registrationId, Lesson::STATUS_CONFIRMED );
return;
} }
// Group enrolments are already `active`; no status change on payment.
// A weekly reservation's payment is linked to its anchor lesson but pays
// for the whole series, so settling it confirms every lesson in the series.
$lesson = $this->bookings->findById( $registrationId );
if ( null !== $lesson && null !== $lesson->seriesId ) {
$this->bookings->updateStatusForSeries( $lesson->seriesId, Lesson::STATUS_CONFIRMED );
return;
}
$this->bookings->updateStatus( $registrationId, Lesson::STATUS_CONFIRMED );
} }
private function linkPayment( string $type, int $registrationId, int $paymentId ): void { private function linkPayment( string $type, int $registrationId, int $paymentId ): void {
+2 -2
View File
@@ -67,8 +67,8 @@ class StripeGateway {
* Seam around the Stripe PaymentIntents create call so tests can stub the * Seam around the Stripe PaymentIntents create call so tests can stub the
* network request. * network request.
* *
* @param array{amount: int, currency: string, metadata: array<string, string>, description: string} $params * @param array<string, mixed> $params
* @param array{idempotency_key?: string} $options * @param array<string, mixed> $options
*/ */
protected function paymentIntentsCreate( array $params, array $options ): PaymentIntent { protected function paymentIntentsCreate( array $params, array $options ): PaymentIntent {
return $this->client()->paymentIntents->create( $params, $options ); return $this->client()->paymentIntents->create( $params, $options );
+13 -107
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment; namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class StudioSettings { class StudioSettings {
@@ -16,32 +15,12 @@ class StudioSettings {
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email'; public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
public const OPT_HST_RATE = 'us_hst_rate'; 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';
/**
* Snapshots of the two core WordPress options this feature takes over while
* open registration is enabled, so disabling restores them exactly rather
* than clobbering a site that set them for its own reasons.
*/
public const OPT_PREV_USERS_CAN_REGISTER = 'us_registration_prev_can_register';
public const OPT_PREV_DEFAULT_ROLE = 'us_registration_prev_default_role';
public function publishableKey(): string { public function publishableKey(): string {
return Val::string( get_option( self::OPT_PUBLISHABLE, '' ) ); return (string) get_option( self::OPT_PUBLISHABLE, '' );
} }
public function secretKey(): string { public function secretKey(): string {
return Val::string( get_option( self::OPT_SECRET, '' ) ); return (string) get_option( self::OPT_SECRET, '' );
} }
/** /**
@@ -49,7 +28,7 @@ class StudioSettings {
* webhook requests genuinely came from Stripe. Empty until configured. * webhook requests genuinely came from Stripe. Empty until configured.
*/ */
public function webhookSecret(): string { public function webhookSecret(): string {
return Val::string( get_option( self::OPT_WEBHOOK_SECRET, '' ) ); return (string) get_option( self::OPT_WEBHOOK_SECRET, '' );
} }
public function mode(): string { public function mode(): string {
@@ -57,7 +36,7 @@ class StudioSettings {
} }
public function currency(): string { public function currency(): string {
$currency = Val::string( get_option( self::OPT_CURRENCY, 'CAD' ) ); $currency = (string) get_option( self::OPT_CURRENCY, 'CAD' );
return '' !== $currency ? strtoupper( $currency ) : 'CAD'; return '' !== $currency ? strtoupper( $currency ) : 'CAD';
} }
@@ -67,23 +46,14 @@ class StudioSettings {
* no override). * no override).
*/ */
public function etransferEmail(): string { public function etransferEmail(): string {
return Val::string( get_option( self::OPT_ETRANSFER_EMAIL, '' ) ); return (string) get_option( self::OPT_ETRANSFER_EMAIL, '' );
} }
/** /**
* Default HST/tax rate as a percentage (e.g. 13.0). 0 means no tax. * Default HST/tax rate as a percentage (e.g. 13.0). 0 means no tax.
*/ */
public function hstRate(): float { public function hstRate(): float {
return max( 0.0, Val::float( get_option( self::OPT_HST_RATE, 0 ) ) ); return max( 0.0, (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 ) ) );
} }
/** /**
@@ -94,24 +64,6 @@ class StudioSettings {
return '' !== $this->publishableKey() && '' !== $this->secretKey(); return '' !== $this->publishableKey() && '' !== $this->secretKey();
} }
/**
* Which student registration mode is active: `invite` (default) only a
* valid invite token grants the registration form or `self_approval`
* anyone may sign up, confirm their email, and await studio approval.
*/
public function registrationMode(): string {
return self::MODE_SELF_APPROVAL === get_option( self::OPT_REGISTRATION_MODE, self::MODE_INVITE )
? self::MODE_SELF_APPROVAL
: self::MODE_INVITE;
}
/**
* Whether anyone may self-register (the `self_approval` mode).
*/
public function openRegistrationEnabled(): bool {
return self::MODE_SELF_APPROVAL === $this->registrationMode();
}
public function renderPage(): void { public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) { if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) {
wp_die( esc_html__( 'You do not have permission to manage billing settings.', 'unsupervised-schedular' ) ); wp_die( esc_html__( 'You do not have permission to manage billing settings.', 'unsupervised-schedular' ) );
@@ -133,9 +85,6 @@ class StudioSettings {
$etransferEmail = $this->etransferEmail(); $etransferEmail = $this->etransferEmail();
$hstRate = $this->hstRate(); $hstRate = $this->hstRate();
$stripeConfigured = $this->isStripeConfigured(); $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'; include USC_PLUGIN_DIR . 'templates/admin/settings.php';
} }
@@ -143,66 +92,23 @@ class StudioSettings {
private function save(): void { private function save(): void {
// Nonce is verified by the caller (renderPage) before this method runs. // Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$mode = sanitize_key( Val::string( wp_unslash( $_POST['mode'] ?? 'test' ) ) ); $mode = sanitize_key( wp_unslash( $_POST['mode'] ?? 'test' ) );
update_option( self::OPT_PUBLISHABLE, sanitize_text_field( Val::string( wp_unslash( $_POST['publishable_key'] ?? '' ) ) ) ); update_option( self::OPT_PUBLISHABLE, sanitize_text_field( wp_unslash( $_POST['publishable_key'] ?? '' ) ) );
// Secret fields are write-only: a blank submission keeps the stored secret, // Secret fields are write-only: a blank submission keeps the stored secret,
// so an admin saving other settings never wipes the keys. // so an admin saving other settings never wipes the keys.
$secretKey = sanitize_text_field( Val::string( wp_unslash( $_POST['secret_key'] ?? '' ) ) ); $secretKey = sanitize_text_field( wp_unslash( $_POST['secret_key'] ?? '' ) );
if ( '' !== $secretKey ) { if ( '' !== $secretKey ) {
update_option( self::OPT_SECRET, $secretKey ); update_option( self::OPT_SECRET, $secretKey );
} }
$webhookSecret = sanitize_text_field( Val::string( wp_unslash( $_POST['webhook_secret'] ?? '' ) ) ); $webhookSecret = sanitize_text_field( wp_unslash( $_POST['webhook_secret'] ?? '' ) );
if ( '' !== $webhookSecret ) { if ( '' !== $webhookSecret ) {
update_option( self::OPT_WEBHOOK_SECRET, $webhookSecret ); update_option( self::OPT_WEBHOOK_SECRET, $webhookSecret );
} }
update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' ); update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' );
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( Val::string( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) ) ); update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) );
update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ); update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion. $hstRate = isset( $_POST['hst_rate'] ) ? (float) $_POST['hst_rate'] : 0.0;
$hstRate = isset( $_POST['hst_rate'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0;
update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) ); 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 // phpcs:enable WordPress.Security.NonceVerification.Missing
} }
/**
* Enable or disable open (self-approval) registration, mirroring the change
* into the two core WordPress options it depends on.
*
* Enabling snapshots the current `users_can_register` and `default_role`,
* then turns registration on and makes Student the default new-user role.
* Disabling restores that snapshot, so this toggle never permanently
* overwrites a site's own membership settings. Only transitions act, so
* saving unrelated settings leaves the core options untouched.
*/
private function applyRegistrationMode( bool $enable ): void {
$currentlyOpen = $this->openRegistrationEnabled();
if ( $enable && ! $currentlyOpen ) {
update_option( self::OPT_PREV_USERS_CAN_REGISTER, get_option( 'users_can_register' ) ? '1' : '0' );
update_option( self::OPT_PREV_DEFAULT_ROLE, Val::string( get_option( 'default_role', 'subscriber' ) ) );
update_option( 'users_can_register', '1' );
update_option( 'default_role', RoleManager::STUDENT );
update_option( self::OPT_REGISTRATION_MODE, self::MODE_SELF_APPROVAL );
return;
}
if ( ! $enable && $currentlyOpen ) {
$prevCanRegister = '1' === Val::string( get_option( self::OPT_PREV_USERS_CAN_REGISTER, '0' ) );
$prevRole = Val::string( get_option( self::OPT_PREV_DEFAULT_ROLE, 'subscriber' ) );
update_option( 'users_can_register', $prevCanRegister ? '1' : '0' );
update_option( 'default_role', '' !== $prevRole ? $prevRole : 'subscriber' );
delete_option( self::OPT_PREV_USERS_CAN_REGISTER );
delete_option( self::OPT_PREV_DEFAULT_ROLE );
update_option( self::OPT_REGISTRATION_MODE, self::MODE_INVITE );
}
}
} }
+3 -36
View File
@@ -3,19 +3,11 @@ declare(strict_types=1);
namespace Unsupervised\Schedular; namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationLoginGate;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
@@ -30,7 +22,6 @@ use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\AnswerRepository; use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\QuestionRepository; use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Registration\RegistrationGate; use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Update\UpdateChecker;
class Plugin { class Plugin {
@@ -38,16 +29,6 @@ class Plugin {
load_plugin_textdomain( 'unsupervised-schedular', false, dirname( plugin_basename( USC_PLUGIN_FILE ) ) . '/languages' ); load_plugin_textdomain( 'unsupervised-schedular', false, dirname( plugin_basename( USC_PLUGIN_FILE ) ) . '/languages' );
global $wpdb; global $wpdb;
if ( ! $wpdb instanceof \wpdb ) {
return;
}
// Re-run install steps when the plugin files were updated without a fresh
// activation (e.g. a deploy), so schema and data migrations still apply.
if ( get_option( 'us_schedular_version' ) !== USC_VERSION ) {
( new Installer() )->run();
}
$availability = new AvailabilityRepository( $wpdb ); $availability = new AvailabilityRepository( $wpdb );
$bookings = new BookingRepository( $wpdb ); $bookings = new BookingRepository( $wpdb );
$offerings = new OfferingRepository( $wpdb ); $offerings = new OfferingRepository( $wpdb );
@@ -59,7 +40,6 @@ class Plugin {
$acceptances = new AcceptanceRepository( $wpdb ); $acceptances = new AcceptanceRepository( $wpdb );
$invites = new InviteRepository( $wpdb ); $invites = new InviteRepository( $wpdb );
$enrollments = new EnrollmentRepository( $wpdb ); $enrollments = new EnrollmentRepository( $wpdb );
$groupAccess = new GroupAccessRepository( $wpdb );
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances ); $registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
$paymentRepo = new PaymentRepository( $wpdb ); $paymentRepo = new PaymentRepository( $wpdb );
@@ -68,22 +48,9 @@ class Plugin {
$stripe = new StripeGateway( $settings ); $stripe = new StripeGateway( $settings );
$paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe ); $paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe );
// The shortcode and block wrappers share the same page objects so
// front-end output is identical whichever way a page embeds them.
$registrationMailer = new RegistrationMailer();
$bookingPage = new BookingPage();
$loginPage = new LoginPage();
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
$groupClassPage = new GroupClassPage();
( new UpdateChecker() )->register();
( new RoleManager() )->register(); ( new RoleManager() )->register();
( new RegistrationLoginGate() )->register(); ( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->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 ShortcodeRegistrar( $invites, $policies, $policyVersions, $acceptances ) )->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();
} }
} }
+1 -19
View File
@@ -46,8 +46,7 @@ class AcceptanceRepository {
public function findByRegistration( string $registrationType, int $registrationId ): array { public function findByRegistration( string $registrationType, int $registrationId ): array {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC', "SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC",
$this->table,
$registrationType, $registrationType,
$registrationId $registrationId
) )
@@ -55,21 +54,4 @@ class AcceptanceRepository {
return array_map( PolicyAcceptance::fromRow( ... ), $rows ?? [] ); return array_map( PolicyAcceptance::fromRow( ... ), $rows ?? [] );
} }
/**
* Find every acceptance a student has recorded, newest first.
*
* @return list<PolicyAcceptance>
*/
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d ORDER BY accepted_at DESC, id DESC',
$this->table,
$studentId
)
);
return array_map( PolicyAcceptance::fromRow( ... ), $rows ?? [] );
}
} }
+6 -8
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy; namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Val;
class Policy { class Policy {
public const SCOPE_SIGNUP = 'signup'; public const SCOPE_SIGNUP = 'signup';
@@ -26,13 +24,13 @@ class Policy {
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
public static function fromRow( \stdClass $row ): self { public static function fromRow( object $row ): self {
return new self( return new self(
title: Val::string( $row->title ), title: $row->title,
slug: Val::string( $row->slug ), slug: $row->slug,
currentVersionId: Val::intOrNull( $row->current_version_id ), currentVersionId: null !== $row->current_version_id ? (int) $row->current_version_id : null,
acceptanceScope: Val::string( $row->acceptance_scope ), acceptanceScope: $row->acceptance_scope,
id: Val::int( $row->id ), id: (int) $row->id,
); );
} }
+8 -10
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy; namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Val;
class PolicyAcceptance { class PolicyAcceptance {
public const REG_ACCOUNT = 'account'; public const REG_ACCOUNT = 'account';
@@ -29,15 +27,15 @@ class PolicyAcceptance {
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
public static function fromRow( \stdClass $row ): self { public static function fromRow( object $row ): self {
return new self( return new self(
policyVersionId: Val::int( $row->policy_version_id ), policyVersionId: (int) $row->policy_version_id,
studentId: Val::int( $row->student_id ), studentId: (int) $row->student_id,
registrationType: Val::string( $row->registration_type ), registrationType: $row->registration_type,
registrationId: Val::int( $row->registration_id ), registrationId: (int) $row->registration_id,
ipAddress: Val::stringOrNull( $row->ip_address ), ipAddress: $row->ip_address,
acceptedAt: Val::stringOrNull( $row->accepted_at ), acceptedAt: $row->accepted_at,
id: Val::int( $row->id ), id: (int) $row->id,
); );
} }
+8 -9
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy; namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PolicyController { class PolicyController {
@@ -24,7 +23,7 @@ class PolicyController {
} }
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only policy selector. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only policy selector.
$policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) ); $policyId = absint( $_GET['policy_id'] ?? 0 );
$policyList = $this->policies->findAll(); $policyList = $this->policies->findAll();
$selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null; $selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null;
$policyVersions = null !== $selectedPolicy ? $this->versions->findByPolicy( (int) $selectedPolicy->id ) : null; $policyVersions = null !== $selectedPolicy ? $this->versions->findByPolicy( (int) $selectedPolicy->id ) : null;
@@ -35,13 +34,13 @@ class PolicyController {
private function handleFormAction(): void { private function handleFormAction(): void {
// Nonce is verified by the caller (renderPage) before this method runs. // Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
if ( 'create_policy' === $action ) { if ( 'create_policy' === $action ) {
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) ); $title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) );
$slugRaw = sanitize_text_field( Val::string( wp_unslash( $_POST['slug'] ?? '' ) ) ); $slugRaw = sanitize_text_field( wp_unslash( $_POST['slug'] ?? '' ) );
$slug = sanitize_title( '' !== $slugRaw ? $slugRaw : $title ); $slug = sanitize_title( '' !== $slugRaw ? $slugRaw : $title );
$scope = sanitize_key( Val::string( wp_unslash( $_POST['acceptance_scope'] ?? Policy::SCOPE_BOOKING ) ) ); $scope = sanitize_key( wp_unslash( $_POST['acceptance_scope'] ?? Policy::SCOPE_BOOKING ) );
if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) { if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) {
$scope = Policy::SCOPE_BOOKING; $scope = Policy::SCOPE_BOOKING;
@@ -54,18 +53,18 @@ class PolicyController {
return; return;
} }
$policyId = absint( Val::int( $_POST['policy_id'] ?? 0 ) ); $policyId = absint( $_POST['policy_id'] ?? 0 );
if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) { if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) {
return; return;
} }
if ( 'add_version' === $action ) { if ( 'add_version' === $action ) {
$body = wp_kses_post( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) ); $body = wp_kses_post( wp_unslash( $_POST['body'] ?? '' ) );
$this->service->addDraftVersion( $policyId, $body ); $this->service->addDraftVersion( $policyId, $body );
} }
if ( 'publish_version' === $action ) { if ( 'publish_version' === $action ) {
$versionId = absint( Val::int( $_POST['version_id'] ?? 0 ) ); $versionId = absint( $_POST['version_id'] ?? 0 );
if ( $versionId > 0 ) { if ( $versionId > 0 ) {
$this->service->publishVersion( $policyId, $versionId ); $this->service->publishVersion( $policyId, $versionId );
} }
+11 -20
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy; namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PolicyEndpoint { class PolicyEndpoint {
@@ -14,11 +13,6 @@ class PolicyEndpoint {
private PolicyService $service, private PolicyService $service,
) {} ) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void { public function registerRoutes( string $route_namespace ): void {
register_rest_route( register_rest_route(
$route_namespace, $route_namespace,
@@ -80,7 +74,7 @@ class PolicyEndpoint {
* `both`-scoped policies). * `both`-scoped policies).
*/ */
public function index( \WP_REST_Request $request ): \WP_REST_Response { public function index( \WP_REST_Request $request ): \WP_REST_Response {
$scope = Val::string( $request->get_param( 'scope' ) ); $scope = (string) $request->get_param( 'scope' );
$policies = in_array( $scope, [ Policy::SCOPE_SIGNUP, Policy::SCOPE_BOOKING ], true ) $policies = in_array( $scope, [ Policy::SCOPE_SIGNUP, Policy::SCOPE_BOOKING ], true )
? $this->policies->findForScope( $scope ) ? $this->policies->findForScope( $scope )
: $this->policies->findAll(); : $this->policies->findAll();
@@ -103,10 +97,7 @@ class PolicyEndpoint {
'slug' => $policy->slug, 'slug' => $policy->slug,
'policy_version_id' => $version->id, 'policy_version_id' => $version->id,
'version_number' => $version->versionNumber, 'version_number' => $version->versionNumber,
// Bodies are kses'd on every write path, but the booking JS renders 'body' => $version->body,
// this HTML raw — sanitise at output too so a missed write path can
// never become stored XSS.
'body' => wp_kses_post( (string) $version->body ),
]; ];
} }
@@ -114,12 +105,12 @@ class PolicyEndpoint {
} }
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ); $title = sanitize_text_field( (string) $request->get_param( 'title' ) );
if ( '' === $title ) { if ( '' === $title ) {
return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) );
} }
$slugParam = sanitize_text_field( Val::string( $request->get_param( 'slug' ) ) ); $slugParam = sanitize_text_field( (string) $request->get_param( 'slug' ) );
$slug = sanitize_title( '' !== $slugParam ? $slugParam : $title ); $slug = sanitize_title( '' !== $slugParam ? $slugParam : $title );
if ( '' === $slug ) { if ( '' === $slug ) {
return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) );
@@ -129,7 +120,7 @@ class PolicyEndpoint {
return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
} }
$scope = Val::string( $request->get_param( 'acceptance_scope' ) ?? Policy::SCOPE_BOOKING ); $scope = (string) ( $request->get_param( 'acceptance_scope' ) ?? Policy::SCOPE_BOOKING );
if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) { if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) {
return $this->invalid( __( 'Invalid acceptance scope.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'Invalid acceptance scope.', 'unsupervised-schedular' ) );
} }
@@ -140,12 +131,12 @@ class PolicyEndpoint {
} }
public function addVersion( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function addVersion( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$policy = $this->policies->findById( absint( Val::int( $request->get_param( 'id' ) ) ) ); $policy = $this->policies->findById( absint( $request->get_param( 'id' ) ) );
if ( null === $policy ) { if ( null === $policy ) {
return $this->notFound(); return $this->notFound();
} }
$body = wp_kses_post( Val::string( $request->get_param( 'body' ) ) ); $body = wp_kses_post( (string) $request->get_param( 'body' ) );
$id = $this->service->addDraftVersion( (int) $policy->id, $body ); $id = $this->service->addDraftVersion( (int) $policy->id, $body );
return new \WP_REST_Response( [ 'id' => $id ], 201 ); return new \WP_REST_Response( [ 'id' => $id ], 201 );
@@ -161,7 +152,7 @@ class PolicyEndpoint {
return $this->invalid( __( 'Only draft versions can be edited.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'Only draft versions can be edited.', 'unsupervised-schedular' ) );
} }
$body = wp_kses_post( Val::string( $request->get_param( 'body' ) ) ); $body = wp_kses_post( (string) $request->get_param( 'body' ) );
$this->versions->updateBody( (int) $version->id, $body ); $this->versions->updateBody( (int) $version->id, $body );
return new \WP_REST_Response( return new \WP_REST_Response(
@@ -179,7 +170,7 @@ class PolicyEndpoint {
return $version; return $version;
} }
$this->service->publishVersion( Val::int( $request->get_param( 'id' ) ), (int) $version->id ); $this->service->publishVersion( (int) $request->get_param( 'id' ), (int) $version->id );
return new \WP_REST_Response( return new \WP_REST_Response(
[ [
@@ -208,8 +199,8 @@ class PolicyEndpoint {
* Load the version named in the route and confirm it belongs to the policy. * Load the version named in the route and confirm it belongs to the policy.
*/ */
private function loadVersionForPolicy( \WP_REST_Request $request ): PolicyVersion|\WP_Error { private function loadVersionForPolicy( \WP_REST_Request $request ): PolicyVersion|\WP_Error {
$policyId = absint( Val::int( $request->get_param( 'id' ) ) ); $policyId = absint( $request->get_param( 'id' ) );
$version = $this->versions->findById( absint( Val::int( $request->get_param( 'vid' ) ) ) ); $version = $this->versions->findById( absint( $request->get_param( 'vid' ) ) );
if ( null === $version || $version->policyId !== $policyId ) { if ( null === $version || $version->policyId !== $policyId ) {
return $this->notFound(); return $this->notFound();
+3 -4
View File
@@ -36,8 +36,7 @@ class PolicyRepository {
public function findForScope( string $scope ): array { public function findForScope( string $scope ): array {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE acceptance_scope = %s OR acceptance_scope = %s ORDER BY title ASC', "SELECT * FROM {$this->table} WHERE acceptance_scope = %s OR acceptance_scope = %s ORDER BY title ASC",
$this->table,
$scope, $scope,
Policy::SCOPE_BOTH Policy::SCOPE_BOTH
) )
@@ -69,7 +68,7 @@ class PolicyRepository {
public function findById( int $id ): ?Policy { public function findById( int $id ): ?Policy {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
); );
return $row ? Policy::fromRow( $row ) : null; return $row ? Policy::fromRow( $row ) : null;
@@ -77,7 +76,7 @@ class PolicyRepository {
public function findBySlug( string $slug ): ?Policy { public function findBySlug( string $slug ): ?Policy {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE slug = %s', $this->table, $slug ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE slug = %s", $slug )
); );
return $row ? Policy::fromRow( $row ) : null; return $row ? Policy::fromRow( $row ) : null;
+7 -9
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy; namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Val;
class PolicyVersion { class PolicyVersion {
public const STATUS_DRAFT = 'draft'; public const STATUS_DRAFT = 'draft';
@@ -27,14 +25,14 @@ class PolicyVersion {
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
public static function fromRow( \stdClass $row ): self { public static function fromRow( object $row ): self {
return new self( return new self(
policyId: Val::int( $row->policy_id ), policyId: (int) $row->policy_id,
versionNumber: Val::int( $row->version_number ), versionNumber: (int) $row->version_number,
body: Val::stringOrNull( $row->body ), body: $row->body,
status: Val::string( $row->status ), status: $row->status,
publishedAt: Val::stringOrNull( $row->published_at ), publishedAt: $row->published_at,
id: Val::int( $row->id ), id: (int) $row->id,
); );
} }
+3 -4
View File
@@ -59,8 +59,7 @@ class PolicyVersionRepository {
public function findByPolicy( int $policyId ): array { public function findByPolicy( int $policyId ): array {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE policy_id = %d ORDER BY version_number DESC', "SELECT * FROM {$this->table} WHERE policy_id = %d ORDER BY version_number DESC",
$this->table,
$policyId $policyId
) )
); );
@@ -70,7 +69,7 @@ class PolicyVersionRepository {
public function findById( int $id ): ?PolicyVersion { public function findById( int $id ): ?PolicyVersion {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
); );
return $row ? PolicyVersion::fromRow( $row ) : null; return $row ? PolicyVersion::fromRow( $row ) : null;
@@ -81,7 +80,7 @@ class PolicyVersionRepository {
*/ */
public function maxVersionNumber( int $policyId ): int { public function maxVersionNumber( int $policyId ): int {
$max = $this->db->get_var( $max = $this->db->get_var(
$this->db->prepare( 'SELECT MAX(version_number) FROM %i WHERE policy_id = %d', $this->table, $policyId ) $this->db->prepare( "SELECT MAX(version_number) FROM {$this->table} WHERE policy_id = %d", $policyId )
); );
return null === $max ? 0 : (int) $max; return null === $max ? 0 : (int) $max;
+9 -13
View File
@@ -3,21 +3,17 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Registration; namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Val;
class Answer { class Answer {
public const REG_LESSON = 'lesson'; public const REG_LESSON = 'lesson';
public const REG_ENROLLMENT = 'enrollment'; public const REG_ENROLLMENT = 'enrollment';
public const REG_ACCOUNT = 'account';
/** /**
* Polymorphic registration targets an answer can attach to. `account` is used * Polymorphic registration targets an answer can attach to.
* by studio-wide questions answered at signup (registration_id = the user ID).
* *
* @var list<string> * @var list<string>
*/ */
public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT, self::REG_ACCOUNT ]; public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT ];
public function __construct( public function __construct(
public readonly int $questionId, public readonly int $questionId,
@@ -28,14 +24,14 @@ class Answer {
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
public static function fromRow( \stdClass $row ): self { public static function fromRow( object $row ): self {
return new self( return new self(
questionId: Val::int( $row->question_id ), questionId: (int) $row->question_id,
registrationType: Val::string( $row->registration_type ), registrationType: $row->registration_type,
registrationId: Val::int( $row->registration_id ), registrationId: (int) $row->registration_id,
studentId: Val::int( $row->student_id ), studentId: (int) $row->student_id,
answerValue: Val::stringOrNull( $row->answer_value ), answerValue: $row->answer_value,
id: Val::int( $row->id ), id: (int) $row->id,
); );
} }
+1 -19
View File
@@ -46,8 +46,7 @@ class AnswerRepository {
public function findByRegistration( string $registrationType, int $registrationId ): array { public function findByRegistration( string $registrationType, int $registrationId ): array {
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC', "SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC",
$this->table,
$registrationType, $registrationType,
$registrationId $registrationId
) )
@@ -55,21 +54,4 @@ class AnswerRepository {
return array_map( Answer::fromRow( ... ), $rows ?? [] ); return array_map( Answer::fromRow( ... ), $rows ?? [] );
} }
/**
* Find every answer a student has submitted, newest registration first.
*
* @return list<Answer>
*/
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d ORDER BY id DESC',
$this->table,
$studentId
)
);
return array_map( Answer::fromRow( ... ), $rows ?? [] );
}
} }
+12 -36
View File
@@ -3,8 +3,6 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Registration; namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Val;
class Question { class Question {
public const FIELD_TEXT = 'text'; public const FIELD_TEXT = 'text';
@@ -12,12 +10,6 @@ class Question {
public const FIELD_SELECT = 'select'; public const FIELD_SELECT = 'select';
public const FIELD_CHECKBOX = 'checkbox'; 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. * All valid field types.
* *
@@ -30,53 +22,38 @@ class Question {
self::FIELD_CHECKBOX, 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. * 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.
* @param list<string>|null $options Choices for a `select` field.
*/ */
public function __construct( public function __construct(
public readonly ?int $offeringId, public readonly int $offeringId,
public readonly string $label, public readonly string $label,
public readonly string $fieldType = self::FIELD_TEXT, public readonly string $fieldType = self::FIELD_TEXT,
public readonly ?array $options = null, public readonly ?array $options = null,
public readonly bool $isRequired = false, public readonly bool $isRequired = false,
public readonly int $sortOrder = 0, public readonly int $sortOrder = 0,
public readonly bool $isActive = true, public readonly bool $isActive = true,
public readonly string $scope = self::SCOPE_OFFERING,
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
public static function fromRow( \stdClass $row ): self { public static function fromRow( object $row ): self {
$options = null; $options = null;
if ( null !== $row->options && '' !== $row->options ) { if ( null !== $row->options && '' !== $row->options ) {
$decoded = json_decode( Val::string( $row->options ), true ); $decoded = json_decode( (string) $row->options, true );
$options = is_array( $decoded ) $options = is_array( $decoded ) ? array_values( array_map( 'strval', $decoded ) ) : null;
? array_values( array_map( static fn( mixed $v ): string => Val::string( $v ), $decoded ) )
: null;
} }
return new self( return new self(
offeringId: Val::intOrNull( $row->offering_id ), offeringId: (int) $row->offering_id,
label: Val::string( $row->label ), label: $row->label,
fieldType: Val::string( $row->field_type ), fieldType: $row->field_type,
options: $options, options: $options,
isRequired: Val::bool( $row->is_required ), isRequired: (bool) $row->is_required,
sortOrder: Val::int( $row->sort_order ), sortOrder: (int) $row->sort_order,
isActive: Val::bool( $row->is_active ), isActive: (bool) $row->is_active,
scope: Val::string( $row->scope ), id: (int) $row->id,
id: Val::int( $row->id ),
); );
} }
@@ -89,7 +66,6 @@ class Question {
return [ return [
'id' => $this->id, 'id' => $this->id,
'offering_id' => $this->offeringId, 'offering_id' => $this->offeringId,
'scope' => $this->scope,
'label' => $this->label, 'label' => $this->label,
'field_type' => $this->fieldType, 'field_type' => $this->fieldType,
'options' => $this->options, 'options' => $this->options,
+14 -43
View File
@@ -6,7 +6,6 @@ namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
class QuestionController { class QuestionController {
@@ -23,13 +22,8 @@ class QuestionController {
$userId = get_current_user_id(); $userId = get_current_user_id();
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS ); $manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
// The selector posts either an offering id or the sentinel `account`. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only offering selector.
// Account-signup questions are studio-wide, so only studio admins manage them. $offeringId = absint( $_GET['offering_id'] ?? 0 );
// 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 ); $offeringList = $manageAll ? $this->offerings->findAll() : $this->offerings->findAll( $userId );
$selectedOffering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null; $selectedOffering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
@@ -38,13 +32,7 @@ class QuestionController {
} }
$questions = null; $questions = null;
if ( $accountScope ) { if ( null !== $selectedOffering ) {
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' ) ) { if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_question_action' ) ) {
$this->handleFormAction( $selectedOffering ); $this->handleFormAction( $selectedOffering );
} }
@@ -55,24 +43,20 @@ class QuestionController {
include USC_PLUGIN_DIR . 'templates/admin/questions.php'; 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. // Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
if ( 'add' === $action ) { if ( 'add' === $action ) {
$this->addQuestion( $offering ); $this->addQuestion( (int) $offering->id );
} }
if ( 'delete' === $action ) { if ( 'delete' === $action ) {
$questionId = absint( Val::int( $_POST['question_id'] ?? 0 ) ); $questionId = absint( $_POST['question_id'] ?? 0 );
if ( $questionId > 0 ) { if ( $questionId > 0 ) {
$question = $this->questions->findById( $questionId ); $question = $this->questions->findById( $questionId );
if ( $question && $this->belongsToContext( $question, $offering ) ) { if ( $question && $question->offeringId === (int) $offering->id ) {
$this->questions->delete( $questionId ); $this->questions->delete( $questionId );
} }
} }
@@ -80,10 +64,10 @@ class QuestionController {
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
} }
private function addQuestion( ?Offering $offering ): void { private function addQuestion( int $offeringId ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) ); $label = sanitize_text_field( wp_unslash( $_POST['label'] ?? '' ) );
$fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) ); $fieldType = sanitize_key( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) );
if ( '' === $label || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) { if ( '' === $label || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
return; return;
@@ -91,30 +75,17 @@ class QuestionController {
$this->questions->insert( $this->questions->insert(
new Question( new Question(
offeringId: null === $offering ? null : (int) $offering->id, offeringId: $offeringId,
label: $label, label: $label,
fieldType: $fieldType, fieldType: $fieldType,
options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ), options: $this->parseOptions( sanitize_textarea_field( wp_unslash( $_POST['options'] ?? '' ) ) ),
isRequired: isset( $_POST['is_required'] ), isRequired: isset( $_POST['is_required'] ),
sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ), sortOrder: absint( $_POST['sort_order'] ?? 0 ),
scope: null === $offering ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING,
) )
); );
// phpcs:enable WordPress.Security.NonceVerification.Missing // 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 { private function canManageOffering( Offering $offering, int $userId, bool $manageAll ): bool {
return $manageAll || $offering->instructorId === $userId; return $manageAll || $offering->instructorId === $userId;
} }
+12 -25
View File
@@ -5,7 +5,6 @@ namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
class QuestionEndpoint { class QuestionEndpoint {
@@ -14,11 +13,6 @@ class QuestionEndpoint {
private OfferingRepository $offerings, private OfferingRepository $offerings,
) {} ) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void { public function registerRoutes( string $route_namespace ): void {
register_rest_route( register_rest_route(
$route_namespace, $route_namespace,
@@ -63,24 +57,24 @@ class QuestionEndpoint {
} }
public function index( \WP_REST_Request $request ): \WP_REST_Response { public function index( \WP_REST_Request $request ): \WP_REST_Response {
$questions = $this->questions->findByOffering( absint( Val::int( $request->get_param( 'id' ) ) ), activeOnly: true ); $questions = $this->questions->findByOffering( absint( $request->get_param( 'id' ) ), activeOnly: true );
return new \WP_REST_Response( array_map( fn( Question $q ) => $q->toArray(), $questions ), 200 ); return new \WP_REST_Response( array_map( fn( Question $q ) => $q->toArray(), $questions ), 200 );
} }
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) ); $offeringId = absint( $request->get_param( 'offering_id' ) );
$ownerCheck = $this->requireOfferingOwner( $offeringId ); $ownerCheck = $this->requireOfferingOwner( $offeringId );
if ( $ownerCheck instanceof \WP_Error ) { if ( $ownerCheck instanceof \WP_Error ) {
return $ownerCheck; return $ownerCheck;
} }
$label = sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ); $label = sanitize_text_field( (string) $request->get_param( 'label' ) );
if ( '' === $label ) { if ( '' === $label ) {
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
} }
$fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT ); $fieldType = (string) ( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) { if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
} }
@@ -91,7 +85,7 @@ class QuestionEndpoint {
fieldType: $fieldType, fieldType: $fieldType,
options: $this->sanitizeOptions( $request->get_param( 'options' ) ), options: $this->sanitizeOptions( $request->get_param( 'options' ) ),
isRequired: (bool) $request->get_param( 'is_required' ), isRequired: (bool) $request->get_param( 'is_required' ),
sortOrder: Val::int( $request->get_param( 'sort_order' ) ), sortOrder: (int) $request->get_param( 'sort_order' ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ), isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
); );
@@ -101,7 +95,7 @@ class QuestionEndpoint {
} }
public function update( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function update( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) ); $id = absint( $request->get_param( 'id' ) );
$existing = $this->questions->findById( $id ); $existing = $this->questions->findById( $id );
if ( null === $existing ) { if ( null === $existing ) {
@@ -113,20 +107,19 @@ class QuestionEndpoint {
return $ownerCheck; return $ownerCheck;
} }
$fieldType = $request->has_param( 'field_type' ) ? Val::string( $request->get_param( 'field_type' ) ) : $existing->fieldType; $fieldType = $request->has_param( 'field_type' ) ? (string) $request->get_param( 'field_type' ) : $existing->fieldType;
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) { if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) ); return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
} }
$question = new Question( $question = new Question(
offeringId: $existing->offeringId, offeringId: $existing->offeringId,
label: $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label, label: $request->has_param( 'label' ) ? sanitize_text_field( (string) $request->get_param( 'label' ) ) : $existing->label,
fieldType: $fieldType, fieldType: $fieldType,
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options, options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired, isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired,
sortOrder: $request->has_param( 'sort_order' ) ? Val::int( $request->get_param( 'sort_order' ) ) : $existing->sortOrder, sortOrder: $request->has_param( 'sort_order' ) ? (int) $request->get_param( 'sort_order' ) : $existing->sortOrder,
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive, isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
scope: $existing->scope,
id: $id, id: $id,
); );
@@ -136,7 +129,7 @@ class QuestionEndpoint {
} }
public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) ); $id = absint( $request->get_param( 'id' ) );
$existing = $this->questions->findById( $id ); $existing = $this->questions->findById( $id );
if ( null === $existing ) { if ( null === $existing ) {
@@ -168,14 +161,8 @@ class QuestionEndpoint {
/** /**
* Ensure the offering exists and the caller owns it (or is a studio admin). * 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 ); $offering = $this->offerings->findById( $offeringId );
if ( null === $offering ) { if ( null === $offering ) {
@@ -205,7 +192,7 @@ class QuestionEndpoint {
$options = array_values( $options = array_values(
array_filter( array_filter(
array_map( array_map(
static fn( mixed $option ): string => sanitize_text_field( Val::string( $option ) ), static fn( $option ): string => sanitize_text_field( (string) $option ),
$value $value
) )
) )
+5 -27
View File
@@ -15,7 +15,7 @@ class QuestionRepository {
$this->db->insert( $this->db->insert(
$this->table, $this->table,
$this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ], $this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ],
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ] [ '%d', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ]
); );
return $this->db->insert_id; return $this->db->insert_id;
@@ -26,7 +26,7 @@ class QuestionRepository {
$this->table, $this->table,
$this->columns( $question ), $this->columns( $question ),
[ 'id' => $id ], [ 'id' => $id ],
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d' ], [ '%d', '%s', '%s', '%s', '%d', '%d', '%d' ],
[ '%d' ] [ '%d' ]
); );
} }
@@ -39,7 +39,6 @@ class QuestionRepository {
private function columns( Question $question ): array { private function columns( Question $question ): array {
return [ return [
'offering_id' => $question->offeringId, 'offering_id' => $question->offeringId,
'scope' => $question->scope,
'label' => $question->label, 'label' => $question->label,
'field_type' => $question->fieldType, 'field_type' => $question->fieldType,
'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ), 'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ),
@@ -55,29 +54,8 @@ class QuestionRepository {
* @return list<Question> * @return list<Question>
*/ */
public function findByOffering( int $offeringId, bool $activeOnly = false ): array { public function findByOffering( int $offeringId, bool $activeOnly = false ): array {
$sql = 'SELECT * FROM %i WHERE offering_id = %d'; $sql = "SELECT * FROM {$this->table} WHERE offering_id = %d";
$params = [ $this->table, $offeringId ]; $params = [ $offeringId ];
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 ?? [] );
}
/**
* 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 ) { if ( $activeOnly ) {
$sql .= ' AND is_active = %d'; $sql .= ' AND is_active = %d';
@@ -93,7 +71,7 @@ class QuestionRepository {
public function findById( int $id ): ?Question { public function findById( int $id ): ?Question {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
); );
return $row ? Question::fromRow( $row ) : null; return $row ? Question::fromRow( $row ) : null;
+4 -7
View File
@@ -7,15 +7,12 @@ use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingEndpoint; use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint; use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\OfferingEndpoint; use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\PaymentEndpoint; use Unsupervised\Schedular\Payment\PaymentEndpoint;
use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\PolicyEndpoint; use Unsupervised\Schedular\Policy\PolicyEndpoint;
use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyService; use Unsupervised\Schedular\Policy\PolicyService;
@@ -36,13 +33,13 @@ class RestRegistrar {
private EnrollmentEndpoint $enrollmentEndpoint; private EnrollmentEndpoint $enrollmentEndpoint;
private PaymentEndpoint $paymentEndpoint; private PaymentEndpoint $paymentEndpoint;
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 ) { public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, PaymentService $paymentService ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, $offerings ); $this->availabilityEndpoint = new AvailabilityEndpoint( $availability, $offerings );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) ); $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService );
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess ); $this->offeringEndpoint = new OfferingEndpoint( $offerings );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings ); $this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService ); $this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess ); $this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService );
$this->paymentEndpoint = new PaymentEndpoint( $paymentService ); $this->paymentEndpoint = new PaymentEndpoint( $paymentService );
} }
+1 -25
View File
@@ -63,11 +63,8 @@ class Schema {
capacity SMALLINT UNSIGNED DEFAULT NULL, capacity SMALLINT UNSIGNED DEFAULT NULL,
term_start DATE DEFAULT NULL, term_start DATE DEFAULT NULL,
term_end DATE DEFAULT NULL, term_end DATE DEFAULT NULL,
class_time TIME DEFAULT NULL,
schedule_note VARCHAR(191) DEFAULT NULL, schedule_note VARCHAR(191) DEFAULT NULL,
etransfer_email 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, is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
@@ -78,8 +75,7 @@ class Schema {
"CREATE TABLE {$prefix}us_questions ( "CREATE TABLE {$prefix}us_questions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
offering_id BIGINT UNSIGNED DEFAULT NULL, offering_id BIGINT UNSIGNED NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'offering',
label VARCHAR(255) NOT NULL, label VARCHAR(255) NOT NULL,
field_type VARCHAR(20) NOT NULL DEFAULT 'text', field_type VARCHAR(20) NOT NULL DEFAULT 'text',
options TEXT, options TEXT,
@@ -89,7 +85,6 @@ class Schema {
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
KEY offering_id (offering_id), KEY offering_id (offering_id),
KEY scope (scope),
KEY is_active (is_active) KEY is_active (is_active)
) {$charset};", ) {$charset};",
@@ -191,35 +186,16 @@ class Schema {
email VARCHAR(191) NOT NULL, email VARCHAR(191) NOT NULL,
token VARCHAR(64) NOT NULL, token VARCHAR(64) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'us_student', 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', status VARCHAR(20) NOT NULL DEFAULT 'pending',
invited_by BIGINT UNSIGNED DEFAULT NULL, invited_by BIGINT UNSIGNED DEFAULT NULL,
accepted_user_id BIGINT UNSIGNED DEFAULT NULL, accepted_user_id BIGINT UNSIGNED DEFAULT NULL,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
accepted_at DATETIME DEFAULT NULL, accepted_at DATETIME DEFAULT NULL,
expires_at DATETIME DEFAULT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY token (token), UNIQUE KEY token (token),
KEY email (email), KEY email (email),
KEY status (status) KEY status (status)
) {$charset};", ) {$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};",
]; ];
} }
} }
+28 -30
View File
@@ -3,40 +3,42 @@ declare(strict_types=1);
namespace Unsupervised\Schedular; namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage; use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
class ShortcodeRegistrar { class ShortcodeRegistrar {
public function __construct( private BookingPage $bookingPage;
private BookingPage $bookingPage, private LoginPage $loginPage;
private LoginPage $loginPage, private RegistrationPage $registrationPage;
private RegistrationPage $registrationPage, private GroupClassPage $groupClassPage;
private GroupClassPage $groupClassPage,
) {}
public function register(): void { public function __construct(
add_shortcode( 'us_booking', self::shortcode( [ $this->bookingPage, 'render' ] ) ); InviteRepository $invites,
add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) ); PolicyRepository $policies,
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) ); PolicyVersionRepository $policyVersions,
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) ); AcceptanceRepository $acceptances,
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] ); ) {
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] ); $this->bookingPage = new BookingPage();
$this->loginPage = new LoginPage();
$this->registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances );
$this->groupClassPage = new GroupClassPage();
} }
/** public function register(): void {
* Wraps a page renderer so bare shortcode usage is safe: WordPress passes add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] );
* an empty string, not an array, to the callback when a shortcode is used add_shortcode( 'us_student_login', [ $this->loginPage, 'render' ] );
* without attributes (`shortcode_parse_atts( '' )` returns `''`). add_shortcode( 'us_student_register', [ $this->registrationPage, 'render' ] );
* add_shortcode( 'us_group_classes', [ $this->groupClassPage, 'render' ] );
* @param callable(array<int|string, mixed>): string $render add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
* @return \Closure(mixed): string add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
*/
private static function shortcode( callable $render ): \Closure {
return static fn( mixed $atts ): string => $render( is_array( $atts ) ? $atts : [] );
} }
public function enqueueAssets(): void { public function enqueueAssets(): void {
@@ -57,10 +59,9 @@ class ShortcodeRegistrar {
wp_register_script( 'us-scheduler-payment', USC_PLUGIN_URL . 'assets/js/payment.js', $paymentDeps, USC_VERSION, true ); wp_register_script( 'us-scheduler-payment', USC_PLUGIN_URL . 'assets/js/payment.js', $paymentDeps, USC_VERSION, true );
$data = [ $data = [
'restUrl' => rest_url( 'us-scheduler/v1/' ), 'restUrl' => rest_url( 'us-scheduler/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ), 'nonce' => wp_create_nonce( 'wp_rest' ),
'stripeKey' => $settings->publishableKey(), 'stripeKey' => $settings->publishableKey(),
'startOfWeek' => Val::int( get_option( 'start_of_week', 1 ) ),
]; ];
// Attach the shared config to the payment helper so it is defined before the // Attach the shared config to the payment helper so it is defined before the
@@ -69,8 +70,5 @@ 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', 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 ); 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 );
} }
} }
-183
View File
@@ -1,183 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Update;
use Unsupervised\Schedular\Val;
/**
* Serves plugin updates from the Gitea repository's releases.
*
* Core reads the plugin's `Update URI` header and, during every update
* check, fires the `update_plugins_{hostname}` filter for that host. This
* class answers the filter by fetching the latest published release from
* the Gitea API and returning its zip asset when it is newer than the
* installed version. Everything downstream the Plugins-screen notice,
* one-click updates, and opt-in auto-updates is handled by core.
*
* Drafts and releases marked "pre-release" in Gitea are never offered:
* the `/releases/latest` endpoint excludes both.
*/
class UpdateChecker {
public const HOSTNAME = 'git.unsupervised.ca';
public const REPO_URL = 'https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler';
public const API_URL = 'https://git.unsupervised.ca/api/v1/repos/Unsupervised/unsupervised-scheduler/releases/latest';
public const TRANSIENT = 'us_schedular_latest_release';
/**
* How long a release lookup (including a failed one) is cached. Core
* runs update checks on admin page loads as well as twice-daily cron,
* so the cache keeps the plugin from hammering the Gitea API.
*/
private const CACHE_TTL = 6 * 3600;
public function register(): void {
add_filter( 'update_plugins_' . self::HOSTNAME, [ $this, 'provideUpdate' ], 10, 3 );
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;
}
/**
* `update_plugins_{hostname}` filter callback. Returns the incoming
* value untouched unless a newer release with a zip asset exists, in
* which case it returns the update array core expects.
*/
public function provideUpdate( mixed $update, mixed $plugin_data, mixed $plugin_file ): mixed {
if ( plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) {
return $update;
}
$release = $this->latestRelease();
if ( '' === $release['version'] || '' === $release['package'] ) {
return $update;
}
if ( version_compare( $release['version'], USC_VERSION, '<=' ) ) {
return $update;
}
return [
'slug' => 'unsupervised-schedular',
'version' => $release['version'],
'url' => self::REPO_URL,
'package' => $release['package'],
];
}
/**
* The latest published release, from the transient cache when fresh.
*
* @return array{version: string, package: string} Empty strings when no
* usable release exists.
*/
private function latestRelease(): array {
$cached = get_transient( self::TRANSIENT );
if ( is_array( $cached ) ) {
return [
'version' => Val::string( $cached['version'] ?? '' ),
'package' => Val::string( $cached['package'] ?? '' ),
];
}
$release = $this->fetchLatestRelease();
set_transient( self::TRANSIENT, $release, self::CACHE_TTL );
return $release;
}
/**
* Ask the Gitea API for the latest published release's version and zip asset.
*
* @return array{version: string, package: string}
*/
private function fetchLatestRelease(): array {
$none = [
'version' => '',
'package' => '',
];
$response = wp_remote_get(
self::API_URL,
[
'timeout' => 10,
'headers' => [ 'Accept' => 'application/json' ],
]
);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return $none;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $body ) ) {
return $none;
}
// Release tags are named v1.2.3; the plugin header carries the bare version.
$version = preg_replace( '/^v/i', '', Val::string( $body['tag_name'] ?? '' ) ) ?? '';
// The release workflow attaches the built plugin zip (top-level
// unsupervised-schedular/ folder, production autoloader) as an asset.
// Gitea's auto-generated source archives are not usable packages.
$package = '';
$assets = $body['assets'] ?? null;
if ( is_array( $assets ) ) {
foreach ( $assets as $asset ) {
if ( ! is_array( $asset ) ) {
continue;
}
if ( str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) {
$package = Val::string( $asset['browser_download_url'] ?? '' );
break;
}
}
}
if ( '' === $version || '' === $package ) {
return $none;
}
return [
'version' => $version,
'package' => $package,
];
}
}
-60
View File
@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular;
/**
* Runtime coercion helpers for values crossing untyped WordPress boundaries
* (wpdb rows, REST request params, superglobals). Each method narrows a mixed
* value with an explicit runtime check instead of a blind cast, so an
* unexpected shape degrades to a safe default rather than leaking garbage
* into typed code.
*/
final class Val {
/**
* Coerce to int; non-numeric values become 0.
*/
public static function int( mixed $value ): int {
return is_numeric( $value ) ? (int) $value : 0;
}
/**
* Coerce to int, preserving null (e.g. nullable DB columns).
*/
public static function intOrNull( mixed $value ): ?int {
return null === $value ? null : self::int( $value );
}
/**
* Coerce to float; non-numeric values become 0.0.
*/
public static function float( mixed $value ): float {
return is_numeric( $value ) ? (float) $value : 0.0;
}
/**
* Coerce to string; non-scalar values become ''.
*/
public static function string( mixed $value ): string {
if ( is_string( $value ) ) {
return $value;
}
return is_scalar( $value ) ? (string) $value : '';
}
/**
* Coerce to string, preserving null (e.g. nullable DB columns).
*/
public static function stringOrNull( mixed $value ): ?string {
return null === $value ? null : self::string( $value );
}
/**
* Coerce to bool using PHP truthiness (DB tinyint flags, option values).
*/
public static function bool( mixed $value ): bool {
return (bool) $value;
}
}
+13 -101
View File
@@ -8,33 +8,12 @@ if (! defined('ABSPATH')) {
/** /**
* @var list<\Unsupervised\Schedular\Availability\AvailabilitySlot> $slots * @var list<\Unsupervised\Schedular\Availability\AvailabilitySlot> $slots
* @var list<\Unsupervised\Schedular\Offering\Offering> $offeringChoices * @var list<\Unsupervised\Schedular\Offering\Offering> $offeringChoices
* @var 'list'|'week' $view
* @var string $weekStart
* @var list<array{date: string, slots: list<\Unsupervised\Schedular\Availability\AvailabilitySlot>}> $weekDays
* @var string $prevWeek
* @var string $nextWeek
*/ */
$baseUrl = admin_url('admin.php?page=us-availability');
$deleteForm = static function (\Unsupervised\Schedular\Availability\AvailabilitySlot $slot): void {
?>
<form method="post" style="display:inline;">
<?php wp_nonce_field('usc_availability_action'); ?>
<input type="hidden" name="usc_action" value="delete">
<input type="hidden" name="slot_id" value="<?php echo esc_attr((string) $slot->id); ?>">
<button type="submit" class="button button-small button-link-delete">
<?php esc_html_e('Delete', 'unsupervised-schedular'); ?>
</button>
</form>
<?php
};
?> ?>
<div class="wrap"> <div class="wrap">
<h1><?php esc_html_e('My Availability', 'unsupervised-schedular'); ?></h1> <h1><?php esc_html_e('My Availability', 'unsupervised-schedular'); ?></h1>
<h2><?php esc_html_e('Add Availability', 'unsupervised-schedular'); ?></h2> <h2><?php esc_html_e('Add Slot', 'unsupervised-schedular'); ?></h2>
<p><?php esc_html_e('The window must start and end on the same day. It is split into bookable slots of the chosen lesson length — for example, 9:00 AM4:00 PM with 60-minute lessons creates seven slots.', 'unsupervised-schedular'); ?></p>
<form method="post"> <form method="post">
<?php wp_nonce_field('usc_availability_action'); ?> <?php wp_nonce_field('usc_availability_action'); ?>
<input type="hidden" name="usc_action" value="add"> <input type="hidden" name="usc_action" value="add">
@@ -77,81 +56,17 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
</td> </td>
</tr> </tr>
</table> </table>
<?php submit_button(esc_html__('Add Availability', 'unsupervised-schedular')); ?> <?php submit_button(esc_html__('Add Slot', 'unsupervised-schedular')); ?>
</form> </form>
<h2><?php esc_html_e('Current Slots', 'unsupervised-schedular'); ?></h2> <h2><?php esc_html_e('Current Slots', 'unsupervised-schedular'); ?></h2>
<ul class="subsubsub" style="margin-bottom:12px;"> <?php if (empty($slots)) : ?>
<li>
<a href="<?php echo esc_url($baseUrl); ?>" <?php echo 'week' === $view ? 'class="current"' : ''; ?>><?php esc_html_e('Week', 'unsupervised-schedular'); ?></a> |
</li>
<li>
<a href="<?php echo esc_url(add_query_arg('usc_view', 'list', $baseUrl)); ?>" <?php echo 'list' === $view ? 'class="current"' : ''; ?>><?php esc_html_e('List', 'unsupervised-schedular'); ?></a>
</li>
</ul>
<div class="clear"></div>
<?php if ('week' === $view) : ?>
<p>
<a class="button" href="<?php echo esc_url(add_query_arg('usc_week', $prevWeek, $baseUrl)); ?>">&lsaquo; <?php esc_html_e('Previous week', 'unsupervised-schedular'); ?></a>
<strong style="margin:0 12px;">
<?php
/* translators: %s: date of the first day of the displayed week */
echo esc_html(sprintf(__('Week of %s', 'unsupervised-schedular'), (string) mysql2date('M j, Y', $weekStart)));
?>
</strong>
<a class="button" href="<?php echo esc_url(add_query_arg('usc_week', $nextWeek, $baseUrl)); ?>"><?php esc_html_e('Next week', 'unsupervised-schedular'); ?> &rsaquo;</a>
</p>
<table class="wp-list-table widefat fixed">
<thead>
<tr>
<?php foreach ($weekDays as $day) : ?>
<th><?php echo esc_html((string) mysql2date('D M j', $day['date'])); ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<tr>
<?php foreach ($weekDays as $day) : ?>
<td style="vertical-align:top;">
<?php if (empty($day['slots'])) : ?>
<span aria-hidden="true"></span>
<?php endif; ?>
<?php foreach ($day['slots'] as $slot) : ?>
<p style="margin:0 0 8px;">
<?php echo esc_html((string) mysql2date('g:i A', $slot->startDt) . '' . (string) mysql2date('g:i A', $slot->endDt)); ?><br>
<?php if ($slot->isBooked) : ?>
<em><?php esc_html_e('Booked', 'unsupervised-schedular'); ?></em>
<?php else : ?>
<?php $deleteForm($slot); ?>
<?php endif; ?>
</p>
<?php endforeach; ?>
</td>
<?php endforeach; ?>
</tr>
</tbody>
</table>
<?php elseif (empty($slots)) : ?>
<p><?php esc_html_e('No availability slots configured.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('No availability slots configured.', 'unsupervised-schedular'); ?></p>
<?php else : ?> <?php else : ?>
<?php
// Bulk-delete form. The row checkboxes live inside the table and are
// associated via the HTML form attribute, because the table also
// contains the per-row delete forms and forms cannot nest.
?>
<form method="post" id="usc-bulk-delete-form" onsubmit="return confirm('<?php echo esc_js(__('Delete the selected slots?', 'unsupervised-schedular')); ?>');">
<?php wp_nonce_field('usc_availability_action'); ?>
<input type="hidden" name="usc_action" value="bulk_delete">
</form>
<table class="wp-list-table widefat fixed striped"> <table class="wp-list-table widefat fixed striped">
<thead> <thead>
<tr> <tr>
<td class="manage-column column-cb check-column">
<input type="checkbox" id="cb-select-all-1">
<label for="cb-select-all-1"><span class="screen-reader-text"><?php esc_html_e('Select all', 'unsupervised-schedular'); ?></span></label>
</td>
<th><?php esc_html_e('Start', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Start', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('End', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('End', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Length', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Length', 'unsupervised-schedular'); ?></th>
@@ -162,28 +77,25 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
<tbody> <tbody>
<?php foreach ($slots as $slot) : ?> <?php foreach ($slots as $slot) : ?>
<tr> <tr>
<th scope="row" class="check-column"> <td><?php echo esc_html($slot->startDt); ?></td>
<?php if (! $slot->isBooked) : ?> <td><?php echo esc_html($slot->endDt); ?></td>
<input type="checkbox" name="slot_ids[]" form="usc-bulk-delete-form" value="<?php echo esc_attr((string) $slot->id); ?>">
<?php endif; ?>
</th>
<td><?php echo esc_html((string) mysql2date('M j, Y g:i A', $slot->startDt)); ?></td>
<td><?php echo esc_html((string) mysql2date('M j, Y g:i A', $slot->endDt)); ?></td>
<td><?php echo esc_html((string) $slot->durationMinutes . ' min'); ?></td> <td><?php echo esc_html((string) $slot->durationMinutes . ' min'); ?></td>
<td><?php echo $slot->isBooked ? esc_html__('Booked', 'unsupervised-schedular') : esc_html__('Available', 'unsupervised-schedular'); ?></td> <td><?php echo $slot->isBooked ? esc_html__('Booked', 'unsupervised-schedular') : esc_html__('Available', 'unsupervised-schedular'); ?></td>
<td> <td>
<?php if (! $slot->isBooked) : ?> <?php if (! $slot->isBooked) : ?>
<?php $deleteForm($slot); ?> <form method="post" style="display:inline;">
<?php wp_nonce_field('usc_availability_action'); ?>
<input type="hidden" name="usc_action" value="delete">
<input type="hidden" name="slot_id" value="<?php echo esc_attr((string) $slot->id); ?>">
<button type="submit" class="button button-small button-link-delete">
<?php esc_html_e('Delete', 'unsupervised-schedular'); ?>
</button>
</form>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
<p>
<button type="submit" class="button" form="usc-bulk-delete-form">
<?php esc_html_e('Delete selected', 'unsupervised-schedular'); ?>
</button>
</p>
<?php endif; ?> <?php endif; ?>
</div> </div>

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