CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / Tests (PHP 8.2) (pull_request) Successful in 59s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Group classes now carry an optional per-class withdrawal deadline. Up to
that day a student may withdraw themselves from the class; the withdrawal
frees the seat and voids any pending payment but never issues an account
credit. After the deadline self-withdrawal closes and a studio admin must
withdraw the student by hand (the admin path is never subject to the
deadline). A blank deadline keeps self-withdrawal open indefinitely.
Also make the Add/Edit Offering form show only the fields relevant to the
selected kind: group settings for group classes, weekly reservation for
private lessons. Progressive enhancement — without JS every field renders.
- New nullable us_offerings.withdrawal_deadline column; Offering model gains
$withdrawalDeadline + isWithdrawalOpen().
- New student endpoint POST /enrollments/{id}/withdraw, gated by the deadline
(403 withdrawal_closed), ownership-checked, idempotent.
- Front-end group-class page shows a Withdraw button while open.
- No USC_VERSION bump: 1.2.0 is unreleased and accumulates schema changes
under its section, matching the scheduled-billing and credit features.
Tests: composer test (596), composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
271 lines
10 KiB
PHP
271 lines
10 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Offering;
|
|
|
|
use Unsupervised\Schedular\Auth\RoleManager;
|
|
use Unsupervised\Schedular\Val;
|
|
|
|
class OfferingController {
|
|
|
|
public function __construct(
|
|
private OfferingRepository $repository,
|
|
private ClassSlotReconciler $reconciler,
|
|
) {}
|
|
|
|
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 );
|
|
}
|
|
|
|
/**
|
|
* Registered instructors offered in the assignment select, by display name.
|
|
*
|
|
* @return list<array{id: int, name: string}>
|
|
*/
|
|
private function instructorOptions(): array {
|
|
$users = array_filter(
|
|
get_users(
|
|
[
|
|
'role' => RoleManager::INSTRUCTOR,
|
|
'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;
|
|
}
|
|
|
|
$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: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
|
|
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
|
|
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;
|
|
}
|
|
}
|