Add group-class scheduling, instructor assignment, and details/invite management
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m52s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped

Group classes now carry a specific class time (alongside date and duration)
and an assigned instructor:

- Schema: add `class_time` (TIME) to `us_offerings`; `Offering` gains
  `normalizeTime`/`sessionWindows`. (Rides the pending 1.0.0->1.1.0 dbDelta
  upgrade, so no version bump.)
- Offering form: class-time field, plus a studio-admin instructor picker
  (plain instructors always own their own classes).
- `ClassSlotReconciler`: assigning an instructor clears their open booking
  slots overlapping each session and flags already-booked lessons that clash
  (a booked lesson is never deleted). Uses new
  `AvailabilityRepository::findOverlapping`.
- Front end: `GET /offerings` exposes `instructor_name`; the enrolment page
  shows who teaches each class and when it meets.

Back-office group-class views redesigned:

- Instructor **My Group Classes** and studio-admin **Group Classes** are now
  per-class summaries with enrolment counts, not flat student lists.
- Each links through (`?class_id=<id>`) to a per-class **details page**
  (schedule panel, roster with payment status, and — for invite-only classes
  — the add/make-available/invite-by-email controls). Invite-only membership
  is managed entirely from this page.
- Invite actions are allowed for the class's owning instructor or any
  `view_all_lessons` studio admin, so an owner-operator (studio admin who also
  teaches) can reach every class's roster and invites from the Group Classes
  page.

Tests: composer test (508), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-07-23 17:29:48 -03:00
co-authored by Claude Opus 4.8
parent bba24566a6
commit b066bef353
23 changed files with 1355 additions and 207 deletions
+120 -9
View File
@@ -8,7 +8,10 @@ use Unsupervised\Schedular\Val;
class OfferingController {
public function __construct( private OfferingRepository $repository ) {}
public function __construct(
private OfferingRepository $repository,
private ClassSlotReconciler $reconciler,
) {}
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) ) {
@@ -18,10 +21,15 @@ class OfferingController {
$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' ) ) {
$this->handleFormAction( $instructorId, $manageAll );
$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
@@ -43,15 +51,22 @@ class OfferingController {
include USC_PLUGIN_DIR . 'templates/admin/offerings.php';
}
private function handleFormAction( int $instructorId, bool $manageAll ): void {
/**
* 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 );
$offering = $this->offeringFromPost( $instructorId, $manageAll );
if ( null !== $offering ) {
$this->repository->insert( $offering );
return $this->reconcileNotice( $offering );
}
}
@@ -60,9 +75,11 @@ class OfferingController {
if ( $offeringId > 0 ) {
$existing = $this->repository->findById( $offeringId );
if ( $existing && ( $manageAll || $existing->instructorId === $instructorId ) ) {
$offering = $this->offeringFromPost( $instructorId, $existing );
$offering = $this->offeringFromPost( $instructorId, $manageAll, $existing );
if ( null !== $offering ) {
$this->repository->update( $offeringId, $offering );
return $this->reconcileNotice( $offering );
}
}
}
@@ -78,15 +95,86 @@ class OfferingController {
}
}
// 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, owner, and currency so an update can never
* reassign an offering to whoever happens to submit the form.
* 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, ?Offering $existing = null ): ?Offering {
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'] ?? '' ) ) );
@@ -119,8 +207,10 @@ class OfferingController {
$termEnd = 'weekly' === $recurrence ? Offering::weeklyTermEnd( $termStart, $sessions ) : $termStart;
}
$classTime = Offering::normalizeTime( sanitize_text_field( Val::string( wp_unslash( $_POST['class_time'] ?? '' ) ) ) );
return new Offering(
instructorId: null !== $existing ? $existing->instructorId : $instructorId,
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' ) ) ) ),
@@ -132,6 +222,7 @@ class OfferingController {
capacity: $capacity > 0 ? $capacity : null,
termStart: $termStart,
termEnd: $termEnd,
classTime: $classTime,
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,
@@ -142,6 +233,26 @@ class OfferingController {
// 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;
}