From bf291625873b82303ad446c69ca6e59bd8765be6 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Fri, 24 Jul 2026 10:19:38 -0300 Subject: [PATCH] Add group-class enrolment deadline with instructor late-enrolment override Group classes gain an instructor-set enrolment deadline (new us_offerings.enrollment_deadline column) that defaults to the first day of the class (term_start). Past the deadline students can no longer self-enrol: the enrolment endpoint rejects it (403 enrollment_closed) and the front-end class list shows "Enrolment has closed." in place of the Enrol button. Instructors keep a manual path: the "Add students directly" control on each class's details page now renders for public classes too (not just invite-only) and deliberately bypasses the deadline and capacity, so a student can be added as a late enrolment after the class has closed. Past the deadline the details page labels these as late enrolments. Bumps USC_VERSION to 1.1.3 for the schema change. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++ assets/js/group-classes.js | 19 ++++- docs/features/group-classes.md | 35 ++++++--- docs/features/offerings.md | 13 +++- src/GroupClass/EnrollmentEndpoint.php | 6 ++ src/GroupClass/GroupClassController.php | 28 ++++--- src/Offering/Offering.php | 24 ++++++ src/Offering/OfferingController.php | 5 ++ src/Offering/OfferingEndpoint.php | 2 + src/Offering/OfferingRepository.php | 8 +- src/Schema.php | 1 + templates/admin/my-group-class-detail.php | 78 ++++++++++++------- templates/admin/offerings.php | 7 ++ .../GroupClass/EnrollmentEndpointTest.php | 31 ++++++++ .../GroupClass/GroupClassControllerTest.php | 44 +++++++++++ .../Unit/Offering/OfferingControllerTest.php | 37 +++++++++ tests/Unit/Offering/OfferingTest.php | 44 +++++++++++ unsupervised-schedular.php | 4 +- 18 files changed, 338 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e149469..5809831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching the plugin to the next patch version and adds a fresh section here for it. Record each change under the current top section as you work. +## [1.1.3] + +### Added +- Group classes now carry an **enrolment deadline** the instructor sets on the offering. It defaults to the first day of the class, and once it passes students can no longer enrol — the enrolment page shows the class as closed and the API rejects late enrolments. +- Instructors can add students to any group class by hand from its details page (**Add students directly**), which now appears for public classes too, not just invite-only ones. This bypasses the enrolment deadline and capacity, so a student can be enrolled as a **late enrolment** after the class has closed to self-enrolment. + ## [1.1.2] ## [1.1.1] diff --git a/assets/js/group-classes.js b/assets/js/group-classes.js index 9fd5444..ed438ca 100644 --- a/assets/js/group-classes.js +++ b/assets/js/group-classes.js @@ -103,6 +103,21 @@ return [termLabel(o), timeLabel(o)].filter(Boolean).join(' · '); } + // Today as a Y-m-d string in the visitor's local timezone, for lexicographic + // comparison against the class's Y-m-d enrolment deadline. + function todayYmd() { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + } + + // Enrolment closes at the end of the deadline day — the instructor's set + // deadline, or the first class day by default. Mirrors the server-side + // Offering::isEnrollmentOpen() gate. + function isEnrollmentOpen(o) { + const deadline = o.enrollment_deadline || o.term_start || ''; + return !deadline || todayYmd() <= deadline; + } + function renderClasses(offerings, enrolledOfferingIds) { let groups = offerings.filter((o) => o.kind === 'group_class'); if (singleOfferingId) { @@ -125,7 +140,9 @@

${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}

