Group class term dates, single-class embed mode, and offering editing
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 1m14s
CI / PHPStan (pull_request) Successful in 1m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 37s
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 1m14s
CI / PHPStan (pull_request) Successful in 1m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 37s
CI / Build Plugin Zip (pull_request) Has been skipped
Group class offerings now carry real dates: the add/edit form takes a start date plus a sessions control (one-off, or weekly for N sessions; the end date is computed as start + (N-1) weeks via Offering::weeklyTermEnd). Dates are validated strictly (Y-m-d) and shown in the offerings list and on the student-facing class card, including the weekly session count. [us_group_classes offering="<id>"] (block attribute offeringId, chosen from a dropdown of active classes fetched from the public offerings endpoint) restricts the page to a single class so the enrolment flow can be embedded on a page dedicated to that class; a pinned class that is no longer offered reports itself closed instead of falling back to the catalog. Offerings are now editable from the admin screen: an Edit button prefills the shared add/edit form and saving posts usc_action=update. Updates always preserve the original owner and currency, and non-admin instructors can only load and update their own offerings. The form also gains the previously missing description field and an Active toggle (the admin-UI counterpart of the REST is_active flag) so an edit cannot wipe data the form never collected. Closes #59 Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -46,6 +46,27 @@ class Offering {
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Normalise a submitted term date to canonical `Y-m-d`, or null when it is
|
||||
* not a real calendar date. Round-trips through DateTimeImmutable so
|
||||
* strings PHP would silently coerce (e.g. `2026-02-30`) are rejected.
|
||||
*/
|
||||
public static function normalizeDate( string $value ): ?string {
|
||||
$date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
|
||||
|
||||
return false !== $date && $date->format( 'Y-m-d' ) === $value ? $date->format( 'Y-m-d' ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last class date of a weekly term: the start date plus `$occurrences - 1`
|
||||
* weeks. A one-off class (one occurrence) ends the day it starts.
|
||||
*/
|
||||
public static function weeklyTermEnd( string $termStart, int $occurrences ): string {
|
||||
$weeks = max( 1, $occurrences ) - 1;
|
||||
|
||||
return ( new \DateTimeImmutable( $termStart ) )->modify( '+' . ( 7 * $weeks ) . ' days' )->format( 'Y-m-d' );
|
||||
}
|
||||
|
||||
public static function fromRow( \stdClass $row ): self {
|
||||
return new self(
|
||||
instructorId: Val::int( $row->instructor_id ),
|
||||
|
||||
@@ -22,6 +22,20 @@ class OfferingController {
|
||||
$this->handleFormAction( $instructorId, $manageAll );
|
||||
}
|
||||
|
||||
// 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 );
|
||||
@@ -35,7 +49,23 @@ class OfferingController {
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||||
|
||||
if ( 'add' === $action ) {
|
||||
$this->addOffering( $instructorId );
|
||||
$offering = $this->offeringFromPost( $instructorId );
|
||||
if ( null !== $offering ) {
|
||||
$this->repository->insert( $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, $existing );
|
||||
if ( null !== $offering ) {
|
||||
$this->repository->update( $offeringId, $offering );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( 'delete' === $action ) {
|
||||
@@ -50,13 +80,20 @@ class OfferingController {
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
private function addOffering( int $instructorId ): void {
|
||||
/**
|
||||
* 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, owner, and currency so an update can never
|
||||
* reassign an offering to whoever happens to submit the form.
|
||||
*/
|
||||
private function offeringFromPost( int $instructorId, ?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;
|
||||
return null;
|
||||
}
|
||||
|
||||
$billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) );
|
||||
@@ -67,19 +104,33 @@ class OfferingController {
|
||||
$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( 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( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
|
||||
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
|
||||
)
|
||||
// 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;
|
||||
}
|
||||
|
||||
return new Offering(
|
||||
instructorId: null !== $existing ? $existing->instructorId : $instructorId,
|
||||
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,
|
||||
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'] ?? '' ) ) ) ),
|
||||
isActive: isset( $_POST['is_active'] ),
|
||||
id: $existing?->id,
|
||||
);
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user