The block editor's group-class picker fetches GET /offerings, whose permission callback only accepted book_lesson — a capability held by students alone. Administrators and instructors editing a page were rejected with a 403 and the picker silently rendered an empty list. Read access now accepts book_lesson or manage_offerings. The listing is unchanged: active offerings only, public ones plus the invite-only classes the caller has been granted, without the e-transfer email. Closes #121 Co-Authored-By: Claude Opus 5 <[email protected]>
9.6 KiB
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, full_term, weekly, or monthly (see Billing Mode below) |
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) |
withdrawal_deadline |
DATE | Group only — last day a student may withdraw themselves; NULL keeps self-withdrawal open indefinitely |
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). Seepayments.md.weekly— not charged at registration; a pending payment for one lesson's fee is generated 24 hours before each lesson by the daily billing scan.monthly— not charged at registration; on the 1st of each month a single pending payment is generated for every lesson that falls in that month (4 lessons ⇒ 4 × fee).
weekly and monthly are scheduled billing (Offering::isScheduledBilling()): the
booking/enrolment succeeds with no payment step, and payments are created later by the
daily us_generate_due_payments cron scan. See scheduled-billing.md and 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.
Withdrawal deadline
A group class also carries an optional withdrawal_deadline — the last day a
student may withdraw themselves from the class. Unlike the enrolment deadline it
has no implicit default: Offering::isWithdrawalOpen($today) treats an unset
(NULL) deadline as always open, so a class only closes to self-withdrawal once the
instructor sets a date and it passes (comparison is inclusive — the deadline day is
still open). A withdrawal made while open frees the seat and voids any still-pending
payment but never issues an account credit (credits are reserved for cancelled
lessons; see credits.md). Once the deadline passes the student must contact the
studio, and an admin withdraws them by hand from the student detail page — the admin
path is never subject to the deadline. The student endpoint enforces it
(403 withdrawal_closed) and the front-end group-class list mirrors the 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 postsusc_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_activeflag).
REST API
| Method | Endpoint | Permission |
|---|---|---|
GET |
/wp-json/us-scheduler/v1/offerings |
book_lesson or manage_offerings (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 includesinstructor_name) - Availability reconciliation:
Unsupervised\Schedular\Offering\ClassSlotReconciler(usesAvailability\AvailabilityRepository::findOverlapping)
Tests
tests/Unit/Offering/OfferingControllerTest.phptests/Unit/Offering/OfferingRepositoryTest.phptests/Unit/Offering/OfferingTest.phptests/Unit/Offering/OfferingEndpointTest.phptests/Unit/Offering/ClassSlotReconcilerTest.php