Files
unsupervised-scheduler/src/Offering/OfferingController.php
T
thatguygriffandClaude Opus 4.8 721c4be1d6
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
Fix field-length saves, student wp-admin access, and empty instructor picker
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]>
2026-07-24 20:22:04 -03:00

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;
}
}