CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m47s
CI / PHPStan (pull_request) Successful in 3m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Three bug fixes for the 1.2.1 section: - Fixed-size fields (question labels, offering titles/notes/e-transfer email, policy titles/slugs) no longer silently fail to save when the value exceeds its column length. The REST endpoints reject over-long values with a 400, the admin controllers refuse to insert them, and the form inputs carry a maxlength so the browser blocks over-long entry. Limits are MAX_* constants on the value objects, kept in lockstep with the schema columns. - Students are kept out of wp-admin entirely. New StudentAdminGuard redirects front-end-only users (no back-office capability) away from the dashboard and hides the admin bar for them, while administrators, studio admins, and instructors keep full access. - The Add/Edit Offering instructor picker now includes WordPress administrators when they act as instructors (the default single-account setup), so a solo studio owner is selectable instead of the dropdown being empty. composer test (618), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 4.8 <[email protected]>
295 lines
12 KiB
PHP
295 lines
12 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Offering;
|
|
|
|
use Unsupervised\Schedular\Auth\AccessSettings;
|
|
use Unsupervised\Schedular\Auth\RoleManager;
|
|
use Unsupervised\Schedular\Val;
|
|
|
|
class OfferingController {
|
|
|
|
public function __construct(
|
|
private OfferingRepository $repository,
|
|
private ClassSlotReconciler $reconciler,
|
|
private AccessSettings $access = new AccessSettings(),
|
|
) {}
|
|
|
|
public function renderPage(): void {
|
|
if ( ! current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) ) {
|
|
wp_die( esc_html__( 'You do not have permission to manage offerings.', 'unsupervised-schedular' ) );
|
|
}
|
|
|
|
$instructorId = get_current_user_id();
|
|
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
|
|
|
|
$notice = '';
|
|
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_offering_action' ) ) {
|
|
$notice = $this->handleFormAction( $instructorId, $manageAll );
|
|
}
|
|
|
|
// Studio admins may assign any instructor to a class; a plain instructor
|
|
// only ever creates classes for themselves, so the picker is theirs alone.
|
|
$instructors = $manageAll ? $this->instructorOptions() : [];
|
|
|
|
// View-state query param only (which offering the form is editing) —
|
|
// nothing is mutated from it, so no nonce applies.
|
|
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
|
$editId = absint( Val::int( $_GET['usc_edit'] ?? 0 ) );
|
|
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
|
|
|
$editing = null;
|
|
if ( $editId > 0 ) {
|
|
$candidate = $this->repository->findById( $editId );
|
|
if ( $candidate && ( $manageAll || $candidate->instructorId === $instructorId ) ) {
|
|
$editing = $candidate;
|
|
}
|
|
}
|
|
|
|
$offerings = $manageAll
|
|
? $this->repository->findAll()
|
|
: $this->repository->findAll( $instructorId );
|
|
|
|
include USC_PLUGIN_DIR . 'templates/admin/offerings.php';
|
|
}
|
|
|
|
/**
|
|
* Process the posted add/update/delete action, returning a status notice for
|
|
* display (e.g. how many booking slots a scheduled class cleared, or that a
|
|
* booked lesson clashes with it). An empty string means nothing to report.
|
|
*/
|
|
private function handleFormAction( int $instructorId, bool $manageAll ): string {
|
|
// Nonce is verified by the caller (renderPage) before this method runs.
|
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
|
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
|
|
|
if ( 'add' === $action ) {
|
|
$offering = $this->offeringFromPost( $instructorId, $manageAll );
|
|
if ( null !== $offering ) {
|
|
$this->repository->insert( $offering );
|
|
|
|
return $this->reconcileNotice( $offering );
|
|
}
|
|
}
|
|
|
|
if ( 'update' === $action ) {
|
|
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
|
|
if ( $offeringId > 0 ) {
|
|
$existing = $this->repository->findById( $offeringId );
|
|
if ( $existing && ( $manageAll || $existing->instructorId === $instructorId ) ) {
|
|
$offering = $this->offeringFromPost( $instructorId, $manageAll, $existing );
|
|
if ( null !== $offering ) {
|
|
$this->repository->update( $offeringId, $offering );
|
|
|
|
return $this->reconcileNotice( $offering );
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( 'delete' === $action ) {
|
|
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
|
|
if ( $offeringId > 0 ) {
|
|
$offering = $this->repository->findById( $offeringId );
|
|
if ( $offering && ( $manageAll || $offering->instructorId === $instructorId ) ) {
|
|
$this->repository->delete( $offeringId );
|
|
}
|
|
}
|
|
}
|
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Clear the assigned instructor's open booking slots that collide with a
|
|
* scheduled group class and describe the result, warning about any booked
|
|
* lesson that clashes (which the studio must resolve by hand).
|
|
*/
|
|
private function reconcileNotice( Offering $offering ): string {
|
|
if ( Offering::KIND_GROUP_CLASS !== $offering->kind ) {
|
|
return '';
|
|
}
|
|
|
|
$result = $this->reconciler->reconcile( $offering );
|
|
$parts = [];
|
|
|
|
if ( $result['removed'] > 0 ) {
|
|
$parts[] = sprintf(
|
|
/* translators: %d: number of open booking slots removed. */
|
|
_n(
|
|
'%d open booking slot was removed to hold the class time.',
|
|
'%d open booking slots were removed to hold the class time.',
|
|
$result['removed'],
|
|
'unsupervised-schedular'
|
|
),
|
|
$result['removed']
|
|
);
|
|
}
|
|
|
|
foreach ( $result['conflicts'] as $startDt ) {
|
|
$parts[] = sprintf(
|
|
/* translators: %s: date and time of the already-booked lesson that clashes. */
|
|
esc_html__( 'Conflict: a lesson is already booked at %s during this class.', 'unsupervised-schedular' ),
|
|
(string) mysql2date( 'M j, Y g:i a', $startDt )
|
|
);
|
|
}
|
|
|
|
return implode( ' ', $parts );
|
|
}
|
|
|
|
/**
|
|
* Instructors offered in the assignment select, by display name.
|
|
*
|
|
* Includes everyone holding the `us_instructor` role plus, when the site owner
|
|
* has left administrators acting as instructors (the default single-account
|
|
* setup), WordPress administrators — who teach through the dynamic capability
|
|
* grant rather than the role. Without them a solo studio owner running the
|
|
* business from an admin account would find no one to assign a class to.
|
|
*
|
|
* @return list<array{id: int, name: string}>
|
|
*/
|
|
private function instructorOptions(): array {
|
|
$roles = [ RoleManager::INSTRUCTOR ];
|
|
if ( $this->access->adminsAreInstructors() ) {
|
|
$roles[] = 'administrator';
|
|
}
|
|
|
|
$users = array_filter(
|
|
get_users(
|
|
[
|
|
'role__in' => $roles,
|
|
'orderby' => 'display_name',
|
|
'order' => 'ASC',
|
|
]
|
|
),
|
|
static fn( mixed $u ): bool => $u instanceof \WP_User
|
|
);
|
|
|
|
return array_values(
|
|
array_map(
|
|
static fn( \WP_User $u ): array => [
|
|
'id' => (int) $u->ID,
|
|
'name' => '' !== (string) $u->display_name ? (string) $u->display_name : (string) $u->user_email,
|
|
],
|
|
$users
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build an offering from the submitted add/edit form, or null when the
|
|
* submission is invalid. When `$existing` is given the result is an edit:
|
|
* it keeps the existing id and currency so an update can never rewrite those
|
|
* from whoever submits the form.
|
|
*
|
|
* The owning instructor normally stays fixed (the creator on add, the existing
|
|
* owner on edit). A studio admin (`$manageAll`) may instead assign the class to
|
|
* any instructor via the picker; a blank or absent choice keeps the default.
|
|
*/
|
|
private function offeringFromPost( int $instructorId, bool $manageAll, ?Offering $existing = null ): ?Offering {
|
|
// Nonce is verified by the caller (renderPage) before this method runs.
|
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
|
$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 null;
|
|
}
|
|
|
|
$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'] ?? '' ) ) ) );
|
|
|
|
// Reject over-long fixed-size fields rather than let the DB silently drop them.
|
|
if ( mb_strlen( $title ) > Offering::MAX_TITLE_LENGTH
|
|
|| ( null !== $scheduleNote && mb_strlen( $scheduleNote ) > Offering::MAX_SCHEDULE_NOTE_LENGTH )
|
|
|| ( null !== $etransferEmail && mb_strlen( $etransferEmail ) > Offering::MAX_ETRANSFER_EMAIL_LENGTH )
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
$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( Val::int( $_POST['duration_minutes'] ?? 0 ) );
|
|
$capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) );
|
|
|
|
// A blank cutoff means "use the studio default" (null); any entered value
|
|
// (including 0 — cancel any time) is a per-offering override.
|
|
$cutoffRaw = trim( sanitize_text_field( Val::string( wp_unslash( $_POST['cancellation_cutoff_hours'] ?? '' ) ) ) );
|
|
$cutoffHours = '' === $cutoffRaw ? null : absint( Val::int( $cutoffRaw ) );
|
|
|
|
// Term dates: a class either meets once (term ends the day it starts)
|
|
// or repeats weekly for a set number of sessions.
|
|
$termStart = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['term_start'] ?? '' ) ) ) );
|
|
$termEnd = null;
|
|
if ( null !== $termStart ) {
|
|
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['term_recurrence'] ?? 'single' ) ) );
|
|
$sessions = absint( Val::int( $_POST['term_sessions'] ?? 1 ) );
|
|
$termEnd = 'weekly' === $recurrence ? Offering::weeklyTermEnd( $termStart, $sessions ) : $termStart;
|
|
}
|
|
|
|
$classTime = Offering::normalizeTime( sanitize_text_field( Val::string( wp_unslash( $_POST['class_time'] ?? '' ) ) ) );
|
|
|
|
// A blank (or invalid) deadline means "use the default" — the first class
|
|
// day (term_start), applied by Offering::effectiveEnrollmentDeadline().
|
|
$enrollmentDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['enrollment_deadline'] ?? '' ) ) ) );
|
|
|
|
// A blank (or invalid) withdrawal deadline leaves the column NULL, which
|
|
// keeps self-withdrawal open for the class's whole life
|
|
// (Offering::isWithdrawalOpen()). A set date closes it after that day.
|
|
$withdrawalDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['withdrawal_deadline'] ?? '' ) ) ) );
|
|
|
|
return new Offering(
|
|
instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
|
|
kind: $kind,
|
|
title: $title,
|
|
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ),
|
|
currency: null !== $existing ? $existing->currency : 'CAD',
|
|
billingMode: $billingMode,
|
|
description: $this->nullableText( sanitize_textarea_field( Val::string( wp_unslash( $_POST['description'] ?? '' ) ) ) ),
|
|
durationMinutes: $duration > 0 ? $duration : null,
|
|
allowWeekly: isset( $_POST['allow_weekly'] ),
|
|
capacity: $capacity > 0 ? $capacity : null,
|
|
termStart: $termStart,
|
|
termEnd: $termEnd,
|
|
classTime: $classTime,
|
|
enrollmentDeadline: $enrollmentDeadline,
|
|
withdrawalDeadline: $withdrawalDeadline,
|
|
scheduleNote: $scheduleNote,
|
|
etransferEmail: $etransferEmail,
|
|
cancellationCutoffHours: $cutoffHours,
|
|
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
|
|
isActive: isset( $_POST['is_active'] ),
|
|
id: $existing?->id,
|
|
);
|
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
|
}
|
|
|
|
/**
|
|
* The instructor the offering should belong to. A studio admin may reassign it
|
|
* via the posted `class_instructor_id`; otherwise it stays with the existing
|
|
* owner (edit) or the current user (add). A plain instructor can never change
|
|
* the owner, so the posted value is ignored unless `$manageAll` is set.
|
|
*/
|
|
private function resolveInstructorId( int $instructorId, bool $manageAll, ?Offering $existing ): int {
|
|
$fallback = null !== $existing ? $existing->instructorId : $instructorId;
|
|
|
|
if ( ! $manageAll ) {
|
|
return $fallback;
|
|
}
|
|
|
|
// Nonce is verified by the caller (renderPage) before this method runs.
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
|
$posted = absint( Val::int( $_POST['class_instructor_id'] ?? 0 ) );
|
|
|
|
return $posted > 0 ? $posted : $fallback;
|
|
}
|
|
|
|
private function nullableText( string $value ): ?string {
|
|
return '' === $value ? null : $value;
|
|
}
|
|
}
|