CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 3m2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped
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 <[email protected]>
109 lines
7.9 KiB
Markdown
109 lines
7.9 KiB
Markdown
# Feature: Offerings
|
||
|
||
## Overview
|
||
An offering is anything a student can register for: a private-lesson type (30 or 60 minutes, single or weekly) or a group class. Offerings carry pricing, billing mode, and the intake questions a registrant must answer. They are the catalog the booking calendar and group-class pages are built from.
|
||
|
||
## Data Model — `{prefix}us_offerings`
|
||
|
||
| Column | Type | Notes |
|
||
|--------------------|------------------|-------------------------------------------------------------|
|
||
| `id` | BIGINT UNSIGNED | Primary key |
|
||
| `instructor_id` | BIGINT UNSIGNED | WordPress user ID of the owning instructor |
|
||
| `kind` | VARCHAR(20) | `private_lesson` or `group_class` |
|
||
| `title` | VARCHAR(191) | Display name |
|
||
| `description` | TEXT | Optional longer description |
|
||
| `duration_minutes` | SMALLINT | Private lessons only (e.g. 30, 60); NULL for group classes |
|
||
| `price` | DECIMAL(10,2) | Price in dollars |
|
||
| `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` |
|
||
| `billing_mode` | VARCHAR(20) | `one_time` (single booking) or `full_term` (weekly / group) |
|
||
| `allow_weekly` | TINYINT(1) | Private only — may be reserved weekly for the term |
|
||
| `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 |
|
||
| `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`) |
|
||
| `is_active` | TINYINT(1) | 0 = hidden from registration, 1 = bookable |
|
||
| `created_at` | DATETIME | Insertion time |
|
||
|
||
## Billing Mode
|
||
- `one_time` — charged once at booking (a single private lesson).
|
||
- `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`.
|
||
|
||
## Term Dates
|
||
Group classes carry a term: `term_start` is the date of the first class and
|
||
`term_end` the last. The add-offering form takes a start date plus a sessions
|
||
control — **one-off** (the term ends the day it starts) or **weekly for N
|
||
sessions** (`term_end = term_start + (N−1) weeks`, computed by
|
||
`Offering::weeklyTermEnd()`). Dates are validated by `Offering::normalizeDate()`
|
||
(strict `Y-m-d`); an invalid start date leaves both term columns NULL. The
|
||
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`).
|
||
|
||
## 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
|
||
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.
|
||
- Instructor (`manage_offerings`) manages only their own.
|
||
- Each offering's intake questions are edited from the offering screen (see `registration-questions.md`).
|
||
- The offerings list shows each offering's ID (needed for `[us_group_classes offering="…"]`) and its term dates.
|
||
- **Edit** on a row reloads the page (`?usc_edit=<id>`) with the form prefilled; saving posts `usc_action=update`. Owner and currency are always preserved on update, so a form submission can never reassign an offering. Non-admin instructors can only load and update their own offerings.
|
||
- The form includes a **description** textarea and an **Active — open for registration** checkbox (unchecking hides the offering from students without deleting it — the admin-UI counterpart of the REST `is_active` flag).
|
||
|
||
## REST API
|
||
| Method | Endpoint | Permission |
|
||
|----------|---------------------------------------------|----------------------------------|
|
||
| `GET` | `/wp-json/us-scheduler/v1/offerings` | Public (active offerings only) |
|
||
| `POST` | `/wp-json/us-scheduler/v1/offerings` | `manage_offerings` |
|
||
| `PATCH` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner |
|
||
| `DELETE` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner |
|
||
|
||
`GET` supports query params: `instructor_id`, `kind`.
|
||
|
||
## Implementation
|
||
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository`
|
||
- 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`)
|
||
|
||
## 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`
|