Upgrade PHPStan to 2.x and raise analysis level from 6 to 10
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.3) (pull_request) Successful in 52s
CI / Coding Standards (pull_request) Successful in 57s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m1s
CI / PHPStan (pull_request) Successful in 1m11s
CI / Build Plugin Zip (pull_request) Has been skipped

- Bump phpstan/phpstan ^2.0 and szepeviktor/phpstan-wordpress ^2.0
- Move the analysis level into phpstan.neon (single source) and raise it to 10
- Add Val, a runtime coercion helper that narrows untyped WordPress boundary
  values (wpdb rows, REST params, superglobals, options) with explicit checks
  instead of blind casts, plus unit tests
- Type value-object fromRow() params as stdClass (what wpdb returns) and map
  columns through Val so unexpected shapes degrade safely
- Use %i identifier placeholders for table names in all wpdb::prepare() calls
  so every query string is a literal and identifiers are escaped by WordPress;
  raises the minimum WordPress version to 6.2 where %i was introduced
- Guard wpdb::prepare() null result before wpdb::query() in updateTax()
- Fix nullable get_permalink()/strtotime() handling, list types at REST and
  capability call sites, dead null-coalescing on checked superglobals, and
  narrow get_users() results before mapping
- Register Val method names with the ValidatedSanitizedInput sniff so it
  validates the real sanitizer around each superglobal read
