Compare commits
1
Commits
v1.0.0
..
5140e76347
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5140e76347
|
@@ -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
|
||||||
|
|||||||
@@ -1,79 +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
|
|
||||||
|
|
||||||
- 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
|
|
||||||
release_id="$(curl -fsS -X POST "${api}/releases" \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"prerelease\":${prerelease}}" \
|
|
||||||
| jq -r '.id')"
|
|
||||||
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
|
|
||||||
@@ -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/`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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">‹ Previous week</button>
|
|
||||||
<strong class="us-week-label">Week of ${escHtml(shortDayLabel(weekStart))}</strong>
|
|
||||||
<button type="button" id="us-week-next">Next week ›</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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,45 +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)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
${termLabel(o) ? `<p>${escHtml(termLabel(o))}</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('');
|
||||||
|
|
||||||
@@ -171,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));
|
||||||
}
|
}
|
||||||
@@ -190,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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"
|
||||||
|
|||||||
@@ -4,77 +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` |
|
||||||
| `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) |
|
|
||||||
|
|
||||||
## 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`:
|
||||||
@@ -84,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.
|
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()`)
|
||||||
@@ -125,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`
|
|
||||||
|
|||||||
@@ -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:00–16: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`
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -15,19 +15,7 @@ Students enrol in a group class — an offering of kind `group_class` — as a c
|
|||||||
| `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
|
|
||||||
A group class offering carries `term_start`/`term_end` (see `offerings.md`):
|
|
||||||
one-off classes end the day they start; weekly classes run a set number of
|
|
||||||
sessions. The class card on the enrolment page shows the date or date range
|
|
||||||
with the session count.
|
|
||||||
|
|
||||||
## 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.
|
||||||
@@ -45,10 +33,7 @@ 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.
|
||||||
@@ -62,18 +47,15 @@ instructor's group classes if the caller has `view_own_lessons` on those offerin
|
|||||||
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
|
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
|
||||||
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_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. Instructor-specific enrolment views (the
|
> are a follow-up — this iteration ships the studio-admin **Group Classes** page
|
||||||
> spec's "under My Lessons") are a follow-up (#71) — this iteration ships the
|
> (`view_all_lessons`) plus per-student/per-instructor REST queries.
|
||||||
> studio-admin **Group Classes** page (`view_all_lessons`) plus
|
|
||||||
> per-student/per-instructor REST queries.
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
- `tests/Unit/GroupClass/EnrollmentTest.php`
|
- `tests/Unit/GroupClass/EnrollmentTest.php`
|
||||||
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
|
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
|
||||||
- `tests/Unit/GroupClass/GroupClassPageTest.php`
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -28,24 +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 + (N−1) 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.
|
|
||||||
|
|
||||||
## 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 |
|
||||||
@@ -64,6 +51,5 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
|
|||||||
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint`
|
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint`
|
||||||
|
|
||||||
## 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`
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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`
|
|
||||||
@@ -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
@@ -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
@@ -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
-47
@@ -8,13 +8,9 @@ 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;
|
||||||
@@ -27,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;
|
||||||
|
|
||||||
@@ -44,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;
|
||||||
@@ -53,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, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) {
|
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 );
|
$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( new RegistrationMailer() );
|
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
|
||||||
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
|
$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 {
|
||||||
@@ -176,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' ),
|
||||||
@@ -233,21 +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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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( '/' );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
-54
@@ -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,59 +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 $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 ),
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +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,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,34 +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,
|
|
||||||
'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', '%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;
|
||||||
@@ -46,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;
|
||||||
@@ -58,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
|
||||||
)
|
)
|
||||||
@@ -76,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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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( '/' ) );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +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 );
|
|
||||||
}
|
|
||||||
|
|
||||||
private function siteName(): string {
|
|
||||||
$name = (string) get_bloginfo( 'name' );
|
|
||||||
|
|
||||||
return '' !== $name ? $name : __( 'the studio', 'unsupervised-schedular' );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+29
-124
@@ -3,81 +3,49 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Unsupervised\Schedular\Auth;
|
namespace Unsupervised\Schedular\Auth;
|
||||||
|
|
||||||
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\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,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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).
|
|
||||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, not a state change.
|
|
||||||
$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();
|
$policyForms = $this->signupPolicies();
|
||||||
$canRegister = $open || $inviteValid;
|
$canRegister = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
|
||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
|
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
|
||||||
@@ -95,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;
|
||||||
}
|
}
|
||||||
@@ -110,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 ) {
|
||||||
@@ -156,17 +106,17 @@ class RegistrationPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( email_exists( $email ) ) {
|
if ( email_exists( $invite->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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -175,57 +125,12 @@ class RegistrationPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->recordAcceptances( $policyForms, (int) $userId );
|
$this->recordAcceptances( $policyForms, (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 );
|
||||||
|
|
||||||
wp_set_current_user( (int) $userId );
|
return true;
|
||||||
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 );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,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(
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,10 +96,6 @@ class StudentController {
|
|||||||
$this->enrollments->findByStudent( (int) $student->ID )
|
$this->enrollments->findByStudent( (int) $student->ID )
|
||||||
);
|
);
|
||||||
|
|
||||||
$acceptances = $this->history->policyAcceptances( (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';
|
||||||
}
|
}
|
||||||
@@ -164,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 : '—',
|
||||||
|
|||||||
@@ -1,112 +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\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 )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every intake answer the student has submitted, newest registration first.
|
|
||||||
*
|
|
||||||
* @return list<array{question: string, answer: string, context: string}>
|
|
||||||
*/
|
|
||||||
public function intakeAnswers( int $studentId ): array {
|
|
||||||
return 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 ),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
$this->answers->findByStudent( $studentId )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 ),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ) {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -183,7 +159,7 @@ class AvailabilityRepository {
|
|||||||
|
|
||||||
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;
|
||||||
@@ -211,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:00–16: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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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:00–16: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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 PM–4:30 PM', 30 ],
|
|
||||||
[ '4:30 PM–5:00 PM', 30 ],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => __( 'Wednesday', 'unsupervised-schedular' ),
|
|
||||||
'slots' => [
|
|
||||||
[ '5:00 PM–5: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 AM–11: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>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
-139
@@ -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 {
|
||||||
|
|
||||||
@@ -29,11 +27,6 @@ class BookingEndpoint {
|
|||||||
private PaymentService $payments,
|
private PaymentService $payments,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,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',
|
||||||
@@ -116,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 ) {
|
||||||
@@ -154,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 ) {
|
||||||
@@ -166,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 ) {
|
||||||
@@ -204,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;
|
||||||
@@ -213,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,
|
||||||
);
|
);
|
||||||
@@ -245,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
|
||||||
);
|
);
|
||||||
@@ -289,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;
|
||||||
@@ -297,45 +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 ) {
|
|
||||||
$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 ) {
|
||||||
@@ -346,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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+11
-13
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 AM–10: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 ) );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
@@ -21,11 +20,6 @@ class EnrollmentEndpoint {
|
|||||||
private PaymentService $payments,
|
private PaymentService $payments,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
||||||
@@ -75,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 ) {
|
||||||
@@ -93,7 +87,7 @@ class EnrollmentEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$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 ) {
|
||||||
@@ -110,17 +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() );
|
||||||
|
|
||||||
$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
|
||||||
);
|
);
|
||||||
@@ -142,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;
|
||||||
@@ -150,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-40
@@ -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';
|
||||||
@@ -46,45 +44,24 @@ class Offering {
|
|||||||
public readonly ?int $id = null,
|
public readonly ?int $id = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
public static function fromRow( object $row ): self {
|
||||||
* 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' );
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
||||||
scheduleNote: Val::stringOrNull( $row->schedule_note ),
|
scheduleNote: $row->schedule_note,
|
||||||
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
|
etransferEmail: $row->etransfer_email,
|
||||||
isActive: Val::bool( $row->is_active ),
|
isActive: (bool) $row->is_active,
|
||||||
id: Val::int( $row->id ),
|
id: (int) $row->id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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 {
|
||||||
|
|
||||||
@@ -22,20 +21,6 @@ class OfferingController {
|
|||||||
$this->handleFormAction( $instructorId, $manageAll );
|
$this->handleFormAction( $instructorId, $manageAll );
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
? $this->repository->findAll()
|
? $this->repository->findAll()
|
||||||
: $this->repository->findAll( $instructorId );
|
: $this->repository->findAll( $instructorId );
|
||||||
@@ -46,30 +31,14 @@ class OfferingController {
|
|||||||
private function handleFormAction( int $instructorId, bool $manageAll ): void {
|
private function handleFormAction( int $instructorId, bool $manageAll ): 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 ) {
|
||||||
$offering = $this->offeringFromPost( $instructorId );
|
$this->addOffering( $instructorId );
|
||||||
if ( null !== $offering ) {
|
|
||||||
$this->repository->insert( $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, $existing );
|
|
||||||
if ( null !== $offering ) {
|
|
||||||
$this->repository->update( $offeringId, $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 ) ) {
|
||||||
@@ -80,57 +49,36 @@ class OfferingController {
|
|||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function addOffering( int $instructorId ): void {
|
||||||
* Build an offering from the submitted add/edit form, or null when the
|
|
||||||
* submission is invalid. When `$existing` is given the result is an edit:
|
|
||||||
* it keeps the existing id, owner, and currency so an update can never
|
|
||||||
* reassign an offering to whoever happens to submit the form.
|
|
||||||
*/
|
|
||||||
private function offeringFromPost( int $instructorId, ?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 );
|
||||||
|
|
||||||
// Term dates: a class either meets once (term ends the day it starts)
|
$this->repository->insert(
|
||||||
// or repeats weekly for a set number of sessions.
|
new Offering(
|
||||||
$termStart = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['term_start'] ?? '' ) ) ) );
|
instructorId: $instructorId,
|
||||||
$termEnd = null;
|
kind: $kind,
|
||||||
if ( null !== $termStart ) {
|
title: $title,
|
||||||
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['term_recurrence'] ?? 'single' ) ) );
|
price: max( 0.0, (float) sanitize_text_field( wp_unslash( $_POST['price'] ?? '0' ) ) ),
|
||||||
$sessions = absint( Val::int( $_POST['term_sessions'] ?? 1 ) );
|
billingMode: $billingMode,
|
||||||
$termEnd = 'weekly' === $recurrence ? Offering::weeklyTermEnd( $termStart, $sessions ) : $termStart;
|
durationMinutes: $duration > 0 ? $duration : null,
|
||||||
}
|
allowWeekly: isset( $_POST['allow_weekly'] ),
|
||||||
|
capacity: $capacity > 0 ? $capacity : null,
|
||||||
return new Offering(
|
scheduleNote: $this->nullableText( sanitize_text_field( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ),
|
||||||
instructorId: null !== $existing ? $existing->instructorId : $instructorId,
|
etransferEmail: $this->nullableText( sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ),
|
||||||
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,
|
|
||||||
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'] ?? '' ) ) ) ),
|
|
||||||
isActive: isset( $_POST['is_active'] ),
|
|
||||||
id: $existing?->id,
|
|
||||||
);
|
);
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +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\Val;
|
|
||||||
|
|
||||||
class OfferingEndpoint {
|
class OfferingEndpoint {
|
||||||
|
|
||||||
public function __construct( private OfferingRepository $repository ) {}
|
public function __construct( private OfferingRepository $repository ) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,8 +57,8 @@ class OfferingEndpoint {
|
|||||||
|
|
||||||
public function index( \WP_REST_Request $request ): \WP_REST_Response {
|
public function index( \WP_REST_Request $request ): \WP_REST_Response {
|
||||||
$offerings = $this->repository->findAll(
|
$offerings = $this->repository->findAll(
|
||||||
Val::int( $request->get_param( 'instructor_id' ) ),
|
(int) $request->get_param( 'instructor_id' ),
|
||||||
Val::string( $request->get_param( 'kind' ) ),
|
(string) $request->get_param( 'kind' ),
|
||||||
activeOnly: true,
|
activeOnly: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -73,17 +67,17 @@ class OfferingEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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' ) );
|
||||||
}
|
}
|
||||||
@@ -93,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' ) ),
|
||||||
@@ -112,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 ) {
|
||||||
@@ -123,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' ) );
|
||||||
}
|
}
|
||||||
@@ -136,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,
|
||||||
@@ -158,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 ) {
|
||||||
@@ -201,17 +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 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 {
|
||||||
@@ -219,6 +213,6 @@ class OfferingEndpoint {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitize_text_field( Val::string( $value ) );
|
return sanitize_text_field( (string) $value );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,18 +90,18 @@ class OfferingRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$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;
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ] );
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 );
|
||||||
|
|||||||
@@ -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,24 +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';
|
||||||
|
|
||||||
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, '' );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,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 {
|
||||||
@@ -49,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';
|
||||||
}
|
}
|
||||||
@@ -59,14 +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 ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,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' ) );
|
||||||
@@ -116,7 +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();
|
|
||||||
|
|
||||||
include USC_PLUGIN_DIR . 'templates/admin/settings.php';
|
include USC_PLUGIN_DIR . 'templates/admin/settings.php';
|
||||||
}
|
}
|
||||||
@@ -124,61 +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 ) );
|
||||||
|
|
||||||
$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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-33
@@ -3,18 +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\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;
|
||||||
@@ -29,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 {
|
||||||
|
|
||||||
@@ -37,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 );
|
||||||
@@ -66,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 );
|
|
||||||
$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 AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
|
|
||||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->register();
|
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->register();
|
||||||
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
|
( new ShortcodeRegistrar( $invites, $policies, $policyVersions, $acceptances ) )->register();
|
||||||
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? [] );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ 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';
|
||||||
@@ -26,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? [] );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -40,24 +38,22 @@ class Question {
|
|||||||
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::int( $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,
|
||||||
id: Val::int( $row->id ),
|
id: (int) $row->id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
@@ -24,7 +23,7 @@ class QuestionController {
|
|||||||
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
|
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
|
||||||
|
|
||||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only offering selector.
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only offering selector.
|
||||||
$offeringId = absint( Val::int( $_GET['offering_id'] ?? 0 ) );
|
$offeringId = absint( $_GET['offering_id'] ?? 0 );
|
||||||
$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;
|
||||||
|
|
||||||
@@ -47,14 +46,14 @@ class QuestionController {
|
|||||||
private function handleFormAction( Offering $offering ): void {
|
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( (int) $offering->id );
|
$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 && $question->offeringId === (int) $offering->id ) {
|
if ( $question && $question->offeringId === (int) $offering->id ) {
|
||||||
@@ -67,8 +66,8 @@ class QuestionController {
|
|||||||
|
|
||||||
private function addQuestion( int $offeringId ): 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;
|
||||||
@@ -79,9 +78,9 @@ class QuestionController {
|
|||||||
offeringId: $offeringId,
|
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 ),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
|
|||||||
@@ -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,18 +107,18 @@ 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,
|
||||||
id: $id,
|
id: $id,
|
||||||
);
|
);
|
||||||
@@ -135,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 ) {
|
||||||
@@ -198,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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,8 +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 ) {
|
if ( $activeOnly ) {
|
||||||
$sql .= ' AND is_active = %d';
|
$sql .= ' AND is_active = %d';
|
||||||
@@ -71,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;
|
||||||
|
|||||||
@@ -186,13 +186,11 @@ 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',
|
|
||||||
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),
|
||||||
|
|||||||
+28
-27
@@ -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
|
||||||
|
|||||||
@@ -1,146 +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 );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 AM–4: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)); ?>">‹ <?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'); ?> ›</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>
|
||||||
|
|||||||
@@ -9,26 +9,11 @@ if (! defined('ABSPATH')) {
|
|||||||
* @var list<\Unsupervised\Schedular\Auth\Invite> $pendingInvites
|
* @var list<\Unsupervised\Schedular\Auth\Invite> $pendingInvites
|
||||||
* @var int $registrationPageId
|
* @var int $registrationPageId
|
||||||
* @var string $registrationPageUrl
|
* @var string $registrationPageUrl
|
||||||
* @var string $newInviteUrl One-time registration link for a just-created invite.
|
|
||||||
* @var string $inviteError Error message when invite creation failed.
|
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1><?php esc_html_e('Invites', 'unsupervised-schedular'); ?></h1>
|
<h1><?php esc_html_e('Invites', 'unsupervised-schedular'); ?></h1>
|
||||||
<p class="description"><?php esc_html_e('Invite a student by email, then send them the registration link. They complete signup and accept any required policies through the [us_student_register] page.', 'unsupervised-schedular'); ?></p>
|
<p class="description"><?php esc_html_e('Invite a student by email, then send them the registration link below. They complete signup and accept any required policies through the [us_student_register] page.', 'unsupervised-schedular'); ?></p>
|
||||||
|
|
||||||
<?php if ($inviteError !== '') : ?>
|
|
||||||
<div class="notice notice-error inline">
|
|
||||||
<p><?php echo esc_html($inviteError); ?></p>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($newInviteUrl !== '') : ?>
|
|
||||||
<div class="notice notice-success inline">
|
|
||||||
<p><?php esc_html_e('Invite created. Copy the registration link now — for security it is not stored and cannot be shown again. To re-send a lost link, revoke the invite and create a new one.', 'unsupervised-schedular'); ?></p>
|
|
||||||
<p><input type="text" class="large-text code" readonly value="<?php echo esc_attr($newInviteUrl); ?>" onclick="this.select()"></p>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<h2><?php esc_html_e('Registration Page', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Registration Page', 'unsupervised-schedular'); ?></h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -73,23 +58,6 @@ if (! defined('ABSPATH')) {
|
|||||||
<?php submit_button(esc_html__('Generate Invitation Link', 'unsupervised-schedular')); ?>
|
<?php submit_button(esc_html__('Generate Invitation Link', 'unsupervised-schedular')); ?>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Group Invite Link', 'unsupervised-schedular'); ?></h2>
|
|
||||||
<p class="description"><?php esc_html_e('Generate a shareable link (e.g. for a newsletter). Anyone with the link can register until it expires: they enter their own email and must confirm it, but no admin approval is needed afterwards.', 'unsupervised-schedular'); ?></p>
|
|
||||||
<form method="post">
|
|
||||||
<?php wp_nonce_field('usc_invite_action'); ?>
|
|
||||||
<input type="hidden" name="usc_action" value="group_invite">
|
|
||||||
<table class="form-table">
|
|
||||||
<tr>
|
|
||||||
<th><label for="expires_at"><?php esc_html_e('Expires on', 'unsupervised-schedular'); ?></label></th>
|
|
||||||
<td>
|
|
||||||
<input type="date" name="expires_at" id="expires_at" required min="<?php echo esc_attr((string) current_time('Y-m-d')); ?>">
|
|
||||||
<p class="description"><?php esc_html_e('The link stops working at the end of this day.', 'unsupervised-schedular'); ?></p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<?php submit_button(esc_html__('Generate Group Link', 'unsupervised-schedular')); ?>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h2><?php esc_html_e('Pending Invites', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Pending Invites', 'unsupervised-schedular'); ?></h2>
|
||||||
|
|
||||||
<?php if (empty($pendingInvites)) : ?>
|
<?php if (empty($pendingInvites)) : ?>
|
||||||
@@ -98,27 +66,25 @@ if (! defined('ABSPATH')) {
|
|||||||
<table class="wp-list-table widefat fixed striped">
|
<table class="wp-list-table widefat fixed striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e('Invite', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Created', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Registration link', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Expires', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<?php $linkBase = $registrationPageUrl !== '' ? $registrationPageUrl : home_url('/'); ?>
|
||||||
<?php $now = current_time('mysql'); ?>
|
<?php $now = current_time('mysql'); ?>
|
||||||
<?php foreach ($pendingInvites as $invite) : ?>
|
<?php foreach ($pendingInvites as $invite) : ?>
|
||||||
|
<?php $link = esc_url(add_query_arg('us_invite', $invite->token, $linkBase)); ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<?php echo $invite->isGroup() ? esc_html__('Group link', 'unsupervised-schedular') : esc_html($invite->email); ?>
|
<?php echo esc_html($invite->email); ?>
|
||||||
<?php if ($invite->isExpired($now)) : ?>
|
<?php if ($invite->isExpired($now)) : ?>
|
||||||
<span class="us-invite-expired" style="color:#b32d2e;">— <?php esc_html_e('expired', 'unsupervised-schedular'); ?></span>
|
<span class="us-invite-expired" style="color:#b32d2e;">— <?php esc_html_e('expired', 'unsupervised-schedular'); ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php echo esc_html((string) $invite->createdAt); ?>
|
<input type="text" class="large-text code" readonly value="<?php echo esc_attr($link); ?>" onclick="this.select()">
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<?php echo esc_html($invite->expiresAt !== null ? (string) mysql2date('M j, Y', $invite->expiresAt) : '—'); ?>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" style="display:inline;">
|
<form method="post" style="display:inline;">
|
||||||
|
|||||||
@@ -5,68 +5,12 @@ if (! defined('ABSPATH')) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @var list<array{student: string, instructor: string, slot_id: int, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows */
|
||||||
* @var list<array{student: string, instructor: string, time: string, day: string, time_short: string, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows
|
|
||||||
* @var 'list'|'week' $view
|
|
||||||
* @var string $weekStart
|
|
||||||
* @var list<array{date: string, items: list<array{student: string, time_short: string, status: string}>}> $weekDays
|
|
||||||
* @var string $prevWeek
|
|
||||||
* @var string $nextWeek
|
|
||||||
* @var string $baseUrl
|
|
||||||
*/
|
|
||||||
?>
|
?>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1><?php esc_html_e('Lessons', 'unsupervised-schedular'); ?></h1>
|
<h1><?php esc_html_e('Lessons', 'unsupervised-schedular'); ?></h1>
|
||||||
|
|
||||||
<ul class="subsubsub" style="margin-bottom:12px;">
|
<?php if (empty($rows)) : ?>
|
||||||
<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)); ?>">‹ <?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'); ?> ›</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['items'])) : ?>
|
|
||||||
<span aria-hidden="true">—</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php foreach ($day['items'] as $item) : ?>
|
|
||||||
<p style="margin:0 0 8px;">
|
|
||||||
<strong><?php echo esc_html($item['time_short']); ?></strong><br>
|
|
||||||
<?php echo esc_html($item['student']); ?><br>
|
|
||||||
<em><?php echo esc_html($item['status']); ?></em>
|
|
||||||
</p>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</td>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<?php elseif (empty($rows)) : ?>
|
|
||||||
<p><?php esc_html_e('No upcoming lessons.', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('No upcoming lessons.', 'unsupervised-schedular'); ?></p>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<table class="wp-list-table widefat fixed striped">
|
<table class="wp-list-table widefat fixed striped">
|
||||||
@@ -74,7 +18,7 @@ if (! defined('ABSPATH')) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Date/Time', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Slot ID', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
|
||||||
@@ -87,7 +31,7 @@ if (! defined('ABSPATH')) {
|
|||||||
<tr>
|
<tr>
|
||||||
<td><?php echo esc_html($row['student']); ?></td>
|
<td><?php echo esc_html($row['student']); ?></td>
|
||||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||||
<td><?php echo esc_html($row['time']); ?></td>
|
<td><?php echo esc_html((string) $row['slot_id']); ?></td>
|
||||||
<td><?php echo esc_html($row['status']); ?></td>
|
<td><?php echo esc_html($row['status']); ?></td>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($row['tax_editable']) : ?>
|
<?php if ($row['tax_editable']) : ?>
|
||||||
|
|||||||
@@ -7,111 +7,64 @@ if (! defined('ABSPATH')) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @var list<\Unsupervised\Schedular\Offering\Offering> $offerings */
|
||||||
* @var list<\Unsupervised\Schedular\Offering\Offering> $offerings
|
|
||||||
* @var \Unsupervised\Schedular\Offering\Offering|null $editing Offering loaded into the form, or null when adding.
|
|
||||||
*/
|
|
||||||
|
|
||||||
$baseUrl = admin_url('admin.php?page=us-offerings');
|
|
||||||
|
|
||||||
// Prefill the sessions control from the stored term dates: a term longer than
|
|
||||||
// one day was created as weekly sessions one week apart.
|
|
||||||
$termRecurrence = 'single';
|
|
||||||
$termSessions = 10;
|
|
||||||
if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $editing->termEnd !== $editing->termStart) {
|
|
||||||
$termRecurrence = 'weekly';
|
|
||||||
$termSessions = (int) round(((int) strtotime($editing->termEnd) - (int) strtotime($editing->termStart)) / 604800) + 1;
|
|
||||||
}
|
|
||||||
?>
|
?>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1><?php esc_html_e('Offerings', 'unsupervised-schedular'); ?></h1>
|
<h1><?php esc_html_e('Offerings', 'unsupervised-schedular'); ?></h1>
|
||||||
|
|
||||||
<h2><?php $editing ? esc_html_e('Edit Offering', 'unsupervised-schedular') : esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<?php wp_nonce_field('usc_offering_action'); ?>
|
<?php wp_nonce_field('usc_offering_action'); ?>
|
||||||
<?php if ($editing) : ?>
|
<input type="hidden" name="usc_action" value="add">
|
||||||
<input type="hidden" name="usc_action" value="update">
|
|
||||||
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $editing->id); ?>">
|
|
||||||
<?php else : ?>
|
|
||||||
<input type="hidden" name="usc_action" value="add">
|
|
||||||
<?php endif; ?>
|
|
||||||
<table class="form-table">
|
<table class="form-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="title" id="title" class="regular-text" required value="<?php echo esc_attr($editing->title ?? ''); ?>"></td>
|
<td><input type="text" name="title" id="title" class="regular-text" required></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<select name="kind" id="kind">
|
<select name="kind" id="kind">
|
||||||
<option value="<?php echo esc_attr(Offering::KIND_PRIVATE_LESSON); ?>"><?php esc_html_e('Private lesson', 'unsupervised-schedular'); ?></option>
|
<option value="<?php echo esc_attr(Offering::KIND_PRIVATE_LESSON); ?>"><?php esc_html_e('Private lesson', 'unsupervised-schedular'); ?></option>
|
||||||
<option value="<?php echo esc_attr(Offering::KIND_GROUP_CLASS); ?>" <?php echo $editing && Offering::KIND_GROUP_CLASS === $editing->kind ? 'selected' : ''; ?>><?php esc_html_e('Group class', 'unsupervised-schedular'); ?></option>
|
<option value="<?php echo esc_attr(Offering::KIND_GROUP_CLASS); ?>"><?php esc_html_e('Group class', 'unsupervised-schedular'); ?></option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<th><label for="description"><?php esc_html_e('Description', 'unsupervised-schedular'); ?></label></th>
|
|
||||||
<td><textarea name="description" id="description" class="large-text" rows="4"><?php echo esc_textarea($editing->description ?? ''); ?></textarea></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="duration_minutes"><?php esc_html_e('Duration (minutes)', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="duration_minutes"><?php esc_html_e('Duration (minutes)', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="number" name="duration_minutes" id="duration_minutes" min="0" step="1" value="<?php echo esc_attr((string) ($editing->durationMinutes ?? '')); ?>"></td>
|
<td><input type="number" name="duration_minutes" id="duration_minutes" min="0" step="1"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="price"><?php esc_html_e('Price (dollars)', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="price"><?php esc_html_e('Price (dollars)', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="number" name="price" id="price" min="0" step="0.01" value="<?php echo esc_attr(number_format($editing->price ?? 0.0, 2, '.', '')); ?>"></td>
|
<td><input type="number" name="price" id="price" min="0" step="0.01" value="0.00"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="billing_mode"><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="billing_mode"><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<select name="billing_mode" id="billing_mode">
|
<select name="billing_mode" id="billing_mode">
|
||||||
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>"><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
|
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>"><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
|
||||||
<option value="<?php echo esc_attr(Offering::BILLING_FULL_TERM); ?>" <?php echo $editing && Offering::BILLING_FULL_TERM === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Full term upfront', 'unsupervised-schedular'); ?></option>
|
<option value="<?php echo esc_attr(Offering::BILLING_FULL_TERM); ?>"><?php esc_html_e('Full term upfront', 'unsupervised-schedular'); ?></option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e('Weekly reservation', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Weekly reservation', 'unsupervised-schedular'); ?></th>
|
||||||
<td><label><input type="checkbox" name="allow_weekly" value="1" <?php echo $editing && $editing->allowWeekly ? 'checked' : ''; ?>> <?php esc_html_e('Allow weekly recurring reservation (private)', 'unsupervised-schedular'); ?></label></td>
|
<td><label><input type="checkbox" name="allow_weekly" value="1"> <?php esc_html_e('Allow weekly recurring reservation (private)', 'unsupervised-schedular'); ?></label></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="capacity"><?php esc_html_e('Capacity', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="capacity"><?php esc_html_e('Capacity', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="number" name="capacity" id="capacity" min="0" step="1" value="<?php echo esc_attr((string) ($editing->capacity ?? '')); ?>"> <span class="description"><?php esc_html_e('Group classes only', 'unsupervised-schedular'); ?></span></td>
|
<td><input type="number" name="capacity" id="capacity" min="0" step="1"> <span class="description"><?php esc_html_e('Group classes only', 'unsupervised-schedular'); ?></span></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th><label for="term_start"><?php esc_html_e('Start date', 'unsupervised-schedular'); ?></label></th>
|
|
||||||
<td>
|
|
||||||
<input type="date" name="term_start" id="term_start" value="<?php echo esc_attr($editing->termStart ?? ''); ?>">
|
|
||||||
<span class="description"><?php esc_html_e('Group classes only — date of the first class', 'unsupervised-schedular'); ?></span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
|
|
||||||
<td>
|
|
||||||
<label><input type="radio" name="term_recurrence" value="single" <?php echo 'single' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label>
|
|
||||||
|
|
||||||
<label><input type="radio" name="term_recurrence" value="weekly" <?php echo 'weekly' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('Weekly for', 'unsupervised-schedular'); ?></label>
|
|
||||||
<input type="number" name="term_sessions" min="1" max="52" value="<?php echo esc_attr((string) $termSessions); ?>" style="width:5em;"> <?php esc_html_e('sessions', 'unsupervised-schedular'); ?>
|
|
||||||
<p class="description"><?php esc_html_e('The end date is calculated from the start date and the number of weekly sessions.', 'unsupervised-schedular'); ?></p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->scheduleNote ?? ''); ?>"></td>
|
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
|
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>"></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
|
|
||||||
<td><label><input type="checkbox" name="is_active" value="1" <?php echo null === $editing || $editing->isActive ? 'checked' : ''; ?>> <?php esc_html_e('Open for registration', 'unsupervised-schedular'); ?></label></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<?php submit_button($editing ? esc_html__('Update Offering', 'unsupervised-schedular') : esc_html__('Add Offering', 'unsupervised-schedular')); ?>
|
<?php submit_button(esc_html__('Add Offering', 'unsupervised-schedular')); ?>
|
||||||
<?php if ($editing) : ?>
|
|
||||||
<p><a href="<?php echo esc_url($baseUrl); ?>"><?php esc_html_e('Cancel editing', 'unsupervised-schedular'); ?></a></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Current Offerings', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Current Offerings', 'unsupervised-schedular'); ?></h2>
|
||||||
@@ -122,13 +75,11 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
|||||||
<table class="wp-list-table widefat fixed striped">
|
<table class="wp-list-table widefat fixed striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:4em;"><?php esc_html_e('ID', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Title', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Title', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Duration', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Duration', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Term', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -136,24 +87,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($offerings as $offering) : ?>
|
<?php foreach ($offerings as $offering) : ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?php echo esc_html((string) $offering->id); ?></td>
|
|
||||||
<td><?php echo esc_html($offering->title); ?></td>
|
<td><?php echo esc_html($offering->title); ?></td>
|
||||||
<td><?php echo esc_html($offering->kind); ?></td>
|
<td><?php echo esc_html($offering->kind); ?></td>
|
||||||
<td><?php echo $offering->durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '—'; ?></td>
|
<td><?php echo $offering->durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '—'; ?></td>
|
||||||
<td><?php echo esc_html(number_format($offering->price, 2) . ' ' . $offering->currency); ?></td>
|
<td><?php echo esc_html(number_format($offering->price, 2) . ' ' . $offering->currency); ?></td>
|
||||||
<td><?php echo esc_html($offering->billingMode); ?></td>
|
<td><?php echo esc_html($offering->billingMode); ?></td>
|
||||||
<td>
|
|
||||||
<?php if (null === $offering->termStart) : ?>
|
|
||||||
—
|
|
||||||
<?php elseif (null === $offering->termEnd || $offering->termEnd === $offering->termStart) : ?>
|
|
||||||
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart)); ?>
|
|
||||||
<?php else : ?>
|
|
||||||
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart) . ' – ' . (string) mysql2date('M j, Y', $offering->termEnd)); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td><?php echo $offering->isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
|
<td><?php echo $offering->isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
|
||||||
<td>
|
<td>
|
||||||
<a class="button button-small" href="<?php echo esc_url(add_query_arg('usc_edit', (int) $offering->id, $baseUrl)); ?>"><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></a>
|
|
||||||
<form method="post" style="display:inline;">
|
<form method="post" style="display:inline;">
|
||||||
<?php wp_nonce_field('usc_offering_action'); ?>
|
<?php wp_nonce_field('usc_offering_action'); ?>
|
||||||
<input type="hidden" name="usc_action" value="delete">
|
<input type="hidden" name="usc_action" value="delete">
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
if (! defined('ABSPATH')) {
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
use Unsupervised\Schedular\Auth\RegistrationApprovalController;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, \WP_User> $awaitingApproval Confirmed, awaiting a decision.
|
|
||||||
* @var array<int, \WP_User> $awaitingConfirmation Not yet confirmed their email.
|
|
||||||
*/
|
|
||||||
?>
|
|
||||||
<div class="wrap">
|
|
||||||
<h1><?php esc_html_e('Pending Students', 'unsupervised-schedular'); ?></h1>
|
|
||||||
|
|
||||||
<div class="notice notice-info inline">
|
|
||||||
<p>
|
|
||||||
<?php esc_html_e('Students who signed themselves up appear here. Once they confirm their email you can approve them into a full student account, or reject the application to remove it.', 'unsupervised-schedular'); ?>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2><?php esc_html_e('Awaiting approval', 'unsupervised-schedular'); ?></h2>
|
|
||||||
<?php if (empty($awaitingApproval)) : ?>
|
|
||||||
<p><?php esc_html_e('No students are awaiting approval.', 'unsupervised-schedular'); ?></p>
|
|
||||||
<?php else : ?>
|
|
||||||
<table class="widefat striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($awaitingApproval as $user) : ?>
|
|
||||||
<tr>
|
|
||||||
<td><?php echo esc_html((string) $user->display_name); ?></td>
|
|
||||||
<td><?php echo esc_html((string) $user->user_email); ?></td>
|
|
||||||
<td><?php echo esc_html((string) $user->user_registered); ?></td>
|
|
||||||
<td>
|
|
||||||
<form method="post" style="display:inline">
|
|
||||||
<?php wp_nonce_field(RegistrationApprovalController::NONCE_ACTION); ?>
|
|
||||||
<input type="hidden" name="user_id" value="<?php echo esc_attr((string) $user->ID); ?>">
|
|
||||||
<button type="submit" name="usc_action" value="approve" class="button button-primary"><?php esc_html_e('Approve', 'unsupervised-schedular'); ?></button>
|
|
||||||
</form>
|
|
||||||
<form method="post" style="display:inline" onsubmit="return confirm('<?php echo esc_js(__('Reject and delete this application?', 'unsupervised-schedular')); ?>');">
|
|
||||||
<?php wp_nonce_field(RegistrationApprovalController::NONCE_ACTION); ?>
|
|
||||||
<input type="hidden" name="user_id" value="<?php echo esc_attr((string) $user->ID); ?>">
|
|
||||||
<button type="submit" name="usc_action" value="reject" class="button button-link-delete"><?php esc_html_e('Reject', 'unsupervised-schedular'); ?></button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<h2><?php esc_html_e('Awaiting email confirmation', 'unsupervised-schedular'); ?></h2>
|
|
||||||
<?php if (empty($awaitingConfirmation)) : ?>
|
|
||||||
<p><?php esc_html_e('No students are awaiting email confirmation.', 'unsupervised-schedular'); ?></p>
|
|
||||||
<?php else : ?>
|
|
||||||
<table class="widefat striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($awaitingConfirmation as $user) : ?>
|
|
||||||
<tr>
|
|
||||||
<td><?php echo esc_html((string) $user->display_name); ?></td>
|
|
||||||
<td><?php echo esc_html((string) $user->user_email); ?></td>
|
|
||||||
<td><?php echo esc_html((string) $user->user_registered); ?></td>
|
|
||||||
<td>
|
|
||||||
<form method="post" style="display:inline" onsubmit="return confirm('<?php echo esc_js(__('Reject and delete this application?', 'unsupervised-schedular')); ?>');">
|
|
||||||
<?php wp_nonce_field(RegistrationApprovalController::NONCE_ACTION); ?>
|
|
||||||
<input type="hidden" name="user_id" value="<?php echo esc_attr((string) $user->ID); ?>">
|
|
||||||
<button type="submit" name="usc_action" value="reject" class="button button-link-delete"><?php esc_html_e('Reject', 'unsupervised-schedular'); ?></button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
@@ -15,7 +15,6 @@ if (! defined('ABSPATH')) {
|
|||||||
* @var string $etransferEmail
|
* @var string $etransferEmail
|
||||||
* @var float $hstRate
|
* @var float $hstRate
|
||||||
* @var bool $stripeConfigured
|
* @var bool $stripeConfigured
|
||||||
* @var bool $openRegistration
|
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
@@ -89,27 +88,6 @@ if (! defined('ABSPATH')) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Registration', 'unsupervised-schedular'); ?></h2>
|
|
||||||
<table class="form-table">
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><?php esc_html_e('Student sign-up', 'unsupervised-schedular'); ?></th>
|
|
||||||
<td>
|
|
||||||
<fieldset>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" name="open_registration" value="1" <?php checked($openRegistration); ?>>
|
|
||||||
<?php esc_html_e('Allow anyone to sign up (email confirmation + admin approval)', 'unsupervised-schedular'); ?>
|
|
||||||
</label>
|
|
||||||
<p class="description">
|
|
||||||
<?php esc_html_e('Off by default — students join by invite only. When on, anyone can register on the student registration page; each new account must confirm its email and then be approved by a studio admin under Students → Pending Students before it can be used.', 'unsupervised-schedular'); ?>
|
|
||||||
</p>
|
|
||||||
<p class="description">
|
|
||||||
<?php esc_html_e('Turning this on also switches on Settings → General → “Anyone can register” and sets the default new-user role to Student. Turning it off restores those two settings to what they were before.', 'unsupervised-schedular'); ?>
|
|
||||||
</p>
|
|
||||||
</fieldset>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<?php submit_button(esc_html__('Save Settings', 'unsupervised-schedular')); ?>
|
<?php submit_button(esc_html__('Save Settings', 'unsupervised-schedular')); ?>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,21 +7,16 @@ if (! defined('ABSPATH')) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @var \WP_User $student
|
* @var \WP_User $student
|
||||||
* @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $upcoming
|
* @var list<array{start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $upcoming
|
||||||
* @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
|
* @var list<array{start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
|
||||||
* @var list<array{id: int, offering: string, status: string}> $enrolments
|
* @var list<array{offering: string, status: string}> $enrolments
|
||||||
* @var list<array{policy: string, version: string, context: string, accepted_at: string}> $acceptances
|
|
||||||
* @var list<array{question: string, answer: string, context: string}> $intake
|
|
||||||
* @var list<array{created_at: string, context: string, method: string, status: string, amount: float, tax_amount: float, total: float, currency: string, receipt: string}> $payments
|
|
||||||
* @var string $backUrl
|
* @var string $backUrl
|
||||||
* @var bool $canBilling
|
* @var bool $canBilling
|
||||||
* @var string $billingOverride
|
* @var string $billingOverride
|
||||||
* @var string $billingDefault
|
* @var string $billingDefault
|
||||||
* @var string $notice
|
|
||||||
* @var string $error
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$renderLessons = static function (array $rows, bool $withActions = false): void {
|
$renderLessons = static function (array $rows): void {
|
||||||
if (empty($rows)) {
|
if (empty($rows)) {
|
||||||
echo '<p>' . esc_html__('None.', 'unsupervised-schedular') . '</p>';
|
echo '<p>' . esc_html__('None.', 'unsupervised-schedular') . '</p>';
|
||||||
return;
|
return;
|
||||||
@@ -34,32 +29,15 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
|||||||
<th><?php esc_html_e('Offering', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Offering', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||||
<?php if ($withActions) : ?>
|
|
||||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($rows as $row) : ?>
|
<?php foreach ($rows as $row) : ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?php echo esc_html($row['start_dt'] !== '' ? (string) mysql2date('M j, Y g:i A', $row['start_dt']) : '—'); ?></td>
|
<td><?php echo esc_html($row['start_dt'] !== '' ? $row['start_dt'] : '—'); ?></td>
|
||||||
<td><?php echo esc_html($row['offering']); ?></td>
|
<td><?php echo esc_html($row['offering']); ?></td>
|
||||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||||
<td><?php echo esc_html($row['status']); ?></td>
|
<td><?php echo esc_html($row['status']); ?></td>
|
||||||
<?php if ($withActions) : ?>
|
|
||||||
<td>
|
|
||||||
<?php if ($row['status'] !== 'cancelled') : ?>
|
|
||||||
<form method="post" style="display:inline">
|
|
||||||
<?php wp_nonce_field('usc_student_actions'); ?>
|
|
||||||
<input type="hidden" name="usc_action" value="cancel_lesson">
|
|
||||||
<input type="hidden" name="lesson_id" value="<?php echo esc_attr((string) $row['id']); ?>">
|
|
||||||
<button type="submit" class="button-link button-link-delete" onclick="return confirm('<?php echo esc_js(__('Cancel this lesson? The slot is freed and any pending payment is voided.', 'unsupervised-schedular')); ?>');">
|
|
||||||
<?php esc_html_e('Cancel lesson', 'unsupervised-schedular'); ?>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -73,33 +51,11 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
|||||||
<a href="<?php echo esc_url($backUrl); ?>" class="page-title-action"><?php esc_html_e('Back to students', 'unsupervised-schedular'); ?></a>
|
<a href="<?php echo esc_url($backUrl); ?>" class="page-title-action"><?php esc_html_e('Back to students', 'unsupervised-schedular'); ?></a>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<?php if ($notice !== '') : ?>
|
|
||||||
<div class="notice notice-success is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($error !== '') : ?>
|
|
||||||
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
|
||||||
<form method="post">
|
<table class="form-table">
|
||||||
<?php wp_nonce_field('usc_student_actions'); ?>
|
<tr><th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th><td><?php echo esc_html($student->user_email); ?></td></tr>
|
||||||
<input type="hidden" name="usc_action" value="update_account">
|
<tr><th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th><td><?php echo esc_html($student->user_registered); ?></td></tr>
|
||||||
<table class="form-table">
|
</table>
|
||||||
<tr>
|
|
||||||
<th><label for="usc-display-name"><?php esc_html_e('Display name', 'unsupervised-schedular'); ?></label></th>
|
|
||||||
<td><input type="text" id="usc-display-name" name="display_name" class="regular-text" value="<?php echo esc_attr($student->display_name); ?>" required></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th><label for="usc-user-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label></th>
|
|
||||||
<td><input type="email" id="usc-user-email" name="user_email" class="regular-text" value="<?php echo esc_attr($student->user_email); ?>" required></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
|
|
||||||
<td><?php echo esc_html($student->user_registered); ?></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<?php submit_button(esc_html__('Save account details', 'unsupervised-schedular'), 'secondary', 'submit', false); ?>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<?php if ($canBilling) : ?>
|
<?php if ($canBilling) : ?>
|
||||||
<h2><?php esc_html_e('Billing method', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Billing method', 'unsupervised-schedular'); ?></h2>
|
||||||
@@ -122,7 +78,7 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Upcoming lessons', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Upcoming lessons', 'unsupervised-schedular'); ?></h2>
|
||||||
<?php $renderLessons($upcoming, true); ?>
|
<?php $renderLessons($upcoming); ?>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Past lessons', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Past lessons', 'unsupervised-schedular'); ?></h2>
|
||||||
<?php $renderLessons($past); ?>
|
<?php $renderLessons($past); ?>
|
||||||
@@ -136,7 +92,6 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
|||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -144,107 +99,9 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
|||||||
<tr>
|
<tr>
|
||||||
<td><?php echo esc_html($enrolment['offering']); ?></td>
|
<td><?php echo esc_html($enrolment['offering']); ?></td>
|
||||||
<td><?php echo esc_html($enrolment['status']); ?></td>
|
<td><?php echo esc_html($enrolment['status']); ?></td>
|
||||||
<td>
|
|
||||||
<?php if ($enrolment['status'] === 'active') : ?>
|
|
||||||
<form method="post" style="display:inline">
|
|
||||||
<?php wp_nonce_field('usc_student_actions'); ?>
|
|
||||||
<input type="hidden" name="usc_action" value="withdraw_enrollment">
|
|
||||||
<input type="hidden" name="enrollment_id" value="<?php echo esc_attr((string) $enrolment['id']); ?>">
|
|
||||||
<button type="submit" class="button-link button-link-delete" onclick="return confirm('<?php echo esc_js(__('Withdraw this student from the class? The seat is freed and any pending payment is voided.', 'unsupervised-schedular')); ?>');">
|
|
||||||
<?php esc_html_e('Withdraw', 'unsupervised-schedular'); ?>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Policy acceptances', 'unsupervised-schedular'); ?></h2>
|
|
||||||
<?php if (empty($acceptances)) : ?>
|
|
||||||
<p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p>
|
|
||||||
<?php else : ?>
|
|
||||||
<table class="wp-list-table widefat fixed striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th><?php esc_html_e('Policy', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Version', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Context', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Accepted', 'unsupervised-schedular'); ?></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($acceptances as $acceptance) : ?>
|
|
||||||
<tr>
|
|
||||||
<td><?php echo esc_html($acceptance['policy']); ?></td>
|
|
||||||
<td><?php echo esc_html($acceptance['version']); ?></td>
|
|
||||||
<td><?php echo esc_html($acceptance['context']); ?></td>
|
|
||||||
<td><?php echo esc_html($acceptance['accepted_at'] !== '' ? (string) mysql2date('M j, Y g:i A', $acceptance['accepted_at']) : '—'); ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<h2><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h2>
|
|
||||||
<?php if (empty($intake)) : ?>
|
|
||||||
<p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p>
|
|
||||||
<?php else : ?>
|
|
||||||
<table class="wp-list-table widefat fixed striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Context', 'unsupervised-schedular'); ?></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($intake as $row) : ?>
|
|
||||||
<tr>
|
|
||||||
<td><?php echo esc_html($row['question']); ?></td>
|
|
||||||
<td><?php echo esc_html($row['answer']); ?></td>
|
|
||||||
<td><?php echo esc_html($row['context']); ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($canBilling) : ?>
|
|
||||||
<h2><?php esc_html_e('Payment history', 'unsupervised-schedular'); ?></h2>
|
|
||||||
<?php if (empty($payments)) : ?>
|
|
||||||
<p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p>
|
|
||||||
<?php else : ?>
|
|
||||||
<table class="wp-list-table widefat fixed striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th><?php esc_html_e('Date', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Context', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Method', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Subtotal', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
|
|
||||||
<th><?php esc_html_e('Receipt', 'unsupervised-schedular'); ?></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($payments as $payment) : ?>
|
|
||||||
<tr>
|
|
||||||
<td><?php echo esc_html($payment['created_at'] !== '' ? (string) mysql2date('M j, Y g:i A', $payment['created_at']) : '—'); ?></td>
|
|
||||||
<td><?php echo esc_html($payment['context']); ?></td>
|
|
||||||
<td><?php echo esc_html($payment['method']); ?></td>
|
|
||||||
<td><?php echo esc_html($payment['status']); ?></td>
|
|
||||||
<td><?php echo esc_html(number_format_i18n($payment['amount'], 2)); ?></td>
|
|
||||||
<td><?php echo esc_html(number_format_i18n($payment['tax_amount'], 2)); ?></td>
|
|
||||||
<td><?php echo esc_html(number_format_i18n($payment['total'], 2) . ' ' . $payment['currency']); ?></td>
|
|
||||||
<td><?php echo esc_html($payment['receipt']); ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ if (! defined('ABSPATH')) {
|
|||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<div id="us-booking-app" data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>">
|
<div id="us-booking-app" data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>">
|
||||||
<div id="us-my-lessons"></div>
|
|
||||||
<div id="us-slot-list">
|
<div id="us-slot-list">
|
||||||
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ declare(strict_types=1);
|
|||||||
if (! defined('ABSPATH')) {
|
if (! defined('ABSPATH')) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var int $offeringId Offering id when the page is restricted to a single class; 0 for the full catalog. */
|
|
||||||
?>
|
?>
|
||||||
<div id="us-group-app"<?php echo $offeringId > 0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>
|
<div id="us-group-app">
|
||||||
<div id="us-group-list">
|
<div id="us-group-list">
|
||||||
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,86 +7,61 @@ if (! defined('ABSPATH')) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @var \Unsupervised\Schedular\Auth\Invite|null $invite
|
* @var \Unsupervised\Schedular\Auth\Invite|null $invite
|
||||||
* @var bool $inviteValid Whether $invite can still be redeemed — only then is the email fixed.
|
|
||||||
* @var string $token Raw invite token from the request (only its hash is stored).
|
|
||||||
* @var bool $canRegister
|
* @var bool $canRegister
|
||||||
* @var bool $open Whether open (self-approval) registration is enabled.
|
* @var bool $success
|
||||||
* @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm).
|
|
||||||
* @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'.
|
|
||||||
* @var string $loginUrl Where the post-confirmation sign-in link points.
|
|
||||||
* @var string $error
|
* @var string $error
|
||||||
* @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
* @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<div class="us-register-form">
|
<div class="us-register-form">
|
||||||
<?php if ($successType === 'invite') : ?>
|
<?php if ($success) : ?>
|
||||||
<p class="us-success"><?php esc_html_e('Your account has been created and you are now logged in.', 'unsupervised-schedular'); ?></p>
|
<p class="us-success"><?php esc_html_e('Your account has been created and you are now logged in.', 'unsupervised-schedular'); ?></p>
|
||||||
<?php elseif ($successType === 'confirm') : ?>
|
<?php elseif (! $canRegister) : ?>
|
||||||
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, a studio admin will review and approve your account.', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('Registration is by invitation only. Please use the link from your invitation email, or contact the studio.', 'unsupervised-schedular'); ?></p>
|
||||||
<?php elseif ($successType === 'confirm_group') : ?>
|
|
||||||
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, your account is ready to use.', 'unsupervised-schedular'); ?></p>
|
|
||||||
<?php elseif ($confirmResult === 'ready') : ?>
|
|
||||||
<p class="us-success"><?php esc_html_e('Thanks — your email is confirmed and your account is ready to use.', 'unsupervised-schedular'); ?></p>
|
|
||||||
<p><a href="<?php echo esc_url($loginUrl); ?>"><?php esc_html_e('Sign in to your account', 'unsupervised-schedular'); ?></a></p>
|
|
||||||
<?php elseif ($confirmResult === '1') : ?>
|
|
||||||
<p class="us-success"><?php esc_html_e('Thanks — your email is confirmed. Your account is now awaiting studio approval; we will email you when it is ready.', 'unsupervised-schedular'); ?></p>
|
|
||||||
<p><a href="<?php echo esc_url($loginUrl); ?>"><?php esc_html_e('Sign in to your account', 'unsupervised-schedular'); ?></a></p>
|
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<?php if ($confirmResult === 'expired') : ?>
|
<?php if ($error !== '') : ?>
|
||||||
<p class="us-error" role="alert"><?php esc_html_e('That confirmation link is invalid or has expired. Please contact the studio.', 'unsupervised-schedular'); ?></p>
|
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (! $canRegister) : ?>
|
<form method="post" action="">
|
||||||
<p><?php esc_html_e('Registration is by invitation only. Please use the link from your invitation email, or contact the studio.', 'unsupervised-schedular'); ?></p>
|
<?php wp_nonce_field('us_student_register'); ?>
|
||||||
<?php else : ?>
|
<input type="hidden" name="us_invite" value="<?php echo esc_attr($invite->token); ?>">
|
||||||
<?php if ($error !== '') : ?>
|
|
||||||
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
|
<p>
|
||||||
|
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
|
||||||
|
<input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="us-reg-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?></label>
|
||||||
|
<input type="text" name="display_name" id="us-reg-name" autocomplete="name" required>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label>
|
||||||
|
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="8" required>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php if (! empty($policyForms)) : ?>
|
||||||
|
<fieldset class="us-policies">
|
||||||
|
<legend><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></legend>
|
||||||
|
<?php foreach ($policyForms as $form) : ?>
|
||||||
|
<div class="us-policy">
|
||||||
|
<h4><?php echo esc_html($form['policy']->title); ?></h4>
|
||||||
|
<div class="us-policy-body"><?php echo wp_kses_post((string) $form['version']->body); ?></div>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
|
||||||
|
<?php
|
||||||
|
/* translators: %s: policy title */
|
||||||
|
echo esc_html(sprintf(__('I have read and agree to the %s.', 'unsupervised-schedular'), $form['policy']->title));
|
||||||
|
?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</fieldset>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="post" action="">
|
<p>
|
||||||
<?php wp_nonce_field('us_student_register'); ?>
|
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
|
||||||
<input type="hidden" name="us_invite" value="<?php echo esc_attr($token); ?>">
|
</p>
|
||||||
|
</form>
|
||||||
<p>
|
|
||||||
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
|
|
||||||
<?php if ($inviteValid && $invite !== null && ! $invite->isGroup()) : ?>
|
|
||||||
<input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly>
|
|
||||||
<?php else : ?>
|
|
||||||
<input type="email" name="email" id="us-reg-email" autocomplete="email" required>
|
|
||||||
<?php endif; ?>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<label for="us-reg-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?></label>
|
|
||||||
<input type="text" name="display_name" id="us-reg-name" autocomplete="name" required>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label>
|
|
||||||
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="8" required>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<?php if (! empty($policyForms)) : ?>
|
|
||||||
<fieldset class="us-policies">
|
|
||||||
<legend><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></legend>
|
|
||||||
<?php foreach ($policyForms as $form) : ?>
|
|
||||||
<div class="us-policy">
|
|
||||||
<h4><?php echo esc_html($form['policy']->title); ?></h4>
|
|
||||||
<div class="us-policy-body"><?php echo wp_kses_post((string) $form['version']->body); ?></div>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
|
|
||||||
<?php
|
|
||||||
/* translators: %s: policy title */
|
|
||||||
echo esc_html(sprintf(__('I have read and agree to the %s.', 'unsupervised-schedular'), $form['policy']->title));
|
|
||||||
?>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</fieldset>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
use Mockery;
|
|
||||||
use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
|
|
||||||
use Unsupervised\Schedular\Auth\RegistrationController;
|
|
||||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
|
||||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
|
||||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
|
||||||
|
|
||||||
class EmailConfirmationHandlerTest extends TestCase
|
|
||||||
{
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
Functions\when('wp_unslash')->alias(static fn ($v) => $v);
|
|
||||||
Functions\when('sanitize_key')->alias(static fn ($v) => $v);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
unset($_REQUEST['action']);
|
|
||||||
$_GET = [];
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stub everything maybeConfirm() needs for a valid token belonging to user
|
|
||||||
* 9, with wp_safe_redirect throwing so the redirect URL can be asserted.
|
|
||||||
*
|
|
||||||
* @param bool $autoApprove Whether user 9 carries the auto-approve marker.
|
|
||||||
*/
|
|
||||||
private function stubConfirmContext(bool $autoApprove): void
|
|
||||||
{
|
|
||||||
$this->stubMode(StudioSettings::MODE_INVITE);
|
|
||||||
Functions\when('is_admin')->justReturn(false);
|
|
||||||
Functions\when('sanitize_text_field')->returnArg();
|
|
||||||
Functions\when('get_permalink')->justReturn('http://wp/register/');
|
|
||||||
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
|
|
||||||
Functions\when('get_users')->justReturn([9]);
|
|
||||||
Functions\when('get_user_meta')->alias(static function (int $id, string $key) use ($autoApprove) {
|
|
||||||
if ($key === RegistrationStatus::META_CONFIRM_EXPIRES) {
|
|
||||||
return '2030-01-01 00:00:00';
|
|
||||||
}
|
|
||||||
if ($key === RegistrationStatus::META_AUTO_APPROVE) {
|
|
||||||
return $autoApprove ? '1' : '';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
Functions\when('update_user_meta')->justReturn(true);
|
|
||||||
Functions\when('delete_user_meta')->justReturn(true);
|
|
||||||
Functions\when('wp_safe_redirect')->alias(static function (string $url): void {
|
|
||||||
throw new \RuntimeException('redirect:' . $url);
|
|
||||||
});
|
|
||||||
|
|
||||||
$_GET['us_confirm'] = 'rawtoken';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testConfirmAutoApprovesGroupLinkSignupWithoutAdminReview(): void
|
|
||||||
{
|
|
||||||
$this->stubConfirmContext(true);
|
|
||||||
|
|
||||||
$user = Mockery::mock(\WP_User::class);
|
|
||||||
Functions\when('get_user_by')->justReturn($user);
|
|
||||||
|
|
||||||
$mailer = Mockery::mock(RegistrationMailer::class);
|
|
||||||
$mailer->shouldReceive('sendApproved')->once()->with($user)->andReturn(true);
|
|
||||||
$mailer->shouldNotReceive('notifyAdminsPending');
|
|
||||||
|
|
||||||
$handler = new EmailConfirmationHandler(new StudioSettings(), $mailer);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$handler->maybeConfirm();
|
|
||||||
self::fail('Expected a redirect');
|
|
||||||
} catch (\RuntimeException $e) {
|
|
||||||
self::assertStringContainsString('us_confirmed=ready', $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testConfirmWithoutAutoApproveNotifiesAdminsAndStaysPending(): void
|
|
||||||
{
|
|
||||||
$this->stubConfirmContext(false);
|
|
||||||
|
|
||||||
$user = Mockery::mock(\WP_User::class);
|
|
||||||
Functions\when('get_user_by')->justReturn($user);
|
|
||||||
|
|
||||||
$mailer = Mockery::mock(RegistrationMailer::class);
|
|
||||||
$mailer->shouldReceive('notifyAdminsPending')->once()->with($user)->andReturn(true);
|
|
||||||
$mailer->shouldNotReceive('sendApproved');
|
|
||||||
|
|
||||||
$handler = new EmailConfirmationHandler(new StudioSettings(), $mailer);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$handler->maybeConfirm();
|
|
||||||
self::fail('Expected a redirect');
|
|
||||||
} catch (\RuntimeException $e) {
|
|
||||||
self::assertStringContainsString('us_confirmed=1', $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handler(): EmailConfirmationHandler
|
|
||||||
{
|
|
||||||
return new EmailConfirmationHandler(new StudioSettings(), new RegistrationMailer());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function stubMode(string $mode): void
|
|
||||||
{
|
|
||||||
Functions\when('get_option')->alias(static function (string $name, $default = false) use ($mode) {
|
|
||||||
if ($name === StudioSettings::OPT_REGISTRATION_MODE) {
|
|
||||||
return $mode;
|
|
||||||
}
|
|
||||||
if ($name === RegistrationController::OPTION_PAGE) {
|
|
||||||
return 5;
|
|
||||||
}
|
|
||||||
return $default;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRegisterUrlPassesThroughWhenClosed(): void
|
|
||||||
{
|
|
||||||
$this->stubMode(StudioSettings::MODE_INVITE);
|
|
||||||
|
|
||||||
self::assertSame('http://wp/register', $this->handler()->registerUrl('http://wp/register'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRegisterUrlPointsAtRegistrationPageWhenOpen(): void
|
|
||||||
{
|
|
||||||
$this->stubMode(StudioSettings::MODE_SELF_APPROVAL);
|
|
||||||
Functions\when('get_permalink')->justReturn('http://studio.test/register');
|
|
||||||
|
|
||||||
self::assertSame('http://studio.test/register', $this->handler()->registerUrl('http://wp/register'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBlockNativeRegistrationIsNoOpWhenClosed(): void
|
|
||||||
{
|
|
||||||
$this->stubMode(StudioSettings::MODE_INVITE);
|
|
||||||
$_REQUEST['action'] = 'register';
|
|
||||||
|
|
||||||
// Must not redirect/exit when open registration is off.
|
|
||||||
Functions\expect('wp_safe_redirect')->never();
|
|
||||||
|
|
||||||
$this->handler()->blockNativeRegistration();
|
|
||||||
|
|
||||||
unset($_REQUEST['action']);
|
|
||||||
self::assertTrue(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBlockNativeRegistrationIgnoresOtherActions(): void
|
|
||||||
{
|
|
||||||
$this->stubMode(StudioSettings::MODE_SELF_APPROVAL);
|
|
||||||
$_REQUEST['action'] = 'lostpassword';
|
|
||||||
|
|
||||||
Functions\expect('wp_safe_redirect')->never();
|
|
||||||
|
|
||||||
$this->handler()->blockNativeRegistration();
|
|
||||||
|
|
||||||
unset($_REQUEST['action']);
|
|
||||||
self::assertTrue(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBlockRegistrationErrorsRejectsWhenOpen(): void
|
|
||||||
{
|
|
||||||
$this->stubMode(StudioSettings::MODE_SELF_APPROVAL);
|
|
||||||
|
|
||||||
$errors = $this->handler()->blockRegistrationErrors(new \WP_Error());
|
|
||||||
|
|
||||||
self::assertSame('us_registration_redirect', $errors->get_error_code());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBlockRegistrationErrorsPassesThroughWhenClosed(): void
|
|
||||||
{
|
|
||||||
$this->stubMode(StudioSettings::MODE_INVITE);
|
|
||||||
|
|
||||||
$errors = $this->handler()->blockRegistrationErrors(new \WP_Error());
|
|
||||||
|
|
||||||
self::assertSame('', $errors->get_error_code());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,55 +34,21 @@ class InviteRepositoryTest extends TestCase
|
|||||||
Mockery::on(static function (array $d): bool {
|
Mockery::on(static function (array $d): bool {
|
||||||
return $d['email'] === '[email protected]'
|
return $d['email'] === '[email protected]'
|
||||||
&& $d['token'] === 'tok123'
|
&& $d['token'] === 'tok123'
|
||||||
&& $d['kind'] === Invite::KIND_PERSONAL
|
|
||||||
&& $d['status'] === Invite::STATUS_PENDING
|
&& $d['status'] === Invite::STATUS_PENDING
|
||||||
&& $d['invited_by'] === 2
|
&& $d['invited_by'] === 2;
|
||||||
&& $d['expires_at'] === null;
|
|
||||||
}),
|
}),
|
||||||
['%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s']
|
['%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s']
|
||||||
);
|
);
|
||||||
$this->db->insert_id = 5;
|
$this->db->insert_id = 5;
|
||||||
|
|
||||||
self::assertSame(5, $this->repo->insert(new Invite('[email protected]', 'tok123', invitedBy: 2)));
|
self::assertSame(5, $this->repo->insert(new Invite('[email protected]', 'tok123', invitedBy: 2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testInsertReturnsZeroWhenDbInsertFails(): void
|
|
||||||
{
|
|
||||||
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00');
|
|
||||||
|
|
||||||
$this->db->shouldReceive('insert')->once()->andReturn(false);
|
|
||||||
$this->db->insert_id = 99; // Stale id from an earlier insert must not leak out.
|
|
||||||
|
|
||||||
self::assertSame(0, $this->repo->insert(new Invite('[email protected]', 'tok123')));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testInsertPersistsGroupKindAndExpiry(): void
|
|
||||||
{
|
|
||||||
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00');
|
|
||||||
|
|
||||||
$this->db->shouldReceive('insert')
|
|
||||||
->once()
|
|
||||||
->with(
|
|
||||||
'wp_us_invites',
|
|
||||||
Mockery::on(static function (array $d): bool {
|
|
||||||
return $d['email'] === ''
|
|
||||||
&& $d['kind'] === Invite::KIND_GROUP
|
|
||||||
&& $d['expires_at'] === '2026-08-31 23:59:59';
|
|
||||||
}),
|
|
||||||
Mockery::type('array')
|
|
||||||
);
|
|
||||||
$this->db->insert_id = 6;
|
|
||||||
|
|
||||||
$invite = new Invite('', 'tok456', invitedBy: 2, kind: Invite::KIND_GROUP, expiresAt: '2026-08-31 23:59:59');
|
|
||||||
|
|
||||||
self::assertSame(6, $this->repo->insert($invite));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFindByTokenReturnsInvite(): void
|
public function testFindByTokenReturnsInvite(): void
|
||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/token = %s/'), 'wp_us_invites', 'tok123')
|
->with(Mockery::pattern('/token = %s/'), 'tok123')
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_row')->andReturn($this->row());
|
$this->db->shouldReceive('get_row')->andReturn($this->row());
|
||||||
@@ -105,7 +71,7 @@ class InviteRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/email = %s AND status = %s/'), 'wp_us_invites', '[email protected]', Invite::STATUS_PENDING)
|
->with(Mockery::pattern('/email = %s AND status = %s/'), '[email protected]', Invite::STATUS_PENDING)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_row')->andReturn($this->row());
|
$this->db->shouldReceive('get_row')->andReturn($this->row());
|
||||||
@@ -117,7 +83,7 @@ class InviteRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/status = %s/'), 'wp_us_invites', Invite::STATUS_PENDING)
|
->with(Mockery::pattern('/status = %s/'), Invite::STATUS_PENDING)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);
|
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user