${enrolledOfferingIds.has(Number(o.id)) ? '

You are enrolled in this class.

' - : ``} + : (isEnrollmentOpen(o) + ? `` + : '

Enrolment has closed.

')} `).join(''); diff --git a/docs/features/group-classes.md b/docs/features/group-classes.md index d0f5b8f..c812399 100644 --- a/docs/features/group-classes.md +++ b/docs/features/group-classes.md @@ -48,6 +48,19 @@ cancelled enrolment does not block re-enrolling). Capacity is enforced at enrolment time by counting `active` rows for the offering; a class at capacity rejects further enrolments. +Enrolment also closes after the class's **enrolment deadline** (the instructor's +`enrollment_deadline`, defaulting to `term_start` — the first class day; see +`offerings.md`). Past the deadline `POST /enrollments` rejects the enrolment with +`403 enrollment_closed`, and the class list shows "Enrolment has closed." in place +of the Enrol button. + +The deadline only bounds student **self**-enrolment. An instructor (or studio admin) +can still enrol someone by hand from the class **details page** — the **Add students +directly** control, available for every group class, deliberately bypasses the +deadline (and capacity) so a **late enrolment** can be added after the class has +closed. Past the deadline the details page labels these as late enrolments. See +**Admin Interface** below. + ## REST API | Method | Endpoint | Permission | |----------|----------------------------------------------|----------------------------------| @@ -74,13 +87,15 @@ flips their grant from `invited` to `enrolled`. Access to an invite-only class is recorded in `{prefix}us_group_access` — a grant per person, separate from the enrolment itself. The instructor manages access from -**My Lessons → My Group Classes**, which renders three controls under each invite-only -class: +**My Lessons → My Group Classes**. **Add students directly** is available on every +class's details page (see **Admin Interface**); invite-only classes add two more +controls beneath it: 1. **Add students directly** — the selected registered students are enrolled immediately (`status = active`) with a **pending payment** at the class price (comp students are settled at once by `PaymentService`). No access grant is needed — this writes straight - to `us_group_enrollments` + `us_payments`. + to `us_group_enrollments` + `us_payments`. It bypasses the enrolment deadline and + capacity, so it doubles as the **late-enrolment** path after a class has closed. 2. **Make available** — the selected registered students get an `invited` grant so the class appears in their own group-class list; they then self-enrol through the normal paid flow. Each is emailed a "you've been added" notice. @@ -121,12 +136,14 @@ class becomes enrollable for them — they choose whether to enrol. against capacity, plus a **View details** link (**View & invite** for invite-only classes). Selecting a class (`?class_id=`, scoped to the owning instructor) opens its **details page**: a class-details panel (when, instructor, enrolled/capacity, duration, - price, schedule note, description, status), the roster of enrolled students with enrolment - and payment status, and — for invite-only classes — an **Invite & enrol students** section - listing who has been invited but not yet enrolled alongside the add/make-available/ - invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to the owning - instructor). Managing who is in an invite-only class is therefore done entirely from this - page. The summary (`templates/admin/my-group-classes.php`) and the details page + price, schedule note, enrolment deadline, status), the roster of enrolled students with + enrolment and payment status, and an **Add students** section. Every class — public or + invite-only — carries the **Add students directly** control there, which enrols the + selected students immediately (a late enrolment past the deadline; the section says so + when the deadline has passed). Invite-only classes additionally get the + **make-available** and **invite-by-email** controls plus the list of who has been invited + but not yet enrolled. These are nonce-checked `usc_action` POSTs, scoped to the owning + instructor. The summary (`templates/admin/my-group-classes.php`) and the details page (`templates/admin/my-group-class-detail.php`) are separate templates. ## Implementation diff --git a/docs/features/offerings.md b/docs/features/offerings.md index 91d2e37..8cb0861 100644 --- a/docs/features/offerings.md +++ b/docs/features/offerings.md @@ -21,6 +21,7 @@ An offering is anything a student can register for: a private-lesson type (30 or | `term_start` | DATE | Group / term offerings — first day; NULL otherwise | | `term_end` | DATE | Group / term offerings — last day; NULL otherwise | | `class_time` | TIME | Group only — time of day each session starts; NULL otherwise | +| `enrollment_deadline` | DATE | Group only — last day students may enrol; NULL defaults to `term_start` (the first class day) | | `schedule_note` | VARCHAR(191) | Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"| | `cancellation_cutoff_hours` | SMALLINT UNSIGNED | Optional per-offering cancellation cutoff in hours; NULL inherits the studio default (see `cancellation-cutoff.md`) | | `access_mode` | VARCHAR(20) | `public` (listed in the catalog) or `invite_only` (group classes hidden from the catalog — see `group-classes.md`) | @@ -51,6 +52,16 @@ one-off), and returns an empty list unless date, time, and a positive duration a all set. These windows drive availability reconciliation (see **Instructor assignment** below and `group-classes.md`). +## Enrolment deadline +A group class carries an optional `enrollment_deadline` the instructor sets on the +offering form (blank leaves it NULL). `Offering::effectiveEnrollmentDeadline()` +resolves it to the stored date, or to `term_start` (the first class day) when unset, +so a class with no explicit deadline still closes to new enrolments once the first +class arrives. `Offering::isEnrollmentOpen($today)` compares a `Y-m-d` "today" +against that effective deadline (inclusive — the deadline day is still open). The +enrolment endpoint enforces it (`403 enrollment_closed`) and the front-end +group-class list mirrors the same rule; see `group-classes.md`. + ## Instructor assignment Every offering has an owning `instructor_id`. A studio admin (`manage_instructors`) sees an **Instructor** picker on the offering form and may @@ -84,7 +95,7 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin. ## Implementation - Repository: `Unsupervised\Schedular\Offering\OfferingRepository` -- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`) +- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`, `effectiveEnrollmentDeadline`, `isEnrollmentOpen`) - Admin controller: `Unsupervised\Schedular\Offering\OfferingController` - REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` (public listing includes `instructor_name`) - Availability reconciliation: `Unsupervised\Schedular\Offering\ClassSlotReconciler` (uses `Availability\AvailabilityRepository::findOverlapping`) diff --git a/src/GroupClass/EnrollmentEndpoint.php b/src/GroupClass/EnrollmentEndpoint.php index 35b2eb1..74beb8e 100644 --- a/src/GroupClass/EnrollmentEndpoint.php +++ b/src/GroupClass/EnrollmentEndpoint.php @@ -95,6 +95,12 @@ class EnrollmentEndpoint { return new \WP_Error( 'invite_required', __( 'This class is by invitation only.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } + // Enrolment closes at the end of the deadline day — the instructor's set + // deadline, or the first class day by default. + if ( ! $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) { + return new \WP_Error( 'enrollment_closed', __( 'Enrolment for this class has closed.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); + } + if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) { return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } diff --git a/src/GroupClass/GroupClassController.php b/src/GroupClass/GroupClassController.php index 335a370..451ed22 100644 --- a/src/GroupClass/GroupClassController.php +++ b/src/GroupClass/GroupClassController.php @@ -176,7 +176,7 @@ class GroupClassController { * invite-only classes — the list of people invited but not yet enrolled. * * @param list $enrollments - * @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, active: bool, roster: list, invited: list} + * @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list, invited: list} */ private function classDetail( Offering $offering, array $enrollments ): array { $roster = []; @@ -195,16 +195,20 @@ class GroupClassController { ]; } + $deadline = $offering->effectiveEnrollmentDeadline(); + return $this->classSummary( $offering, $enrollments ) + [ - 'instructor' => $this->instructorName( $offering ), - 'price' => $offering->price, - 'currency' => $offering->currency, - 'duration' => $offering->durationMinutes, - 'description' => $offering->description, - 'schedule_note' => $offering->scheduleNote, - 'active' => $offering->isActive, - 'roster' => $roster, - 'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [], + 'instructor' => $this->instructorName( $offering ), + 'price' => $offering->price, + 'currency' => $offering->currency, + 'duration' => $offering->durationMinutes, + 'description' => $offering->description, + 'schedule_note' => $offering->scheduleNote, + 'deadline' => null !== $deadline ? (string) mysql2date( 'M j, Y', $deadline ) : '', + 'enrollment_open' => $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ), + 'active' => $offering->isActive, + 'roster' => $roster, + 'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [], ]; } @@ -312,6 +316,10 @@ class GroupClassController { * Directly enrol registered students, each with a pending payment at the * class price (comp students are settled immediately by the payment service). * + * This is the instructor's manual enrolment path and deliberately bypasses the + * enrolment deadline and capacity, so a student can be added as a late + * enrolment after the class has closed to self-enrolment. + * * @param list $studentIds */ private function addDirect( Offering $offering, array $studentIds ): string { diff --git a/src/Offering/Offering.php b/src/Offering/Offering.php index 54cdfc2..2b6dcf9 100644 --- a/src/Offering/Offering.php +++ b/src/Offering/Offering.php @@ -54,6 +54,7 @@ class Offering { public readonly ?string $termStart = null, public readonly ?string $termEnd = null, public readonly ?string $classTime = null, + public readonly ?string $enrollmentDeadline = null, public readonly ?string $scheduleNote = null, public readonly ?string $etransferEmail = null, public readonly ?int $cancellationCutoffHours = null, @@ -70,6 +71,27 @@ class Offering { return self::ACCESS_INVITE_ONLY === $this->accessMode; } + /** + * The last day on which a student may enrol in this group class. Defaults to + * the first day of the class (`term_start`) when the instructor has not set an + * explicit deadline; null only when the class has no dates at all. + */ + public function effectiveEnrollmentDeadline(): ?string { + return $this->enrollmentDeadline ?? $this->termStart; + } + + /** + * Whether enrolment is still open on `$today` (a `Y-m-d` date). Enrolment stays + * open through the end of the deadline day, so the first class is still + * enrollable under the default deadline. A class with no deadline at all (no + * dates configured) is always open. + */ + public function isEnrollmentOpen( string $today ): bool { + $deadline = $this->effectiveEnrollmentDeadline(); + + return null === $deadline || $today <= $deadline; + } + /** * 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 @@ -169,6 +191,7 @@ class Offering { termStart: Val::stringOrNull( $row->term_start ), termEnd: Val::stringOrNull( $row->term_end ), classTime: Val::stringOrNull( $row->class_time ?? null ), + enrollmentDeadline: Val::stringOrNull( $row->enrollment_deadline ?? null ), scheduleNote: Val::stringOrNull( $row->schedule_note ), etransferEmail: Val::stringOrNull( $row->etransfer_email ), cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ), @@ -203,6 +226,7 @@ class Offering { 'term_start' => $this->termStart, 'term_end' => $this->termEnd, 'class_time' => $this->classTime, + 'enrollment_deadline' => $this->enrollmentDeadline, 'schedule_note' => $this->scheduleNote, 'cancellation_cutoff_hours' => $this->cancellationCutoffHours, 'access_mode' => $this->accessMode, diff --git a/src/Offering/OfferingController.php b/src/Offering/OfferingController.php index 5e4ccfa..3136dcf 100644 --- a/src/Offering/OfferingController.php +++ b/src/Offering/OfferingController.php @@ -209,6 +209,10 @@ class OfferingController { $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'] ?? '' ) ) ) ); + return new Offering( instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ), kind: $kind, @@ -223,6 +227,7 @@ class OfferingController { termStart: $termStart, termEnd: $termEnd, classTime: $classTime, + enrollmentDeadline: $enrollmentDeadline, 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, diff --git a/src/Offering/OfferingEndpoint.php b/src/Offering/OfferingEndpoint.php index 1737df4..9532ebc 100644 --- a/src/Offering/OfferingEndpoint.php +++ b/src/Offering/OfferingEndpoint.php @@ -161,6 +161,7 @@ class OfferingEndpoint { capacity: $this->nullableInt( $request->get_param( 'capacity' ) ), termStart: $this->nullableText( $request->get_param( 'term_start' ) ), termEnd: $this->nullableText( $request->get_param( 'term_end' ) ), + enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ), scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ), etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ), cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ), @@ -208,6 +209,7 @@ class OfferingEndpoint { capacity: $request->has_param( 'capacity' ) ? $this->nullableInt( $request->get_param( 'capacity' ) ) : $existing->capacity, termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart, termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd, + enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline, scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote, etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail, cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours, diff --git a/src/Offering/OfferingRepository.php b/src/Offering/OfferingRepository.php index cf1e3fa..e3b7807 100644 --- a/src/Offering/OfferingRepository.php +++ b/src/Offering/OfferingRepository.php @@ -14,12 +14,13 @@ class OfferingRepository { /** * Column formats aligned to {@see columns()} (instructor_id, kind, title, * description, duration_minutes, price, currency, billing_mode, allow_weekly, - * capacity, term_start, term_end, class_time, schedule_note, etransfer_email, - * cancellation_cutoff_hours, access_mode, is_active). + * capacity, term_start, term_end, class_time, enrollment_deadline, + * schedule_note, etransfer_email, cancellation_cutoff_hours, access_mode, + * is_active). * * @var list */ - private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ]; + private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ]; public function insert( Offering $offering ): int { $this->db->insert( @@ -61,6 +62,7 @@ class OfferingRepository { 'term_start' => $offering->termStart, 'term_end' => $offering->termEnd, 'class_time' => $offering->classTime, + 'enrollment_deadline' => $offering->enrollmentDeadline, 'schedule_note' => $offering->scheduleNote, 'etransfer_email' => $offering->etransferEmail, 'cancellation_cutoff_hours' => $offering->cancellationCutoffHours, diff --git a/src/Schema.php b/src/Schema.php index 3e2af10..98dbb8e 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -64,6 +64,7 @@ class Schema { term_start DATE DEFAULT NULL, term_end DATE DEFAULT NULL, class_time TIME DEFAULT NULL, + enrollment_deadline DATE DEFAULT NULL, schedule_note VARCHAR(191) DEFAULT NULL, etransfer_email VARCHAR(191) DEFAULT NULL, cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL, diff --git a/templates/admin/my-group-class-detail.php b/templates/admin/my-group-class-detail.php index 0a9f083..0ebdee7 100644 --- a/templates/admin/my-group-class-detail.php +++ b/templates/admin/my-group-class-detail.php @@ -6,7 +6,7 @@ if (! defined('ABSPATH')) { } /** - * @var array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, active: bool, roster: list, invited: list} $class + * @var array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list, invited: list} $class * @var list $students * @var string $notice * @var string $baseUrl @@ -78,6 +78,12 @@ if (! defined('ABSPATH')) { + + + + + + @@ -130,35 +136,51 @@ if (! defined('ABSPATH')) { - -