- Update repository unit tests for the %i placeholder arguments

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:42:50 -03:00
parent b23508f726
commit 1d6ac46ba3
67 changed files with 666 additions and 368 deletions
+2 -1
View File
@@ -70,6 +70,7 @@ All database access goes through repository classes within their domain package.
| `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\LoginPage` | Renders front-end student login form |
| `Availability\AvailabilitySlot` | Immutable value object for a slot row |
@@ -108,6 +109,6 @@ All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()
### CI
Gitea Actions (`.gitea/workflows/ci.yml`) runs on every push and pull request:
- **lint** — PHPCS WordPress coding standards
- **static-analysis** — PHPStan level 6
- **static-analysis** — PHPStan level 10
- **test** — PHPUnit on PHP 8.1, 8.2, 8.3
- **no-debug** — rejects commits with `var_dump`, `error_log`, etc. in `src/`
+3 -3
View File
@@ -11,8 +11,8 @@
"phpunit/phpunit": "^10.5",
"brain/monkey": "^2.6",
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^1.10",
"szepeviktor/phpstan-wordpress": "^1.3",
"phpstan/phpstan": "^2.0",
"szepeviktor/phpstan-wordpress": "^2.0",
"php-stubs/wordpress-stubs": "^6.0",
"squizlabs/php_codesniffer": "^3.7",
"wp-coding-standards/wpcs": "^3.0"
@@ -30,7 +30,7 @@
"scripts": {
"test": "phpunit --configuration phpunit.xml",
"test:coverage": "phpunit --configuration phpunit.xml --coverage-html coverage/",
"lint": "phpstan analyse src/ --level=6 --configuration phpstan.neon --memory-limit=1G",
"lint": "phpstan analyse --configuration phpstan.neon --memory-limit=1G",
"cs": "phpcs --standard=phpcs.xml.dist",
"cs:fix": "phpcbf --standard=phpcs.xml.dist",
"build": "bash bin/build-zip.sh"
+26 -1
View File
@@ -44,7 +44,32 @@
</properties>
</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. -->
<config name="minimum_supported_wp_version" value="6.0"/>
<config name="minimum_supported_wp_version" value="6.2"/>
<config name="testVersion" value="8.1-"/>
</ruleset>
+1 -1
View File
@@ -2,7 +2,7 @@ includes:
- vendor/szepeviktor/phpstan-wordpress/extension.neon
parameters:
level: 6
level: 10
paths:
- src
bootstrapFiles:
+3 -1
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Site-owner toggles for whether WordPress administrators automatically receive
* the studio-admin and/or instructor capabilities.
@@ -39,7 +41,7 @@ class AccessSettings {
* single-account behaviour.
*/
private function flag( string $option ): bool {
return '0' !== (string) get_option( $option, '1' );
return '0' !== Val::string( get_option( $option, '1' ) );
}
public function renderPage(): void {
+18 -12
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Studio-admin **Instructors** page: create instructor accounts and toggle each
* instructor's managed capabilities. Gated on `manage_instructors`. A studio
@@ -24,7 +26,7 @@ class InstructorController {
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only instructor selector.
$instructorId = absint( $_GET['instructor_id'] ?? 0 );
$instructorId = absint( Val::int( $_GET['instructor_id'] ?? 0 ) );
$instructor = $instructorId > 0 ? get_userdata( $instructorId ) : false;
if ( $instructor && in_array( RoleManager::INSTRUCTOR, (array) $instructor->roles, true ) ) {
@@ -50,12 +52,15 @@ class InstructorController {
'email' => $user->user_email,
'registered' => $user->user_registered,
],
get_users(
[
'role' => RoleManager::INSTRUCTOR,
'orderby' => 'display_name',
'order' => 'ASC',
]
array_filter(
get_users(
[
'role' => RoleManager::INSTRUCTOR,
'orderby' => 'display_name',
'order' => 'ASC',
]
),
static fn( mixed $user ): bool => $user instanceof \WP_User
)
);
@@ -66,7 +71,7 @@ class InstructorController {
private function handleFormAction(): string {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( 'create' === $action ) {
@@ -82,8 +87,8 @@ class InstructorController {
private function createInstructor(): string {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
$name = sanitize_text_field( wp_unslash( $_POST['display_name'] ?? '' ) );
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
$name = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( ! is_email( $email ) ) {
@@ -125,8 +130,9 @@ class InstructorController {
private function updateCaps(): string {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$instructorId = absint( $_POST['instructor_id'] ?? 0 );
$submitted = array_map( 'sanitize_key', (array) wp_unslash( $_POST['capabilities'] ?? [] ) );
$instructorId = absint( Val::int( $_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_values( array_map( static fn( mixed $cap ): string => sanitize_key( Val::string( $cap ) ), (array) wp_unslash( $_POST['capabilities'] ?? [] ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
$instructor = $instructorId > 0 ? get_userdata( $instructorId ) : false;
+12 -10
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
class Invite {
public const STATUS_PENDING = 'pending';
@@ -44,17 +46,17 @@ class Invite {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
email: $row->email,
token: $row->token,
role: $row->role,
status: $row->status,
invitedBy: null !== $row->invited_by ? (int) $row->invited_by : null,
acceptedUserId: null !== $row->accepted_user_id ? (int) $row->accepted_user_id : null,
acceptedAt: $row->accepted_at,
createdAt: $row->created_at ?? null,
id: (int) $row->id,
email: Val::string( $row->email ),
token: Val::string( $row->token ),
role: Val::string( $row->role ),
status: Val::string( $row->status ),
invitedBy: Val::intOrNull( $row->invited_by ),
acceptedUserId: Val::intOrNull( $row->accepted_user_id ),
acceptedAt: Val::stringOrNull( $row->accepted_at ),
createdAt: Val::stringOrNull( $row->created_at ?? null ),
id: Val::int( $row->id ),
);
}
+6 -4
View File
@@ -32,7 +32,7 @@ class InviteRepository {
public function findByToken( string $token ): ?Invite {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE token = %s", $token )
$this->db->prepare( 'SELECT * FROM %i WHERE token = %s', $this->table, $token )
);
return $row ? Invite::fromRow( $row ) : null;
@@ -40,7 +40,7 @@ class InviteRepository {
public function findById( int $id ): ?Invite {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Invite::fromRow( $row ) : null;
@@ -52,7 +52,8 @@ class InviteRepository {
public function findPendingByEmail( string $email ): ?Invite {
$row = $this->db->get_row(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE email = %s AND status = %s ORDER BY id DESC LIMIT 1",
'SELECT * FROM %i WHERE email = %s AND status = %s ORDER BY id DESC LIMIT 1',
$this->table,
$email,
Invite::STATUS_PENDING
)
@@ -69,7 +70,8 @@ class InviteRepository {
public function findPending(): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE status = %s ORDER BY created_at DESC",
'SELECT * FROM %i WHERE status = %s ORDER BY created_at DESC',
$this->table,
Invite::STATUS_PENDING
)
);
+5 -3
View File
@@ -3,12 +3,14 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
class LoginPage {
/**
* Renders the student login shortcode output.
*
* @param array<string, string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( is_user_logged_in() ) {
@@ -26,9 +28,9 @@ class LoginPage {
if ( isset( $_POST['us_login'] ) && check_admin_referer( 'us_student_login' ) ) {
$credentials = [
'user_login' => sanitize_user( wp_unslash( $_POST['log'] ?? '' ) ),
'user_login' => sanitize_user( Val::string( wp_unslash( $_POST['log'] ?? '' ) ) ),
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized.
'user_password' => wp_unslash( $_POST['pwd'] ?? '' ),
'user_password' => Val::string( wp_unslash( $_POST['pwd'] ?? '' ) ),
'remember' => isset( $_POST['rememberme'] ),
];
+8 -6
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
class RegistrationController {
/**
@@ -23,7 +25,7 @@ class RegistrationController {
}
$pendingInvites = $this->invites->findPending();
$registrationPageId = (int) get_option( self::OPTION_PAGE, 0 );
$registrationPageId = Val::int( get_option( self::OPTION_PAGE, 0 ) );
$registrationPageUrl = $registrationPageId > 0 ? (string) get_permalink( $registrationPageId ) : '';
include USC_PLUGIN_DIR . 'templates/admin/invites.php';
@@ -37,14 +39,14 @@ class RegistrationController {
private function handleFormAction(): string {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'set_page' === $action ) {
update_option( self::OPTION_PAGE, absint( $_POST['registration_page_id'] ?? 0 ) );
update_option( self::OPTION_PAGE, absint( Val::int( $_POST['registration_page_id'] ?? 0 ) ) );
}
if ( 'invite' === $action ) {
$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
if (
is_email( $email )
@@ -66,7 +68,7 @@ class RegistrationController {
}
if ( 'revoke' === $action ) {
$inviteId = absint( $_POST['invite_id'] ?? 0 );
$inviteId = absint( Val::int( $_POST['invite_id'] ?? 0 ) );
if ( $inviteId > 0 ) {
$this->invites->revoke( $inviteId );
}
@@ -80,7 +82,7 @@ class RegistrationController {
* Build the registration URL for a raw invite token.
*/
private function registrationLink( string $rawToken ): string {
$pageId = (int) get_option( self::OPTION_PAGE, 0 );
$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( '/' ) );
+10 -8
View File
@@ -8,6 +8,7 @@ use Unsupervised\Schedular\Policy\Policy;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Val;
class RegistrationPage {
@@ -21,7 +22,7 @@ class RegistrationPage {
/**
* Renders the student registration shortcode output.
*
* @param array<string, string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( is_user_logged_in() ) {
@@ -29,7 +30,7 @@ class RegistrationPage {
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked below.
$token = sanitize_text_field( wp_unslash( $_REQUEST['us_invite'] ?? '' ) );
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) );
// Only the token's hash is stored, so hash the submitted token for lookup.
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
@@ -64,12 +65,12 @@ class RegistrationPage {
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only token used only to build the redirect target.
$token = sanitize_text_field( wp_unslash( $_GET['us_invite'] ?? '' ) );
$token = sanitize_text_field( Val::string( wp_unslash( $_GET['us_invite'] ?? '' ) ) );
if ( '' === $token ) {
return;
}
$pageId = (int) get_option( RegistrationController::OPTION_PAGE, 0 );
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
if ( $pageId <= 0 || is_page( $pageId ) ) {
return;
}
@@ -90,15 +91,16 @@ class RegistrationPage {
// The submit nonce is verified by the caller (render) before this runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized.
$password = (string) wp_unslash( $_POST['password'] ?? '' );
$displayName = sanitize_text_field( wp_unslash( $_POST['display_name'] ?? '' ) );
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) );
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
if ( strlen( $password ) < 8 ) {
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
}
$policyForms = $this->signupPolicies();
$accepted = array_map( 'absint', (array) ( $_POST['accept'] ?? [] ) );
// 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( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
foreach ( $policyForms as $form ) {
@@ -141,7 +143,7 @@ class RegistrationPage {
*/
private function recordAcceptances( array $policyForms, int $userId ): void {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP is stored verbatim for audit.
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
foreach ( $policyForms as $form ) {
$this->acceptances->insert(
+13 -9
View File
@@ -11,6 +11,7 @@ use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Val;
class StudentController {
@@ -28,7 +29,7 @@ class StudentController {
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only student selector.
$studentId = absint( $_GET['student_id'] ?? 0 );
$studentId = absint( Val::int( $_GET['student_id'] ?? 0 ) );
$student = $studentId > 0 ? get_userdata( $studentId ) : false;
if ( $student && in_array( RoleManager::STUDENT, (array) $student->roles, true ) ) {
@@ -45,12 +46,15 @@ class StudentController {
'upcoming' => $this->bookings->countUpcomingForStudent( (int) $user->ID ),
'enrolments' => $this->enrollments->countActiveForStudent( (int) $user->ID ),
],
get_users(
[
'role' => RoleManager::STUDENT,
'orderby' => 'display_name',
'order' => 'ASC',
]
array_filter(
get_users(
[
'role' => RoleManager::STUDENT,
'orderby' => 'display_name',
'order' => 'ASC',
]
),
static fn( mixed $user ): bool => $user instanceof \WP_User
)
);
@@ -63,7 +67,7 @@ class StudentController {
if ( $canBilling && isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_student_billing' ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
$method = sanitize_key( wp_unslash( $_POST['payment_method'] ?? '' ) );
$method = sanitize_key( Val::string( wp_unslash( $_POST['payment_method'] ?? '' ) ) );
if ( in_array( $method, Payment::VALID_METHODS, true ) ) {
update_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, $method );
} else {
@@ -71,7 +75,7 @@ class StudentController {
}
}
$billingOverride = (string) get_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, true );
$billingOverride = Val::string( get_user_meta( (int) $student->ID, BillingMethodResolver::META_METHOD, true ) );
$billingDefault = $this->resolver->defaultMethod();
$now = current_time( 'mysql' );
+7 -5
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Pure helper for splitting a student's dated rows into upcoming and past.
*/
@@ -21,7 +23,7 @@ class StudentSchedule {
$past = [];
foreach ( $rows as $row ) {
$start = (string) ( $row['start_dt'] ?? '' );
$start = Val::string( $row['start_dt'] ?? '' );
if ( '' !== $start && $start >= $now ) {
$upcoming[] = $row;
} else {
@@ -29,12 +31,12 @@ class StudentSchedule {
}
}
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( (string) ( $b['start_dt'] ?? '' ), (string) ( $a['start_dt'] ?? '' ) ) );
usort( $upcoming, static fn( array $a, array $b ): int => strcmp( Val::string( $a['start_dt'] ?? '' ), Val::string( $b['start_dt'] ?? '' ) ) );
usort( $past, static fn( array $a, array $b ): int => strcmp( Val::string( $b['start_dt'] ?? '' ), Val::string( $a['start_dt'] ?? '' ) ) );
return [
'upcoming' => array_values( $upcoming ),
'past' => array_values( $past ),
'upcoming' => $upcoming,
'past' => $past,
];
}
}
+9 -8
View File
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
class AvailabilityController {
@@ -34,14 +35,14 @@ class AvailabilityController {
private function handleFormAction( int $instructorId ): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) {
$this->addSlot( $instructorId );
}
if ( 'delete' === $action ) {
$slotId = absint( $_POST['slot_id'] ?? 0 );
$slotId = absint( Val::int( $_POST['slot_id'] ?? 0 ) );
if ( $slotId > 0 ) {
$slot = $this->repository->findById( $slotId );
if ( $slot && $slot->instructorId === $instructorId ) {
@@ -54,15 +55,15 @@ class AvailabilityController {
private function addSlot( int $instructorId ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( wp_unslash( $_POST['start_dt'] ?? '' ) ) );
$endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( wp_unslash( $_POST['end_dt'] ?? '' ) ) );
$startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ) );
$endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ) );
if ( null === $startDt || null === $endDt || $endDt <= $startDt ) {
return;
}
$offeringId = absint( $_POST['offering_id'] ?? 0 );
$duration = absint( $_POST['duration_minutes'] ?? 0 );
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
$slot = new AvailabilitySlot(
instructorId: $instructorId,
@@ -72,8 +73,8 @@ class AvailabilityController {
offeringId: $offeringId > 0 ? $offeringId : null,
);
if ( 'weekly' === sanitize_key( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ) {
$this->repository->createWeeklySeries( $slot, absint( $_POST['weeks'] ?? 1 ) );
if ( 'weekly' === sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ) ) {
$this->repository->createWeeklySeries( $slot, absint( Val::int( $_POST['weeks'] ?? 1 ) ) );
return;
}
+17 -11
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
class AvailabilityEndpoint {
@@ -13,6 +14,11 @@ class AvailabilityEndpoint {
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 {
register_rest_route(
$route_namespace,
@@ -96,11 +102,11 @@ class AvailabilityEndpoint {
public function index( \WP_REST_Request $request ): \WP_REST_Response {
$slots = $this->repository->findAvailable(
(int) $request->get_param( 'instructor_id' ),
(int) $request->get_param( 'offering_id' ),
(int) $request->get_param( 'duration_minutes' ),
(string) $request->get_param( 'from' ),
(string) $request->get_param( 'to' ),
Val::int( $request->get_param( 'instructor_id' ) ),
Val::int( $request->get_param( 'offering_id' ) ),
Val::int( $request->get_param( 'duration_minutes' ) ),
Val::string( $request->get_param( 'from' ) ),
Val::string( $request->get_param( 'to' ) ),
);
return new \WP_REST_Response( array_map( fn( AvailabilitySlot $s ) => $s->toArray(), $slots ), 200 );
@@ -108,8 +114,8 @@ class AvailabilityEndpoint {
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$instructorId = get_current_user_id();
$offeringId = absint( $request->get_param( 'offering_id' ) );
$duration = absint( $request->get_param( 'duration_minutes' ) );
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$duration = absint( Val::int( $request->get_param( 'duration_minutes' ) ) );
// 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.
@@ -120,8 +126,8 @@ class AvailabilityEndpoint {
}
}
$startDt = AvailabilitySlot::normalizeDateTime( (string) $request->get_param( 'start_dt' ) );
$endDt = AvailabilitySlot::normalizeDateTime( (string) $request->get_param( 'end_dt' ) );
$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 ] );
@@ -136,7 +142,7 @@ class AvailabilityEndpoint {
);
if ( 'weekly' === $request->get_param( 'recurrence' ) ) {
$ids = $this->repository->createWeeklySeries( $slot, absint( $request->get_param( 'weeks' ) ) );
$ids = $this->repository->createWeeklySeries( $slot, absint( Val::int( $request->get_param( 'weeks' ) ) ) );
return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
}
@@ -147,7 +153,7 @@ class AvailabilityEndpoint {
}
public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$slot = $this->repository->findById( $id );
if ( null === $slot ) {
+9 -7
View File
@@ -116,11 +116,11 @@ class AvailabilityRepository {
}
$whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC";
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY start_dt ASC";
$rows = $params
? $this->db->get_results( $this->db->prepare( $sql, $params ) )
: $this->db->get_results( $sql );
$rows = $this->db->get_results(
$this->db->prepare( $sql, array_merge( [ $this->table ], $params ) )
);
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
@@ -133,7 +133,8 @@ class AvailabilityRepository {
public function findByInstructor( int $instructorId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY start_dt ASC",
'SELECT * FROM %i WHERE instructor_id = %d ORDER BY start_dt ASC',
$this->table,
$instructorId
)
);
@@ -149,7 +150,8 @@ class AvailabilityRepository {
public function findUnbookedInGroup( int $recurrenceGroup ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE recurrence_group = %d AND is_booked = 0 ORDER BY start_dt ASC",
'SELECT * FROM %i WHERE recurrence_group = %d AND is_booked = 0 ORDER BY start_dt ASC',
$this->table,
$recurrenceGroup
)
);
@@ -159,7 +161,7 @@ class AvailabilityRepository {
public function findById( int $id ): ?AvailabilitySlot {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? AvailabilitySlot::fromRow( $row ) : null;
+11 -9
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Val;
class AvailabilitySlot {
public function __construct(
@@ -35,16 +37,16 @@ class AvailabilitySlot {
return null;
}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
instructorId: (int) $row->instructor_id,
startDt: $row->start_dt,
endDt: $row->end_dt,
durationMinutes: (int) $row->duration_minutes,
offeringId: null !== $row->offering_id ? (int) $row->offering_id : null,
isBooked: (bool) $row->is_booked,
recurrenceGroup: null !== $row->recurrence_group ? (int) $row->recurrence_group : null,
id: (int) $row->id,
instructorId: Val::int( $row->instructor_id ),
startDt: Val::string( $row->start_dt ),
endDt: Val::string( $row->end_dt ),
durationMinutes: Val::int( $row->duration_minutes ),
offeringId: Val::intOrNull( $row->offering_id ),
isBooked: Val::bool( $row->is_booked ),
recurrenceGroup: Val::intOrNull( $row->recurrence_group ),
id: Val::int( $row->id ),
);
}
+14 -8
View File
@@ -10,6 +10,7 @@ use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Val;
class BookingEndpoint {
@@ -27,6 +28,11 @@ class BookingEndpoint {
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 {
register_rest_route(
$route_namespace,
@@ -103,7 +109,7 @@ class BookingEndpoint {
}
public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$slotId = (int) $request->get_param( 'slot_id' );
$slotId = Val::int( $request->get_param( 'slot_id' ) );
$slot = $this->availability->findById( $slotId );
if ( null === $slot ) {
@@ -120,7 +126,7 @@ class BookingEndpoint {
// used must belong to the slot's instructor. This prevents substituting a
// cheaper/free offering to dodge payment, or another instructor's offering
// to misroute it.
$requestedOfferingId = absint( $request->get_param( 'offering_id' ) );
$requestedOfferingId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$slotOfferingId = (int) ( $slot->offeringId ?? 0 );
if ( $slotOfferingId > 0 ) {
@@ -142,7 +148,7 @@ class BookingEndpoint {
}
$answers = $this->answers( $request );
$acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) );
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) );
$gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds );
if ( $gateError instanceof \WP_Error ) {
@@ -150,7 +156,7 @@ class BookingEndpoint {
}
$studentId = get_current_user_id();
$notes = (string) $request->get_param( 'notes' );
$notes = Val::string( $request->get_param( 'notes' ) );
$recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' )
? Lesson::RECURRENCE_WEEKLY
: Lesson::RECURRENCE_SINGLE;
@@ -212,7 +218,7 @@ class BookingEndpoint {
private function answers( \WP_REST_Request $request ): array {
$out = [];
foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) {
$out[ (int) $questionId ] = sanitize_text_field( (string) $value );
$out[ (int) $questionId ] = sanitize_text_field( Val::string( $value ) );
}
return $out;
@@ -220,13 +226,13 @@ class BookingEndpoint {
private function clientIp(): ?string {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
return '' !== $ip ? $ip : null;
}
public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$lesson = $this->bookings->findById( $id );
if ( null === $lesson ) {
@@ -237,7 +243,7 @@ class BookingEndpoint {
return new \WP_Error( 'forbidden', __( 'You cannot update this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
$this->bookings->updateStatus( $id, (string) $request->get_param( 'status' ) );
$this->bookings->updateStatus( $id, Val::string( $request->get_param( 'status' ) ) );
return new \WP_REST_Response(
[
+4 -2
View File
@@ -10,14 +10,16 @@ class BookingPage {
/**
* Renders the booking shortcode output.
*
* @param array<string, string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( ! is_user_logged_in() ) {
$permalink = get_permalink();
return sprintf(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url( wp_login_url( get_permalink() ) ),
esc_url( wp_login_url( false === $permalink ? '' : $permalink ) ),
esc_html__( 'log in to book a lesson', 'unsupervised-schedular' )
);
}
+18 -11
View File
@@ -80,7 +80,7 @@ class BookingRepository {
public function findById( int $id ): ?Lesson {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Lesson::fromRow( $row ) : null;
@@ -96,12 +96,14 @@ class BookingRepository {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT l.* FROM {$this->table} l
JOIN {$avTable} a ON a.id = l.slot_id
'SELECT l.* FROM %i l
JOIN %i a ON a.id = l.slot_id
WHERE l.instructor_id = %d
AND l.status != %s
AND a.start_dt >= %s
ORDER BY a.start_dt ASC",
ORDER BY a.start_dt ASC',
$this->table,
$avTable,
$instructorId,
Lesson::STATUS_CANCELLED,
current_time( 'mysql' )
@@ -119,11 +121,13 @@ class BookingRepository {
return (int) $this->db->get_var(
$this->db->prepare(
"SELECT COUNT(*) FROM {$this->table} l
JOIN {$avTable} a ON a.id = l.slot_id
'SELECT COUNT(*) FROM %i l
JOIN %i a ON a.id = l.slot_id
WHERE l.student_id = %d
AND l.status != %s
AND a.start_dt >= %s",
AND a.start_dt >= %s',
$this->table,
$avTable,
$studentId,
Lesson::STATUS_CANCELLED,
current_time( 'mysql' )
@@ -139,7 +143,8 @@ class BookingRepository {
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY created_at DESC",
'SELECT * FROM %i WHERE student_id = %d ORDER BY created_at DESC',
$this->table,
$studentId
)
);
@@ -157,11 +162,13 @@ class BookingRepository {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT l.* FROM {$this->table} l
JOIN {$avTable} a ON a.id = l.slot_id
'SELECT l.* FROM %i l
JOIN %i a ON a.id = l.slot_id
WHERE l.status != %s
AND a.start_dt >= %s
ORDER BY a.start_dt ASC",
ORDER BY a.start_dt ASC',
$this->table,
$avTable,
Lesson::STATUS_CANCELLED,
current_time( 'mysql' )
)
+13 -11
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Val;
class Lesson {
public const STATUS_PENDING = 'pending';
@@ -39,18 +41,18 @@ class Lesson {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
slotId: (int) $row->slot_id,
studentId: (int) $row->student_id,
instructorId: (int) $row->instructor_id,
offeringId: null !== $row->offering_id ? (int) $row->offering_id : null,
recurrence: $row->recurrence,
seriesId: null !== $row->series_id ? (int) $row->series_id : null,
status: $row->status,
paymentId: null !== $row->payment_id ? (int) $row->payment_id : null,
notes: $row->notes,
id: (int) $row->id,
slotId: Val::int( $row->slot_id ),
studentId: Val::int( $row->student_id ),
instructorId: Val::int( $row->instructor_id ),
offeringId: Val::intOrNull( $row->offering_id ),
recurrence: Val::string( $row->recurrence ),
seriesId: Val::intOrNull( $row->series_id ),
status: Val::string( $row->status ),
paymentId: Val::intOrNull( $row->payment_id ),
notes: Val::stringOrNull( $row->notes ),
id: Val::int( $row->id ),
);
}
+6 -4
View File
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Val;
class LessonController {
@@ -48,10 +49,11 @@ class LessonController {
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$paymentId = absint( $_POST['payment_id'] ?? 0 );
$email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) );
$taxRate = isset( $_POST['tax_rate'] ) ? max( 0.0, (float) $_POST['tax_rate'] ) : 0.0;
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
$paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) );
$email = sanitize_email( Val::string( 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, Val::float( $_POST['tax_rate'] ) ) : 0.0;
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( $paymentId <= 0 || ! in_array( $action, [ 'set_etransfer', 'set_tax' ], true ) ) {
+9 -7
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Val;
class Enrollment {
public const STATUS_ACTIVE = 'active';
@@ -25,14 +27,14 @@ class Enrollment {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
offeringId: (int) $row->offering_id,
studentId: (int) $row->student_id,
instructorId: (int) $row->instructor_id,
status: $row->status,
paymentId: null !== $row->payment_id ? (int) $row->payment_id : null,
id: (int) $row->id,
offeringId: Val::int( $row->offering_id ),
studentId: Val::int( $row->student_id ),
instructorId: Val::int( $row->instructor_id ),
status: Val::string( $row->status ),
paymentId: Val::intOrNull( $row->payment_id ),
id: Val::int( $row->id ),
);
}
+10 -4
View File
@@ -10,6 +10,7 @@ use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Val;
class EnrollmentEndpoint {
@@ -20,6 +21,11 @@ class EnrollmentEndpoint {
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 {
register_rest_route(
$route_namespace,
@@ -69,7 +75,7 @@ class EnrollmentEndpoint {
}
public function enroll( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$offeringId = absint( $request->get_param( 'offering_id' ) );
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
@@ -87,7 +93,7 @@ class EnrollmentEndpoint {
}
$answers = $this->answers( $request );
$acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) );
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) );
$gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds );
if ( $gateError instanceof \WP_Error ) {
@@ -133,7 +139,7 @@ class EnrollmentEndpoint {
private function answers( \WP_REST_Request $request ): array {
$out = [];
foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) {
$out[ (int) $questionId ] = sanitize_text_field( (string) $value );
$out[ (int) $questionId ] = sanitize_text_field( Val::string( $value ) );
}
return $out;
@@ -141,7 +147,7 @@ class EnrollmentEndpoint {
private function clientIp(): ?string {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
return '' !== $ip ? $ip : null;
}
+13 -7
View File
@@ -30,7 +30,7 @@ class EnrollmentRepository {
public function findById( int $id ): ?Enrollment {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Enrollment::fromRow( $row ) : null;
@@ -42,7 +42,8 @@ class EnrollmentRepository {
public function countActiveForOffering( int $offeringId ): int {
return (int) $this->db->get_var(
$this->db->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND status = %s",
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND status = %s',
$this->table,
$offeringId,
Enrollment::STATUS_ACTIVE
)
@@ -55,7 +56,8 @@ class EnrollmentRepository {
public function countActiveForStudent( int $studentId ): int {
return (int) $this->db->get_var(
$this->db->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE student_id = %d AND status = %s",
'SELECT COUNT(*) FROM %i WHERE student_id = %d AND status = %s',
$this->table,
$studentId,
Enrollment::STATUS_ACTIVE
)
@@ -68,7 +70,8 @@ class EnrollmentRepository {
public function hasActiveEnrollment( int $offeringId, int $studentId ): bool {
$count = (int) $this->db->get_var(
$this->db->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND student_id = %d AND status = %s",
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND student_id = %d AND status = %s',
$this->table,
$offeringId,
$studentId,
Enrollment::STATUS_ACTIVE
@@ -86,7 +89,8 @@ class EnrollmentRepository {
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY enrolled_at DESC",
'SELECT * FROM %i WHERE student_id = %d ORDER BY enrolled_at DESC',
$this->table,
$studentId
)
);
@@ -102,7 +106,8 @@ class EnrollmentRepository {
public function findByInstructor( int $instructorId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY enrolled_at DESC",
'SELECT * FROM %i WHERE instructor_id = %d ORDER BY enrolled_at DESC',
$this->table,
$instructorId
)
);
@@ -118,7 +123,8 @@ class EnrollmentRepository {
public function findAllActive(): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE status = %s ORDER BY enrolled_at DESC",
'SELECT * FROM %i WHERE status = %s ORDER BY enrolled_at DESC',
$this->table,
Enrollment::STATUS_ACTIVE
)
);
+4 -2
View File
@@ -10,14 +10,16 @@ class GroupClassPage {
/**
* Renders the group-class enrolment shortcode output.
*
* @param array<string, string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( ! is_user_logged_in() ) {
$permalink = get_permalink();
return sprintf(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url( wp_login_url( get_permalink() ) ),
esc_url( wp_login_url( false === $permalink ? '' : $permalink ) ),
esc_html__( 'log in to enrol in a class', 'unsupervised-schedular' )
);
}
+3
View File
@@ -16,6 +16,9 @@ class Installer {
private function createTables(): void {
global $wpdb;
if ( ! $wpdb instanceof \wpdb ) {
return;
}
$charset = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+19 -17
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Val;
class Offering {
public const KIND_PRIVATE_LESSON = 'private_lesson';
@@ -44,24 +46,24 @@ class Offering {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
instructorId: (int) $row->instructor_id,
kind: $row->kind,
title: $row->title,
price: (float) $row->price,
currency: $row->currency,
billingMode: $row->billing_mode,
description: $row->description,
durationMinutes: null !== $row->duration_minutes ? (int) $row->duration_minutes : null,
allowWeekly: (bool) $row->allow_weekly,
capacity: null !== $row->capacity ? (int) $row->capacity : null,
termStart: $row->term_start,
termEnd: $row->term_end,
scheduleNote: $row->schedule_note,
etransferEmail: $row->etransfer_email,
isActive: (bool) $row->is_active,
id: (int) $row->id,
instructorId: Val::int( $row->instructor_id ),
kind: Val::string( $row->kind ),
title: Val::string( $row->title ),
price: Val::float( $row->price ),
currency: Val::string( $row->currency ),
billingMode: Val::string( $row->billing_mode ),
description: Val::stringOrNull( $row->description ),
durationMinutes: Val::intOrNull( $row->duration_minutes ),
allowWeekly: Val::bool( $row->allow_weekly ),
capacity: Val::intOrNull( $row->capacity ),
termStart: Val::stringOrNull( $row->term_start ),
termEnd: Val::stringOrNull( $row->term_end ),
scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
isActive: Val::bool( $row->is_active ),
id: Val::int( $row->id ),
);
}
+11 -10
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class OfferingController {
@@ -31,14 +32,14 @@ class OfferingController {
private function handleFormAction( int $instructorId, bool $manageAll ): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) {
$this->addOffering( $instructorId );
}
if ( 'delete' === $action ) {
$offeringId = absint( $_POST['offering_id'] ?? 0 );
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
if ( $offeringId > 0 ) {
$offering = $this->repository->findById( $offeringId );
if ( $offering && ( $manageAll || $offering->instructorId === $instructorId ) ) {
@@ -51,33 +52,33 @@ class OfferingController {
private function addOffering( int $instructorId ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) );
$kind = sanitize_key( wp_unslash( $_POST['kind'] ?? '' ) );
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) );
$kind = sanitize_key( Val::string( wp_unslash( $_POST['kind'] ?? '' ) ) );
if ( '' === $title || ! in_array( $kind, Offering::VALID_KINDS, true ) ) {
return;
}
$billingMode = sanitize_key( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) );
$billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) );
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
$billingMode = Offering::BILLING_ONE_TIME;
}
$duration = absint( $_POST['duration_minutes'] ?? 0 );
$capacity = absint( $_POST['capacity'] ?? 0 );
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
$capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) );
$this->repository->insert(
new Offering(
instructorId: $instructorId,
kind: $kind,
title: $title,
price: max( 0.0, (float) sanitize_text_field( wp_unslash( $_POST['price'] ?? '0' ) ) ),
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ),
billingMode: $billingMode,
durationMinutes: $duration > 0 ? $duration : null,
allowWeekly: isset( $_POST['allow_weekly'] ),
capacity: $capacity > 0 ? $capacity : null,
scheduleNote: $this->nullableText( sanitize_text_field( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ),
etransferEmail: $this->nullableText( sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ),
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
)
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
+22 -16
View File
@@ -4,11 +4,17 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class OfferingEndpoint {
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 {
register_rest_route(
$route_namespace,
@@ -57,8 +63,8 @@ class OfferingEndpoint {
public function index( \WP_REST_Request $request ): \WP_REST_Response {
$offerings = $this->repository->findAll(
(int) $request->get_param( 'instructor_id' ),
(string) $request->get_param( 'kind' ),
Val::int( $request->get_param( 'instructor_id' ) ),
Val::string( $request->get_param( 'kind' ) ),
activeOnly: true,
);
@@ -67,17 +73,17 @@ class OfferingEndpoint {
}
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$title = sanitize_text_field( (string) $request->get_param( 'title' ) );
$title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) );
if ( '' === $title ) {
return $this->invalid( __( 'A title is required.', 'unsupervised-schedular' ) );
}
$kind = (string) $request->get_param( 'kind' );
$kind = Val::string( $request->get_param( 'kind' ) );
if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) {
return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) );
}
$billingMode = (string) ( $request->get_param( 'billing_mode' ) ?? Offering::BILLING_ONE_TIME );
$billingMode = Val::string( $request->get_param( 'billing_mode' ) ?? Offering::BILLING_ONE_TIME );
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
}
@@ -87,7 +93,7 @@ class OfferingEndpoint {
kind: $kind,
title: $title,
price: $this->price( $request->get_param( 'price' ) ),
currency: sanitize_text_field( (string) ( $request->get_param( 'currency' ) ?? 'CAD' ) ),
currency: sanitize_text_field( Val::string( $request->get_param( 'currency' ) ?? 'CAD' ) ),
billingMode: $billingMode,
description: $this->nullableText( $request->get_param( 'description' ) ),
durationMinutes: $this->nullableInt( $request->get_param( 'duration_minutes' ) ),
@@ -106,7 +112,7 @@ class OfferingEndpoint {
}
public function update( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$existing = $this->repository->findById( $id );
if ( null === $existing ) {
@@ -117,12 +123,12 @@ class OfferingEndpoint {
return new \WP_Error( 'forbidden', __( 'You cannot edit this offering.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
$kind = $request->has_param( 'kind' ) ? (string) $request->get_param( 'kind' ) : $existing->kind;
$kind = $request->has_param( 'kind' ) ? Val::string( $request->get_param( 'kind' ) ) : $existing->kind;
if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) {
return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) );
}
$billingMode = $request->has_param( 'billing_mode' ) ? (string) $request->get_param( 'billing_mode' ) : $existing->billingMode;
$billingMode = $request->has_param( 'billing_mode' ) ? Val::string( $request->get_param( 'billing_mode' ) ) : $existing->billingMode;
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
}
@@ -130,9 +136,9 @@ class OfferingEndpoint {
$offering = new Offering(
instructorId: $existing->instructorId,
kind: $kind,
title: $request->has_param( 'title' ) ? sanitize_text_field( (string) $request->get_param( 'title' ) ) : $existing->title,
title: $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title,
price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price,
currency: $request->has_param( 'currency' ) ? sanitize_text_field( (string) $request->get_param( 'currency' ) ) : $existing->currency,
currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::string( $request->get_param( 'currency' ) ) ) : $existing->currency,
billingMode: $billingMode,
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,
@@ -152,7 +158,7 @@ class OfferingEndpoint {
}
public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$existing = $this->repository->findById( $id );
if ( null === $existing ) {
@@ -195,17 +201,17 @@ class OfferingEndpoint {
}
private function price( mixed $value ): float {
return max( 0.0, (float) $value );
return max( 0.0, Val::float( $value ) );
}
private function nullableEmail( mixed $value ): ?string {
$email = sanitize_email( (string) $value );
$email = sanitize_email( Val::string( $value ) );
return '' !== $email ? $email : null;
}
private function nullableInt( mixed $value ): ?int {
return ( null === $value || '' === $value ) ? null : (int) $value;
return ( null === $value || '' === $value ) ? null : Val::int( $value );
}
private function nullableText( mixed $value ): ?string {
@@ -213,6 +219,6 @@ class OfferingEndpoint {
return null;
}
return sanitize_text_field( (string) $value );
return sanitize_text_field( Val::string( $value ) );
}
}
+5 -5
View File
@@ -90,18 +90,18 @@ class OfferingRepository {
}
$whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY title ASC";
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY title ASC";
$rows = $params
? $this->db->get_results( $this->db->prepare( $sql, $params ) )
: $this->db->get_results( $sql );
$rows = $this->db->get_results(
$this->db->prepare( $sql, array_merge( [ $this->table ], $params ) )
);
return array_map( Offering::fromRow( ... ), $rows ?? [] );
}
public function findById( int $id ): ?Offering {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Offering::fromRow( $row ) : null;
+3 -1
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Val;
/**
* 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
@@ -15,7 +17,7 @@ class BillingMethodResolver {
public function __construct( private StudioSettings $settings ) {}
public function resolve( int $studentId ): string {
$override = (string) get_user_meta( $studentId, self::META_METHOD, true );
$override = Val::string( get_user_meta( $studentId, self::META_METHOD, true ) );
if ( in_array( $override, Payment::VALID_METHODS, true ) ) {
return $override;
}
+19 -17
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Val;
class Payment {
public const METHOD_CARD = 'card';
@@ -50,24 +52,24 @@ class Payment {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
studentId: (int) $row->student_id,
instructorId: (int) $row->instructor_id,
registrationType: $row->registration_type,
registrationId: (int) $row->registration_id,
amount: (float) $row->amount,
currency: $row->currency,
method: $row->method,
status: $row->status,
taxRate: (float) $row->tax_rate,
taxAmount: (float) $row->tax_amount,
etransferEmail: $row->etransfer_email,
stripePaymentIntentId: $row->stripe_payment_intent_id,
receiptNumber: $row->receipt_number,
receiptSentAt: $row->receipt_sent_at,
paidAt: $row->paid_at,
id: (int) $row->id,
studentId: Val::int( $row->student_id ),
instructorId: Val::int( $row->instructor_id ),
registrationType: Val::string( $row->registration_type ),
registrationId: Val::int( $row->registration_id ),
amount: Val::float( $row->amount ),
currency: Val::string( $row->currency ),
method: Val::string( $row->method ),
status: Val::string( $row->status ),
taxRate: Val::float( $row->tax_rate ),
taxAmount: Val::float( $row->tax_amount ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ),
receiptNumber: Val::stringOrNull( $row->receipt_number ),
receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ),
paidAt: Val::stringOrNull( $row->paid_at ),
id: Val::int( $row->id ),
);
}
+4 -3
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PaymentController {
@@ -19,10 +20,10 @@ class PaymentController {
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_payment_action' ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
if ( 'mark_paid' === sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ) ) {
if ( 'mark_paid' === sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$paymentId = absint( $_POST['payment_id'] ?? 0 );
$email = sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) );
$paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) );
$email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( $paymentId > 0 ) {
// Record the destination it was actually sent to before confirming.
+9 -3
View File
@@ -4,11 +4,17 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PaymentEndpoint {
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 {
register_rest_route(
$route_namespace,
@@ -64,8 +70,8 @@ class PaymentEndpoint {
* (Stripe client secret for card; display data for e-transfer/comp).
*/
public function createIntent( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$type = (string) $request->get_param( 'registration_type' );
$registrationId = absint( $request->get_param( 'registration_id' ) );
$type = Val::string( $request->get_param( 'registration_type' ) );
$registrationId = absint( Val::int( $request->get_param( 'registration_id' ) ) );
$result = $this->service->createIntent( $type, $registrationId, get_current_user_id() );
if ( null === $result ) {
@@ -99,7 +105,7 @@ class PaymentEndpoint {
* Studio admin marks a pending payment (e-transfer) received.
*/
public function markPaid( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$id = absint( Val::int( $request->get_param( 'id' ) ) );
if ( ! $this->service->markPaid( $id ) ) {
return new \WP_Error( 'not_found', __( 'Payment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
+7 -5
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PaymentReportController {
@@ -21,8 +22,8 @@ class PaymentReportController {
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only report filters, no state change.
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : '' );
$instructorId = isset( $_GET['instructor_id'] ) ? absint( $_GET['instructor_id'] ) : 0;
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( Val::string( wp_unslash( $_GET['month'] ) ) ) : '' );
$instructorId = isset( $_GET['instructor_id'] ) ? absint( Val::int( $_GET['instructor_id'] ) ) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$instructorId = $this->scopeInstructor( $instructorId );
@@ -58,8 +59,8 @@ class PaymentReportController {
check_admin_referer( self::EXPORT_ACTION );
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce checked above.
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : '' );
$instructorId = isset( $_GET['instructor_id'] ) ? absint( $_GET['instructor_id'] ) : 0;
$month = $this->sanitizeMonth( isset( $_GET['month'] ) ? sanitize_text_field( Val::string( wp_unslash( $_GET['month'] ) ) ) : '' );
$instructorId = isset( $_GET['instructor_id'] ) ? absint( Val::int( $_GET['instructor_id'] ) ) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$instructorId = $this->scopeInstructor( $instructorId );
@@ -92,7 +93,8 @@ class PaymentReportController {
*/
private function buildReport( string $month, int $instructorId ): PaymentReport {
$start = $month . '-01 00:00:00';
$end = gmdate( 'Y-m-d H:i:s', strtotime( $month . '-01 00:00:00 +1 month' ) );
$endTs = strtotime( $month . '-01 00:00:00 +1 month' );
$end = false === $endTs ? $start : gmdate( 'Y-m-d H:i:s', $endTs );
$rows = array_map(
static function ( Payment $payment ): array {
+17 -13
View File
@@ -55,7 +55,8 @@ class PaymentRepository {
public function findByStripeIntentId( string $intentId ): ?Payment {
$row = $this->db->get_row(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1",
'SELECT * FROM %i WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1',
$this->table,
$intentId
)
);
@@ -77,14 +78,15 @@ class PaymentRepository {
* Set a payment's tax rate and recompute the tax amount from its subtotal.
*/
public function updateTax( int $id, float $rate ): bool {
return false !== $this->db->query(
$this->db->prepare(
"UPDATE {$this->table} SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d",
$rate,
$rate,
$id
)
$sql = $this->db->prepare(
'UPDATE %i SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d',
$this->table,
$rate,
$rate,
$id
);
return null !== $sql && false !== $this->db->query( $sql );
}
/**
@@ -94,8 +96,8 @@ class PaymentRepository {
* @return list<Payment>
*/
public function findPaidBetween( string $from, string $to, int $instructorId = 0 ): array {
$sql = "SELECT * FROM {$this->table} WHERE status = %s AND paid_at >= %s AND paid_at < %s";
$params = [ Payment::STATUS_PAID, $from, $to ];
$sql = 'SELECT * FROM %i WHERE status = %s AND paid_at >= %s AND paid_at < %s';
$params = [ $this->table, Payment::STATUS_PAID, $from, $to ];
if ( $instructorId > 0 ) {
$sql .= ' AND instructor_id = %d';
@@ -111,7 +113,7 @@ class PaymentRepository {
public function findById( int $id ): ?Payment {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Payment::fromRow( $row ) : null;
@@ -120,7 +122,8 @@ class PaymentRepository {
public function findByRegistration( string $registrationType, int $registrationId ): ?Payment {
$row = $this->db->get_row(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id DESC LIMIT 1",
'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id DESC LIMIT 1',
$this->table,
$registrationType,
$registrationId
)
@@ -137,7 +140,8 @@ class PaymentRepository {
public function findPending(): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE status = %s ORDER BY created_at DESC",
'SELECT * FROM %i WHERE status = %s ORDER BY created_at DESC',
$this->table,
Payment::STATUS_PENDING
)
);
+2 -2
View File
@@ -67,8 +67,8 @@ class StripeGateway {
* Seam around the Stripe PaymentIntents create call so tests can stub the
* network request.
*
* @param array<string, mixed> $params
* @param array<string, mixed> $options
* @param array{amount: int, currency: string, metadata: array<string, string>, description: string} $params
* @param array{idempotency_key?: string} $options
*/
protected function paymentIntentsCreate( array $params, array $options ): PaymentIntent {
return $this->client()->paymentIntents->create( $params, $options );
+15 -13
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class StudioSettings {
@@ -16,11 +17,11 @@ class StudioSettings {
public const OPT_HST_RATE = 'us_hst_rate';
public function publishableKey(): string {
return (string) get_option( self::OPT_PUBLISHABLE, '' );
return Val::string( get_option( self::OPT_PUBLISHABLE, '' ) );
}
public function secretKey(): string {
return (string) get_option( self::OPT_SECRET, '' );
return Val::string( get_option( self::OPT_SECRET, '' ) );
}
/**
@@ -28,7 +29,7 @@ class StudioSettings {
* webhook requests genuinely came from Stripe. Empty until configured.
*/
public function webhookSecret(): string {
return (string) get_option( self::OPT_WEBHOOK_SECRET, '' );
return Val::string( get_option( self::OPT_WEBHOOK_SECRET, '' ) );
}
public function mode(): string {
@@ -36,7 +37,7 @@ class StudioSettings {
}
public function currency(): string {
$currency = (string) get_option( self::OPT_CURRENCY, 'CAD' );
$currency = Val::string( get_option( self::OPT_CURRENCY, 'CAD' ) );
return '' !== $currency ? strtoupper( $currency ) : 'CAD';
}
@@ -46,14 +47,14 @@ class StudioSettings {
* no override).
*/
public function etransferEmail(): string {
return (string) get_option( self::OPT_ETRANSFER_EMAIL, '' );
return Val::string( get_option( self::OPT_ETRANSFER_EMAIL, '' ) );
}
/**
* Default HST/tax rate as a percentage (e.g. 13.0). 0 means no tax.
*/
public function hstRate(): float {
return max( 0.0, (float) get_option( self::OPT_HST_RATE, 0 ) );
return max( 0.0, Val::float( get_option( self::OPT_HST_RATE, 0 ) ) );
}
/**
@@ -92,22 +93,23 @@ class StudioSettings {
private function save(): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$mode = sanitize_key( wp_unslash( $_POST['mode'] ?? 'test' ) );
update_option( self::OPT_PUBLISHABLE, sanitize_text_field( wp_unslash( $_POST['publishable_key'] ?? '' ) ) );
$mode = sanitize_key( Val::string( wp_unslash( $_POST['mode'] ?? 'test' ) ) );
update_option( self::OPT_PUBLISHABLE, sanitize_text_field( Val::string( wp_unslash( $_POST['publishable_key'] ?? '' ) ) ) );
// Secret fields are write-only: a blank submission keeps the stored secret,
// so an admin saving other settings never wipes the keys.
$secretKey = sanitize_text_field( wp_unslash( $_POST['secret_key'] ?? '' ) );
$secretKey = sanitize_text_field( Val::string( wp_unslash( $_POST['secret_key'] ?? '' ) ) );
if ( '' !== $secretKey ) {
update_option( self::OPT_SECRET, $secretKey );
}
$webhookSecret = sanitize_text_field( wp_unslash( $_POST['webhook_secret'] ?? '' ) );
$webhookSecret = sanitize_text_field( Val::string( wp_unslash( $_POST['webhook_secret'] ?? '' ) ) );
if ( '' !== $webhookSecret ) {
update_option( self::OPT_WEBHOOK_SECRET, $webhookSecret );
}
update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' );
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) );
update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
$hstRate = isset( $_POST['hst_rate'] ) ? (float) $_POST['hst_rate'] : 0.0;
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( Val::string( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) ) );
update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( Val::string( 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'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0;
update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
+3
View File
@@ -33,6 +33,9 @@ class Plugin {
load_plugin_textdomain( 'unsupervised-schedular', false, dirname( plugin_basename( USC_PLUGIN_FILE ) ) . '/languages' );
global $wpdb;
if ( ! $wpdb instanceof \wpdb ) {
return;
}
$availability = new AvailabilityRepository( $wpdb );
$bookings = new BookingRepository( $wpdb );
$offerings = new OfferingRepository( $wpdb );
+2 -1
View File
@@ -46,7 +46,8 @@ class AcceptanceRepository {
public function findByRegistration( string $registrationType, int $registrationId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC",
'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC',
$this->table,
$registrationType,
$registrationId
)
+8 -6
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Val;
class Policy {
public const SCOPE_SIGNUP = 'signup';
@@ -24,13 +26,13 @@ class Policy {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
title: $row->title,
slug: $row->slug,
currentVersionId: null !== $row->current_version_id ? (int) $row->current_version_id : null,
acceptanceScope: $row->acceptance_scope,
id: (int) $row->id,
title: Val::string( $row->title ),
slug: Val::string( $row->slug ),
currentVersionId: Val::intOrNull( $row->current_version_id ),
acceptanceScope: Val::string( $row->acceptance_scope ),
id: Val::int( $row->id ),
);
}
+10 -8
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Val;
class PolicyAcceptance {
public const REG_ACCOUNT = 'account';
@@ -27,15 +29,15 @@ class PolicyAcceptance {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
policyVersionId: (int) $row->policy_version_id,
studentId: (int) $row->student_id,
registrationType: $row->registration_type,
registrationId: (int) $row->registration_id,
ipAddress: $row->ip_address,
acceptedAt: $row->accepted_at,
id: (int) $row->id,
policyVersionId: Val::int( $row->policy_version_id ),
studentId: Val::int( $row->student_id ),
registrationType: Val::string( $row->registration_type ),
registrationId: Val::int( $row->registration_id ),
ipAddress: Val::stringOrNull( $row->ip_address ),
acceptedAt: Val::stringOrNull( $row->accepted_at ),
id: Val::int( $row->id ),
);
}
+9 -8
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PolicyController {
@@ -23,7 +24,7 @@ class PolicyController {
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only policy selector.
$policyId = absint( $_GET['policy_id'] ?? 0 );
$policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) );
$policyList = $this->policies->findAll();
$selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null;
$policyVersions = null !== $selectedPolicy ? $this->versions->findByPolicy( (int) $selectedPolicy->id ) : null;
@@ -34,13 +35,13 @@ class PolicyController {
private function handleFormAction(): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'create_policy' === $action ) {
$title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) );
$slugRaw = sanitize_text_field( wp_unslash( $_POST['slug'] ?? '' ) );
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) );
$slugRaw = sanitize_text_field( Val::string( wp_unslash( $_POST['slug'] ?? '' ) ) );
$slug = sanitize_title( '' !== $slugRaw ? $slugRaw : $title );
$scope = sanitize_key( wp_unslash( $_POST['acceptance_scope'] ?? Policy::SCOPE_BOOKING ) );
$scope = sanitize_key( Val::string( wp_unslash( $_POST['acceptance_scope'] ?? Policy::SCOPE_BOOKING ) ) );
if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) {
$scope = Policy::SCOPE_BOOKING;
@@ -53,18 +54,18 @@ class PolicyController {
return;
}
$policyId = absint( $_POST['policy_id'] ?? 0 );
$policyId = absint( Val::int( $_POST['policy_id'] ?? 0 ) );
if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) {
return;
}
if ( 'add_version' === $action ) {
$body = wp_kses_post( wp_unslash( $_POST['body'] ?? '' ) );
$body = wp_kses_post( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) );
$this->service->addDraftVersion( $policyId, $body );
}
if ( 'publish_version' === $action ) {
$versionId = absint( $_POST['version_id'] ?? 0 );
$versionId = absint( Val::int( $_POST['version_id'] ?? 0 ) );
if ( $versionId > 0 ) {
$this->service->publishVersion( $policyId, $versionId );
}
+16 -10
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class PolicyEndpoint {
@@ -13,6 +14,11 @@ class PolicyEndpoint {
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 {
register_rest_route(
$route_namespace,
@@ -74,7 +80,7 @@ class PolicyEndpoint {
* `both`-scoped policies).
*/
public function index( \WP_REST_Request $request ): \WP_REST_Response {
$scope = (string) $request->get_param( 'scope' );
$scope = Val::string( $request->get_param( 'scope' ) );
$policies = in_array( $scope, [ Policy::SCOPE_SIGNUP, Policy::SCOPE_BOOKING ], true )
? $this->policies->findForScope( $scope )
: $this->policies->findAll();
@@ -108,12 +114,12 @@ class PolicyEndpoint {
}
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$title = sanitize_text_field( (string) $request->get_param( 'title' ) );
$title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) );
if ( '' === $title ) {
return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) );
}
$slugParam = sanitize_text_field( (string) $request->get_param( 'slug' ) );
$slugParam = sanitize_text_field( Val::string( $request->get_param( 'slug' ) ) );
$slug = sanitize_title( '' !== $slugParam ? $slugParam : $title );
if ( '' === $slug ) {
return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) );
@@ -123,7 +129,7 @@ class PolicyEndpoint {
return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$scope = (string) ( $request->get_param( 'acceptance_scope' ) ?? Policy::SCOPE_BOOKING );
$scope = Val::string( $request->get_param( 'acceptance_scope' ) ?? Policy::SCOPE_BOOKING );
if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) {
return $this->invalid( __( 'Invalid acceptance scope.', 'unsupervised-schedular' ) );
}
@@ -134,12 +140,12 @@ class PolicyEndpoint {
}
public function addVersion( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$policy = $this->policies->findById( absint( $request->get_param( 'id' ) ) );
$policy = $this->policies->findById( absint( Val::int( $request->get_param( 'id' ) ) ) );
if ( null === $policy ) {
return $this->notFound();
}
$body = wp_kses_post( (string) $request->get_param( 'body' ) );
$body = wp_kses_post( Val::string( $request->get_param( 'body' ) ) );
$id = $this->service->addDraftVersion( (int) $policy->id, $body );
return new \WP_REST_Response( [ 'id' => $id ], 201 );
@@ -155,7 +161,7 @@ class PolicyEndpoint {
return $this->invalid( __( 'Only draft versions can be edited.', 'unsupervised-schedular' ) );
}
$body = wp_kses_post( (string) $request->get_param( 'body' ) );
$body = wp_kses_post( Val::string( $request->get_param( 'body' ) ) );
$this->versions->updateBody( (int) $version->id, $body );
return new \WP_REST_Response(
@@ -173,7 +179,7 @@ class PolicyEndpoint {
return $version;
}
$this->service->publishVersion( (int) $request->get_param( 'id' ), (int) $version->id );
$this->service->publishVersion( Val::int( $request->get_param( 'id' ) ), (int) $version->id );
return new \WP_REST_Response(
[
@@ -202,8 +208,8 @@ class PolicyEndpoint {
* Load the version named in the route and confirm it belongs to the policy.
*/
private function loadVersionForPolicy( \WP_REST_Request $request ): PolicyVersion|\WP_Error {
$policyId = absint( $request->get_param( 'id' ) );
$version = $this->versions->findById( absint( $request->get_param( 'vid' ) ) );
$policyId = absint( Val::int( $request->get_param( 'id' ) ) );
$version = $this->versions->findById( absint( Val::int( $request->get_param( 'vid' ) ) ) );
if ( null === $version || $version->policyId !== $policyId ) {
return $this->notFound();
+4 -3
View File
@@ -36,7 +36,8 @@ class PolicyRepository {
public function findForScope( string $scope ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE acceptance_scope = %s OR acceptance_scope = %s ORDER BY title ASC",
'SELECT * FROM %i WHERE acceptance_scope = %s OR acceptance_scope = %s ORDER BY title ASC',
$this->table,
$scope,
Policy::SCOPE_BOTH
)
@@ -68,7 +69,7 @@ class PolicyRepository {
public function findById( int $id ): ?Policy {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Policy::fromRow( $row ) : null;
@@ -76,7 +77,7 @@ class PolicyRepository {
public function findBySlug( string $slug ): ?Policy {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE slug = %s", $slug )
$this->db->prepare( 'SELECT * FROM %i WHERE slug = %s', $this->table, $slug )
);
return $row ? Policy::fromRow( $row ) : null;
+9 -7
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Policy;
use Unsupervised\Schedular\Val;
class PolicyVersion {
public const STATUS_DRAFT = 'draft';
@@ -25,14 +27,14 @@ class PolicyVersion {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
policyId: (int) $row->policy_id,
versionNumber: (int) $row->version_number,
body: $row->body,
status: $row->status,
publishedAt: $row->published_at,
id: (int) $row->id,
policyId: Val::int( $row->policy_id ),
versionNumber: Val::int( $row->version_number ),
body: Val::stringOrNull( $row->body ),
status: Val::string( $row->status ),
publishedAt: Val::stringOrNull( $row->published_at ),
id: Val::int( $row->id ),
);
}
+4 -3
View File
@@ -59,7 +59,8 @@ class PolicyVersionRepository {
public function findByPolicy( int $policyId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE policy_id = %d ORDER BY version_number DESC",
'SELECT * FROM %i WHERE policy_id = %d ORDER BY version_number DESC',
$this->table,
$policyId
)
);
@@ -69,7 +70,7 @@ class PolicyVersionRepository {
public function findById( int $id ): ?PolicyVersion {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? PolicyVersion::fromRow( $row ) : null;
@@ -80,7 +81,7 @@ class PolicyVersionRepository {
*/
public function maxVersionNumber( int $policyId ): int {
$max = $this->db->get_var(
$this->db->prepare( "SELECT MAX(version_number) FROM {$this->table} WHERE policy_id = %d", $policyId )
$this->db->prepare( 'SELECT MAX(version_number) FROM %i WHERE policy_id = %d', $this->table, $policyId )
);
return null === $max ? 0 : (int) $max;
+9 -7
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Val;
class Answer {
public const REG_LESSON = 'lesson';
@@ -24,14 +26,14 @@ class Answer {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
return new self(
questionId: (int) $row->question_id,
registrationType: $row->registration_type,
registrationId: (int) $row->registration_id,
studentId: (int) $row->student_id,
answerValue: $row->answer_value,
id: (int) $row->id,
questionId: Val::int( $row->question_id ),
registrationType: Val::string( $row->registration_type ),
registrationId: Val::int( $row->registration_id ),
studentId: Val::int( $row->student_id ),
answerValue: Val::stringOrNull( $row->answer_value ),
id: Val::int( $row->id ),
);
}
+2 -1
View File
@@ -46,7 +46,8 @@ class AnswerRepository {
public function findByRegistration( string $registrationType, int $registrationId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC",
'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id ASC',
$this->table,
$registrationType,
$registrationId
)
+14 -10
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Val;
class Question {
public const FIELD_TEXT = 'text';
@@ -38,22 +40,24 @@ class Question {
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
public static function fromRow( \stdClass $row ): self {
$options = null;
if ( null !== $row->options && '' !== $row->options ) {
$decoded = json_decode( (string) $row->options, true );
$options = is_array( $decoded ) ? array_values( array_map( 'strval', $decoded ) ) : null;
$decoded = json_decode( Val::string( $row->options ), true );
$options = is_array( $decoded )
? array_values( array_map( static fn( mixed $v ): string => Val::string( $v ), $decoded ) )
: null;
}
return new self(
offeringId: (int) $row->offering_id,
label: $row->label,
fieldType: $row->field_type,
offeringId: Val::int( $row->offering_id ),
label: Val::string( $row->label ),
fieldType: Val::string( $row->field_type ),
options: $options,
isRequired: (bool) $row->is_required,
sortOrder: (int) $row->sort_order,
isActive: (bool) $row->is_active,
id: (int) $row->id,
isRequired: Val::bool( $row->is_required ),
sortOrder: Val::int( $row->sort_order ),
isActive: Val::bool( $row->is_active ),
id: Val::int( $row->id ),
);
}
+8 -7
View File
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
class QuestionController {
@@ -23,7 +24,7 @@ class QuestionController {
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only offering selector.
$offeringId = absint( $_GET['offering_id'] ?? 0 );
$offeringId = absint( Val::int( $_GET['offering_id'] ?? 0 ) );
$offeringList = $manageAll ? $this->offerings->findAll() : $this->offerings->findAll( $userId );
$selectedOffering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
@@ -46,14 +47,14 @@ class QuestionController {
private function handleFormAction( Offering $offering ): void {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) {
$this->addQuestion( (int) $offering->id );
}
if ( 'delete' === $action ) {
$questionId = absint( $_POST['question_id'] ?? 0 );
$questionId = absint( Val::int( $_POST['question_id'] ?? 0 ) );
if ( $questionId > 0 ) {
$question = $this->questions->findById( $questionId );
if ( $question && $question->offeringId === (int) $offering->id ) {
@@ -66,8 +67,8 @@ class QuestionController {
private function addQuestion( int $offeringId ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$label = sanitize_text_field( wp_unslash( $_POST['label'] ?? '' ) );
$fieldType = sanitize_key( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) );
$label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) );
$fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) );
if ( '' === $label || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
return;
@@ -78,9 +79,9 @@ class QuestionController {
offeringId: $offeringId,
label: $label,
fieldType: $fieldType,
options: $this->parseOptions( sanitize_textarea_field( wp_unslash( $_POST['options'] ?? '' ) ) ),
options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ),
isRequired: isset( $_POST['is_required'] ),
sortOrder: absint( $_POST['sort_order'] ?? 0 ),
sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ),
)
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
+17 -11
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
class QuestionEndpoint {
@@ -13,6 +14,11 @@ class QuestionEndpoint {
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 {
register_rest_route(
$route_namespace,
@@ -57,24 +63,24 @@ class QuestionEndpoint {
}
public function index( \WP_REST_Request $request ): \WP_REST_Response {
$questions = $this->questions->findByOffering( absint( $request->get_param( 'id' ) ), activeOnly: true );
$questions = $this->questions->findByOffering( absint( Val::int( $request->get_param( 'id' ) ) ), activeOnly: true );
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 {
$offeringId = absint( $request->get_param( 'offering_id' ) );
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$ownerCheck = $this->requireOfferingOwner( $offeringId );
if ( $ownerCheck instanceof \WP_Error ) {
return $ownerCheck;
}
$label = sanitize_text_field( (string) $request->get_param( 'label' ) );
$label = sanitize_text_field( Val::string( $request->get_param( 'label' ) ) );
if ( '' === $label ) {
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
}
$fieldType = (string) ( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
$fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
}
@@ -85,7 +91,7 @@ class QuestionEndpoint {
fieldType: $fieldType,
options: $this->sanitizeOptions( $request->get_param( 'options' ) ),
isRequired: (bool) $request->get_param( 'is_required' ),
sortOrder: (int) $request->get_param( 'sort_order' ),
sortOrder: Val::int( $request->get_param( 'sort_order' ) ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
);
@@ -95,7 +101,7 @@ class QuestionEndpoint {
}
public function update( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$existing = $this->questions->findById( $id );
if ( null === $existing ) {
@@ -107,18 +113,18 @@ class QuestionEndpoint {
return $ownerCheck;
}
$fieldType = $request->has_param( 'field_type' ) ? (string) $request->get_param( 'field_type' ) : $existing->fieldType;
$fieldType = $request->has_param( 'field_type' ) ? Val::string( $request->get_param( 'field_type' ) ) : $existing->fieldType;
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
}
$question = new Question(
offeringId: $existing->offeringId,
label: $request->has_param( 'label' ) ? sanitize_text_field( (string) $request->get_param( 'label' ) ) : $existing->label,
label: $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label,
fieldType: $fieldType,
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,
sortOrder: $request->has_param( 'sort_order' ) ? (int) $request->get_param( 'sort_order' ) : $existing->sortOrder,
sortOrder: $request->has_param( 'sort_order' ) ? Val::int( $request->get_param( 'sort_order' ) ) : $existing->sortOrder,
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
id: $id,
);
@@ -129,7 +135,7 @@ class QuestionEndpoint {
}
public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$existing = $this->questions->findById( $id );
if ( null === $existing ) {
@@ -192,7 +198,7 @@ class QuestionEndpoint {
$options = array_values(
array_filter(
array_map(
static fn( $option ): string => sanitize_text_field( (string) $option ),
static fn( mixed $option ): string => sanitize_text_field( Val::string( $option ) ),
$value
)
)
+3 -3
View File
@@ -54,8 +54,8 @@ class QuestionRepository {
* @return list<Question>
*/
public function findByOffering( int $offeringId, bool $activeOnly = false ): array {
$sql = "SELECT * FROM {$this->table} WHERE offering_id = %d";
$params = [ $offeringId ];
$sql = 'SELECT * FROM %i WHERE offering_id = %d';
$params = [ $this->table, $offeringId ];
if ( $activeOnly ) {
$sql .= ' AND is_active = %d';
@@ -71,7 +71,7 @@ class QuestionRepository {
public function findById( int $id ): ?Question {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Question::fromRow( $row ) : null;
+60
View File
@@ -0,0 +1,60 @@
<?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;
}
}
+3 -3
View File
@@ -48,7 +48,7 @@ class InviteRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/token = %s/'), 'tok123')
->with(Mockery::pattern('/token = %s/'), 'wp_us_invites', 'tok123')
->andReturn('SELECT ...');
$this->db->shouldReceive('get_row')->andReturn($this->row());
@@ -71,7 +71,7 @@ class InviteRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/email = %s AND status = %s/'), 'a@b.test', Invite::STATUS_PENDING)
->with(Mockery::pattern('/email = %s AND status = %s/'), 'wp_us_invites', 'a@b.test', Invite::STATUS_PENDING)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_row')->andReturn($this->row());
@@ -83,7 +83,7 @@ class InviteRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/status = %s/'), Invite::STATUS_PENDING)
->with(Mockery::pattern('/status = %s/'), 'wp_us_invites', Invite::STATUS_PENDING)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);
@@ -147,11 +147,16 @@ class AvailabilityRepositoryTest extends TestCase
self::assertFalse($this->repo->delete(1));
}
public function testFindAvailableWithNoFiltersUsesNoParams(): void
public function testFindAvailableWithNoFiltersPreparesTableOnly(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/WHERE is_booked = 0/'), ['wp_us_availability'])
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')
->once()
->with(Mockery::pattern('/WHERE is_booked = 0/'))
->with('SELECT ...')
->andReturn([]);
$result = $this->repo->findAvailable();
@@ -177,7 +182,7 @@ class AvailabilityRepositoryTest extends TestCase
->once()
->with(
Mockery::pattern('/offering_id = %d AND duration_minutes = %d/'),
Mockery::on(static fn (array $p): bool => $p === [8, 30])
Mockery::on(static fn (array $p): bool => $p === ['wp_us_availability', 8, 30])
)
->andReturn('SELECT ...');
+1 -1
View File
@@ -138,7 +138,7 @@ class BookingRepositoryTest extends TestCase
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/COUNT\(\*\).*l.student_id = %d.*a.start_dt >= %s/s'), 5, Lesson::STATUS_CANCELLED, '2026-06-08 12:00:00')
->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')
->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->andReturn('3');
@@ -48,7 +48,7 @@ class EnrollmentRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/COUNT\(\*\).*offering_id = %d AND status = %s/s'), 7, Enrollment::STATUS_ACTIVE)
->with(Mockery::pattern('/COUNT\(\*\).*offering_id = %d AND status = %s/s'), 'wp_us_group_enrollments', 7, Enrollment::STATUS_ACTIVE)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->andReturn('4');
@@ -60,7 +60,7 @@ class EnrollmentRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/student_id = %d AND status = %s/'), 5, Enrollment::STATUS_ACTIVE)
->with(Mockery::pattern('/student_id = %d AND status = %s/'), 'wp_us_group_enrollments', 5, Enrollment::STATUS_ACTIVE)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->andReturn('2');
@@ -72,7 +72,7 @@ class EnrollmentRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/offering_id = %d AND student_id = %d AND status = %s/'), 7, 5, Enrollment::STATUS_ACTIVE)
->with(Mockery::pattern('/offering_id = %d AND student_id = %d AND status = %s/'), 'wp_us_group_enrollments', 7, 5, Enrollment::STATUS_ACTIVE)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->andReturn('1');
@@ -109,11 +109,16 @@ class OfferingRepositoryTest extends TestCase
self::assertSame(3, $offering->instructorId);
}
public function testFindAllWithNoFiltersUsesNoParams(): void
public function testFindAllWithNoFiltersPreparesTableOnly(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/WHERE 1 = 1/'), ['wp_us_offerings'])
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')
->once()
->with(Mockery::pattern('/WHERE 1 = 1/'))
->with('SELECT ...')
->andReturn([$this->sampleRow()]);
$offerings = $this->repo->findAll();
@@ -126,7 +131,7 @@ class OfferingRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/is_active = %d/'), Mockery::on(static fn (array $p): bool => $p === [1]))
->with(Mockery::pattern('/is_active = %d/'), Mockery::on(static fn (array $p): bool => $p === ['wp_us_offerings', 1]))
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([]);
@@ -140,7 +145,7 @@ class OfferingRepositoryTest extends TestCase
->once()
->with(
Mockery::pattern('/instructor_id = %d AND kind = %s/'),
Mockery::on(static fn (array $p): bool => $p === [3, Offering::KIND_GROUP_CLASS])
Mockery::on(static fn (array $p): bool => $p === ['wp_us_offerings', 3, Offering::KIND_GROUP_CLASS])
)
->andReturn('SELECT ...');
+6 -6
View File
@@ -66,7 +66,7 @@ class PaymentRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/tax_amount = ROUND\( amount \* %f \/ 100, 2 \)/'), 13.0, 13.0, 50)
->with(Mockery::pattern('/tax_amount = ROUND\( amount \* %f \/ 100, 2 \)/'), 'wp_us_payments', 13.0, 13.0, 50)
->andReturn('UPDATE ...');
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1);
@@ -78,7 +78,7 @@ class PaymentRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->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])
->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])
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);
@@ -90,7 +90,7 @@ class PaymentRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->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'])
->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'])
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([]);
@@ -118,7 +118,7 @@ class PaymentRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/stripe_payment_intent_id = %s/'), 'pi_123')
->with(Mockery::pattern('/stripe_payment_intent_id = %s/'), 'wp_us_payments', 'pi_123')
->andReturn('SELECT ...');
$this->db->shouldReceive('get_row')->andReturn($this->row());
@@ -138,7 +138,7 @@ class PaymentRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), Payment::REG_LESSON, 12)
->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), 'wp_us_payments', Payment::REG_LESSON, 12)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_row')->andReturn($this->row());
@@ -150,7 +150,7 @@ class PaymentRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/status = %s/'), Payment::STATUS_PENDING)
->with(Mockery::pattern('/status = %s/'), 'wp_us_payments', Payment::STATUS_PENDING)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);
@@ -70,7 +70,7 @@ class AcceptanceRepositoryTest extends TestCase
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), PolicyAcceptance::REG_LESSON, 12)
->with(Mockery::pattern('/registration_type = %s AND registration_id = %d/'), 'wp_us_policy_acceptances', PolicyAcceptance::REG_LESSON, 12)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([
@@ -45,6 +45,7 @@ class PolicyRepositoryTest extends TestCase
->once()
->with(
Mockery::pattern('/acceptance_scope = %s OR acceptance_scope = %s/'),
'wp_us_policies',
Policy::SCOPE_SIGNUP,
Policy::SCOPE_BOTH
)
@@ -84,6 +84,7 @@ class AnswerRepositoryTest extends TestCase
->once()
->with(
Mockery::pattern('/registration_type = %s AND registration_id = %d/'),
'wp_us_question_answers',
Answer::REG_LESSON,
12
)
@@ -101,7 +101,7 @@ class QuestionRepositoryTest extends TestCase
->once()
->with(
Mockery::pattern('/offering_id = %d AND is_active = %d/'),
Mockery::on(static fn (array $p): bool => $p === [7, 1])
Mockery::on(static fn (array $p): bool => $p === ['wp_us_questions', 7, 1])
)
->andReturn('SELECT ...');
+66
View File
@@ -0,0 +1,66 @@
<?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));
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
* Plugin URI: https://unsupervised.ca
* Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.0.0-rc.1
* Requires at least: 6.0
* Requires at least: 6.2
* Requires PHP: 8.1
* Author: Unsupervised
* License: GPL-2.0-or-later