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
+32 -13
View File
@@ -17,11 +17,17 @@ A group class can be marked **invite-only** (`us_offerings.access_mode = invite_
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
| `enrolled_at` | DATETIME | Insertion time |
## Class Dates
A group class offering carries `term_start`/`term_end` (see `offerings.md`):
one-off classes end the day they start; weekly classes run a set number of
sessions. The class card on the enrolment page shows the date or date range
with the session count.
## Class Dates, Time, and Instructor
A group class offering carries `term_start`/`term_end` plus a `class_time` and an
owning `instructor_id` (see `offerings.md`): one-off classes end the day they
start; weekly classes run a set number of sessions, all at `class_time`. The class
card on the enrolment page shows **when** the class meets (the date or date range
plus the start time) and **who** teaches it (the assigned instructor's display
name, surfaced as `instructor_name` on the `GET /offerings` response).
Assigning an instructor to a scheduled class removes that instructor's open
booking slots at the class time and flags any already-booked lesson that clashes;
see **Instructor assignment** in `offerings.md`.
## Enrolment Flow
The class list is loaded together with the student's own enrolments
@@ -100,19 +106,32 @@ class becomes enrollable for them — they choose whether to enrol.
| `created_at` | DATETIME | Insertion time |
## Admin Interface
- **Group Classes** (`view_all_lessons` / studio admin): all active enrolments across instructors
- **My Lessons → My Group Classes** (`view_own_lessons` / instructor): the instructor's
own group classes, each showing its active-enrolment count against capacity and a
per-class roster of enrolled students with enrolment and payment status. Invite-only
classes additionally list who has been invited but not yet enrolled and carry the
add/make-available/invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to
the owning instructor)
- **Group Classes** (`view_all_lessons` / studio admin): a per-class summary across
instructors — each class with its instructor, when it meets, and its active-enrolment
count against capacity (not a flat list of individual student enrolments). Selecting a
class (`?class_id=<id>`) opens the same per-class **details page** described below, so a
studio admin — including an owner-operator who also teaches, for whom the instructor
**My Group Classes** menu is hidden — can view any class's roster and manage invite-only
membership from here. Invite actions are permitted for the class's own instructor or any
`view_all_lessons` studio admin.
- **My Lessons → My Group Classes** (`view_own_lessons` / instructor): a summary of the
instructor's own group classes — each with when it meets and its active-enrolment count
against capacity, plus a **View details** link (**View & invite** for invite-only
classes). Selecting a class (`?class_id=<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
(`templates/admin/my-group-class-detail.php`) are separate templates.
## Implementation
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
- Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`)
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController``renderPage` (studio admin, `view_all_lessons`) and `renderInstructorPage` (instructor, `view_own_lessons`)
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController``renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`)
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute)
- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
+28 -2
View File
@@ -20,6 +20,7 @@ An offering is anything a student can register for: a private-lesson type (30 or
| `capacity` | SMALLINT | Group only — max enrolments; NULL for private |
| `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 |
| `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`) |
@@ -40,6 +41,28 @@ sessions** (`term_end = term_start + (N1) weeks`, computed by
student-facing class card shows the date (one-off) or the date range with the
weekly session count.
## Class Time and Sessions
A group class also carries `class_time` — the time of day each session starts —
validated by `Offering::normalizeTime()` (strict `H:i`/`H:i:s`; garbage leaves it
NULL). `class_time` + `term_start`/`term_end` + `duration_minutes` together define
the concrete session windows: `Offering::sessionWindows()` returns one
`{start, end}` per session (weekly across the term, or a single window for a
one-off), and returns an empty list unless date, time, and a positive duration are
all set. These windows drive availability reconciliation (see **Instructor
assignment** below and `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
assign a group class to any instructor; a plain instructor never sees the picker
and always owns the classes they create (the posted value is ignored for them, and
updates never reassign the owner otherwise). When a group class is saved with an
assigned instructor and a full schedule, `Offering\ClassSlotReconciler` clears that
instructor's **open** availability slots overlapping each session so students can't
book them, and reports any **already-booked** lesson that clashes as a conflict for
the studio to resolve by hand (a booked lesson is never deleted). The result is
surfaced as an admin notice after saving.
## Admin Interface
Studio admin and instructors manage offerings under **Offerings** in wp-admin.
- Studio admin (`manage_offerings`) manages offerings for any instructor.
@@ -61,11 +84,14 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
## Implementation
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository`
- Model: `Unsupervised\Schedular\Offering\Offering`
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`)
- Admin controller: `Unsupervised\Schedular\Offering\OfferingController`
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint`
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` (public listing includes `instructor_name`)
- Availability reconciliation: `Unsupervised\Schedular\Offering\ClassSlotReconciler` (uses `Availability\AvailabilityRepository::findOverlapping`)
## Tests
- `tests/Unit/Offering/OfferingControllerTest.php`
- `tests/Unit/Offering/OfferingRepositoryTest.php`
- `tests/Unit/Offering/OfferingTest.php`
- `tests/Unit/Offering/OfferingEndpointTest.php`
- `tests/Unit/Offering/ClassSlotReconcilerTest.php`