+

+ +

+ +

+

+ - -

-
    - -
  • + +

    +
      + +
    • + +
    + + +
    +
    + + +

    +

    + +

    + -

    -

    - -

    - -

    -
    + +

    + +

    + +
    @@ -184,6 +206,6 @@ if (! defined('ABSPATH')) {

    -
    - + + diff --git a/templates/admin/offerings.php b/templates/admin/offerings.php index 5ed61e5..86679b4 100644 --- a/templates/admin/offerings.php +++ b/templates/admin/offerings.php @@ -112,6 +112,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e + + + + +

    + + diff --git a/tests/Unit/GroupClass/EnrollmentEndpointTest.php b/tests/Unit/GroupClass/EnrollmentEndpointTest.php index 489a785..d133aa7 100644 --- a/tests/Unit/GroupClass/EnrollmentEndpointTest.php +++ b/tests/Unit/GroupClass/EnrollmentEndpointTest.php @@ -32,6 +32,7 @@ class EnrollmentEndpointTest extends TestCase Functions\when('wp_unslash')->returnArg(); Functions\when('sanitize_text_field')->returnArg(); Functions\when('get_current_user_id')->justReturn(5); + Functions\when('current_time')->justReturn('2026-07-24'); $this->enrollments = Mockery::mock(EnrollmentRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class); @@ -108,6 +109,36 @@ class EnrollmentEndpointTest extends TestCase ); } + public function testRejectsEnrollmentAfterExplicitDeadline(): void + { + // current_time is stubbed to 2026-07-24, past the 2026-07-10 deadline. + $offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-01', enrollmentDeadline: '2026-07-10', id: 8); + $this->offerings->shouldReceive('findById')->with(8)->andReturn($offering); + $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false); + $this->enrollments->shouldReceive('insert')->never(); + + $result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8])); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('enrollment_closed', $result->get_error_code()); + self::assertSame(403, $result->error_data['enrollment_closed']['status']); + } + + public function testRejectsEnrollmentAfterDefaultDeadlineOfFirstClassDay(): void + { + // No explicit deadline, so it defaults to term_start (the first class day), + // which is in the past relative to the stubbed 2026-07-24 "today". + $offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-20', id: 8); + $this->offerings->shouldReceive('findById')->with(8)->andReturn($offering); + $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false); + $this->enrollments->shouldReceive('insert')->never(); + + $result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8])); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('enrollment_closed', $result->get_error_code()); + } + public function testInviteOnlyClassRejectsStudentWithoutGrant(): void { $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering()); diff --git a/tests/Unit/GroupClass/GroupClassControllerTest.php b/tests/Unit/GroupClass/GroupClassControllerTest.php index aa17e14..b38c6d7 100644 --- a/tests/Unit/GroupClass/GroupClassControllerTest.php +++ b/tests/Unit/GroupClass/GroupClassControllerTest.php @@ -65,6 +65,7 @@ class GroupClassControllerTest extends TestCase static fn (string $format, string $date) => date($format, (int) strtotime($date)) ); Functions\when('wp_nonce_field')->justReturn(''); + Functions\when('current_time')->justReturn('2026-01-01'); $_GET = []; } @@ -183,6 +184,49 @@ class GroupClassControllerTest extends TestCase self::assertStringContainsString('Invite by email', $html); } + public function testClassDetailOffersDirectAddForPublicClassWithoutInviteControls(): void + { + Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); + $_GET = ['class_id' => '8']; + + // A plain public group class — the instructor can still add students + // directly (a late enrolment), but the invite-only controls are absent. + $offering = $this->offering(8, 'Choir', 10); + + $this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]); + $this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('Add students directly', $html); + self::assertStringContainsString('add_direct', $html); + self::assertStringNotContainsString('Invite by email', $html); + self::assertStringNotContainsString('Make available to students', $html); + } + + public function testClassDetailFlagsLateEnrolmentPastTheDeadline(): void + { + Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); + // current_time is stubbed to 2026-01-01, which is past this class's deadline. + $_GET = ['class_id' => '8']; + + $offering = new Offering( + instructorId: 3, + kind: Offering::KIND_GROUP_CLASS, + title: 'Choir', + termStart: '2025-09-08', + id: 8, + ); + + $this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]); + $this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('late enrolments', $html); + self::assertStringContainsString('Add students directly', $html); + } + public function testClassDetailEnrolmentCountExcludesCancelledButRosterKeepsThem(): void { Functions\when('get_userdata')->justReturn($this->userNamed('Grace Hopper')); diff --git a/tests/Unit/Offering/OfferingControllerTest.php b/tests/Unit/Offering/OfferingControllerTest.php index b78ddf7..ec72e42 100644 --- a/tests/Unit/Offering/OfferingControllerTest.php +++ b/tests/Unit/Offering/OfferingControllerTest.php @@ -102,6 +102,43 @@ class OfferingControllerTest extends TestCase self::assertStringContainsString('2 open booking slots were removed', $html); } + public function testAddGroupClassStoresEnrollmentDeadline(): void + { + $_POST = [ + 'usc_action' => 'add', + 'title' => 'Ballet Beginners', + 'kind' => Offering::KIND_GROUP_CLASS, + 'term_start' => '2026-09-08', + 'enrollment_deadline' => '2026-08-31', + ]; + + $this->repository->shouldReceive('insert')->once()->with(Mockery::on( + static fn (Offering $o) => '2026-08-31' === $o->enrollmentDeadline + ))->andReturn(1); + $this->repository->shouldReceive('findAll')->andReturn([]); + $this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]); + + $this->render(); + } + + public function testBlankEnrollmentDeadlineLeavesItNullToDefaultToFirstClass(): void + { + $_POST = [ + 'usc_action' => 'add', + 'title' => 'Choir', + 'kind' => Offering::KIND_GROUP_CLASS, + 'term_start' => '2026-09-08', + ]; + + $this->repository->shouldReceive('insert')->once()->with(Mockery::on( + static fn (Offering $o) => null === $o->enrollmentDeadline + ))->andReturn(1); + $this->repository->shouldReceive('findAll')->andReturn([]); + $this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]); + + $this->render(); + } + public function testGarbageClassTimeIsRejected(): void { $_POST = [ diff --git a/tests/Unit/Offering/OfferingTest.php b/tests/Unit/Offering/OfferingTest.php index 615ca92..a5286bd 100644 --- a/tests/Unit/Offering/OfferingTest.php +++ b/tests/Unit/Offering/OfferingTest.php @@ -277,4 +277,48 @@ class OfferingTest extends TestCase self::assertContains(Offering::BILLING_ONE_TIME, Offering::VALID_BILLING_MODES); self::assertContains(Offering::BILLING_FULL_TERM, Offering::VALID_BILLING_MODES); } + + public function testEffectiveEnrollmentDeadlineDefaultsToTermStart(): void + { + $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08'); + + self::assertSame('2026-09-08', $offering->effectiveEnrollmentDeadline()); + } + + public function testEffectiveEnrollmentDeadlineUsesExplicitValueWhenSet(): void + { + $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', enrollmentDeadline: '2026-08-31'); + + self::assertSame('2026-08-31', $offering->effectiveEnrollmentDeadline()); + } + + public function testEffectiveEnrollmentDeadlineIsNullWithoutDates(): void + { + $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir'); + + self::assertNull($offering->effectiveEnrollmentDeadline()); + } + + public function testIsEnrollmentOpenOnAndBeforeTheDeadlineDay(): void + { + $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', enrollmentDeadline: '2026-08-31'); + + self::assertTrue($offering->isEnrollmentOpen('2026-08-30')); + self::assertTrue($offering->isEnrollmentOpen('2026-08-31')); + self::assertFalse($offering->isEnrollmentOpen('2026-09-01')); + } + + public function testIsEnrollmentOpenAlwaysTrueWithoutADeadline(): void + { + $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir'); + + self::assertTrue($offering->isEnrollmentOpen('2099-01-01')); + } + + public function testToArrayIncludesEnrollmentDeadline(): void + { + $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', enrollmentDeadline: '2026-08-31', id: 10); + + self::assertSame('2026-08-31', $offering->toArray()['enrollment_deadline']); + } } diff --git a/unsupervised-schedular.php b/unsupervised-schedular.php index 36ffc36..472bf1b 100644 --- a/unsupervised-schedular.php +++ b/unsupervised-schedular.php @@ -3,7 +3,7 @@ * Plugin Name: Unsupervised Scheduler * Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler * Description: Instructor/student lesson scheduling for WordPress. - * Version: 1.1.2 + * Version: 1.1.3 * Requires at least: 6.2 * Requires PHP: 8.1 * Author: Unsupervised @@ -21,7 +21,7 @@ if (! defined('ABSPATH')) { exit; } -define('USC_VERSION', '1.1.2'); +define('USC_VERSION', '1.1.3'); define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));