Compare commits
11
Commits
v1.1.0
...
36e7178158
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36e7178158 | ||
|
|
32619a1b75
|
||
|
|
1a447743b3 | ||
|
|
fc7c0fa966
|
||
|
|
bf29162587
|
||
|
|
991ed2f5ad | ||
|
|
ad2ddefebf | ||
|
|
1fe28d5575 | ||
|
|
51dd032668
|
||
|
|
37ec8a3315 | ||
|
|
f93c5aba05 |
@@ -11,6 +11,19 @@ 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. While enrolment is open, each class card shows an "Enrol by" date.
|
||||
- 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]
|
||||
|
||||
### Fixed
|
||||
- The **Enable auto-updates** toggle now appears for the plugin on the Plugins screen. The self-updater now reports the plugin to WordPress even when it is already current, so core marks it update-supported and shows the toggle; previously the toggle was hidden between releases.
|
||||
|
||||
## [1.1.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -46,6 +46,26 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.us-my-lesson-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.us-my-lesson-title {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.us-my-lesson-duration {
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.us-my-lesson-when {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.us-my-lesson-actions {
|
||||
@@ -54,6 +74,18 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.us-show-all-lessons {
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.us-show-all-lessons:hover {
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
.us-cancel-lesson {
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
|
||||
+38
-9
@@ -388,6 +388,25 @@
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
// How many upcoming lessons to show before the "Show all" reveal.
|
||||
const INITIAL_LESSON_COUNT = 5;
|
||||
|
||||
function lessonRowHtml(l) {
|
||||
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson';
|
||||
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
|
||||
return `
|
||||
<div class="us-my-lesson">
|
||||
<span class="us-my-lesson-info">
|
||||
<strong class="us-my-lesson-title">${title}${duration}</strong>
|
||||
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}</span>
|
||||
</span>
|
||||
<span class="us-my-lesson-actions">
|
||||
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
|
||||
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMyLessons(lessons) {
|
||||
const upcoming = lessons.filter((l) => l.start_dt);
|
||||
if (!upcoming.length) {
|
||||
@@ -395,20 +414,30 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Show only the soonest few by default; the rest sit hidden behind a
|
||||
// reveal so a busy student's list stays short.
|
||||
const visible = upcoming.slice(0, INITIAL_LESSON_COUNT);
|
||||
const hidden = upcoming.slice(INITIAL_LESSON_COUNT);
|
||||
|
||||
myLessons.innerHTML = `
|
||||
<div class="us-my-lessons">
|
||||
<h3>Your upcoming lessons</h3>
|
||||
${upcoming.map((l) => `
|
||||
<div class="us-my-lesson">
|
||||
<span>${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}</span>
|
||||
<span class="us-my-lesson-actions">
|
||||
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
|
||||
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${visible.map(lessonRowHtml).join('')}
|
||||
${hidden.length ? `
|
||||
<div class="us-my-lessons-more" hidden>${hidden.map(lessonRowHtml).join('')}</div>
|
||||
<button type="button" class="us-show-all-lessons">Show all ${upcoming.length} lessons</button>
|
||||
` : ''}
|
||||
</div>`;
|
||||
|
||||
const moreBox = myLessons.querySelector('.us-my-lessons-more');
|
||||
const showAll = myLessons.querySelector('.us-show-all-lessons');
|
||||
if (showAll && moreBox) {
|
||||
showAll.addEventListener('click', () => {
|
||||
moreBox.hidden = false;
|
||||
showAll.remove();
|
||||
});
|
||||
}
|
||||
|
||||
myLessons.querySelectorAll('.us-cancel-lesson').forEach((btn) => {
|
||||
btn.addEventListener('click', () => cancelLesson(Number(btn.dataset.lessonId)));
|
||||
});
|
||||
|
||||
@@ -103,6 +103,26 @@
|
||||
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')}`;
|
||||
}
|
||||
|
||||
// The effective enrolment deadline: the instructor's set deadline, or the
|
||||
// first class day by default. Empty when the class has no dates at all.
|
||||
function enrolmentDeadline(o) {
|
||||
return o.enrollment_deadline || o.term_start || '';
|
||||
}
|
||||
|
||||
// Enrolment closes at the end of the deadline day. Mirrors the server-side
|
||||
// Offering::isEnrollmentOpen() gate.
|
||||
function isEnrollmentOpen(o) {
|
||||
const deadline = enrolmentDeadline(o);
|
||||
return !deadline || todayYmd() <= deadline;
|
||||
}
|
||||
|
||||
function renderClasses(offerings, enrolledOfferingIds) {
|
||||
let groups = offerings.filter((o) => o.kind === 'group_class');
|
||||
if (singleOfferingId) {
|
||||
@@ -123,9 +143,14 @@
|
||||
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
||||
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
||||
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
|
||||
${!enrolledOfferingIds.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
|
||||
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
|
||||
: ''}
|
||||
${enrolledOfferingIds.has(Number(o.id))
|
||||
? '<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>'
|
||||
: `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`}
|
||||
: (isEnrollmentOpen(o)
|
||||
? `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`
|
||||
: '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>')}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
|
||||
@@ -48,6 +48,20 @@ 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. While enrolment is still open the class card shows an
|
||||
"Enrol by" line with the effective deadline date.
|
||||
|
||||
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 +88,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 +137,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=<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
|
||||
|
||||
@@ -29,7 +29,7 @@ Students register for a private lesson by choosing an offering, picking a time (
|
||||
7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
|
||||
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) with a per-lesson status badge (pending payment / confirmed) and a **Cancel** button.
|
||||
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
||||
|
||||
## Cancellation
|
||||
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
|
||||
@@ -85,7 +85,12 @@ kind `group_class`; see `group-classes.md`.
|
||||
Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week`
|
||||
query params, same pattern as the availability page, bucketed via
|
||||
`Availability\WeekCalendar`), with the original table available as the **List**
|
||||
view — the list is where the per-lesson HST and e-transfer edit forms live.
|
||||
view — the list is where the per-lesson HST and e-transfer edit forms live. Both
|
||||
views show the booked offering's name, and each lesson links through (`?lesson_id=`)
|
||||
to a **detail view** (`LessonController::maybeRenderDetail()`) that shows the
|
||||
offering, time, status, notes, the policy versions the student accepted (with
|
||||
acceptance time and IP), and their intake-question answers. On **My Lessons** an
|
||||
instructor may only open their own lessons; the studio **Scheduler** may open any.
|
||||
|
||||
## Frontend Shortcodes
|
||||
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability
|
||||
@@ -96,6 +101,7 @@ view — the list is where the per-lesson HST and e-transfer edit forms live.
|
||||
- Model: `Unsupervised\Schedular\Booking\Lesson`
|
||||
- Registration gate: `Unsupervised\Schedular\Registration\RegistrationGate` — validates and records intake answers + booking-scoped policy acceptances; shared with group enrolment
|
||||
- Admin controller: `Unsupervised\Schedular\Booking\LessonController`
|
||||
- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`
|
||||
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
|
||||
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
|
||||
|
||||
@@ -109,3 +115,6 @@ view — the list is where the per-lesson HST and e-transfer edit forms live.
|
||||
## Tests
|
||||
- `tests/Unit/Booking/BookingRepositoryTest.php`
|
||||
- `tests/Unit/Booking/LessonTest.php`
|
||||
- `tests/Unit/Booking/LessonControllerTest.php`
|
||||
- `tests/Unit/Booking/LessonDetailTest.php`
|
||||
- `tests/Unit/Booking/BookingEndpointTest.php`
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -50,9 +50,16 @@ update for a same-slug plugin and makes core fire the
|
||||
4. When newer, returns the release's first `.zip` asset as the update
|
||||
package. Core takes over from there: Plugins-screen notice, one-click
|
||||
update, and WP-Cron auto-updates if enabled.
|
||||
5. When not newer — the site is current, or the lookup failed — returns a
|
||||
`no_update` payload (installed version, empty package). This keeps the
|
||||
plugin in core's `update_plugins` transient so core's `update-supported`
|
||||
flag stays set and the **Enable auto-updates** toggle shows on the
|
||||
Plugins screen. Without it, an off-directory plugin is absent from the
|
||||
transient between releases and the toggle never appears.
|
||||
|
||||
Any API failure, malformed response, or asset-less release degrades to
|
||||
"no update available" — never an error surfaced to the site.
|
||||
"no update available" (the `no_update` payload) — never an error surfaced
|
||||
to the site, and never a lost auto-update toggle during a Gitea blip.
|
||||
|
||||
## Cutting a Release
|
||||
1. Bump the version in `unsupervised-schedular.php` (both the `Version:`
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@ use Unsupervised\Schedular\Auth\StudentController;
|
||||
use Unsupervised\Schedular\Auth\StudentHistory;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassController;
|
||||
@@ -57,7 +58,7 @@ class AdminMenu {
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer ) {
|
||||
$this->availabilityController = new AvailabilityController( $availability, $offerings );
|
||||
$this->lessonController = new LessonController( $bookings, $payments, $availability );
|
||||
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
|
||||
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
|
||||
$this->questionController = new QuestionController( $questions, $offerings );
|
||||
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
|
||||
|
||||
@@ -123,17 +123,27 @@ class BookingEndpoint {
|
||||
}
|
||||
|
||||
/**
|
||||
* A lesson's array form plus its slot's start/end times, so front-end lists
|
||||
* can show when the session happens without a second request.
|
||||
* A lesson's array form plus its slot's start/end times and the booked
|
||||
* offering's name, so front-end lists can show what the session is and when
|
||||
* it happens without a second request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function lessonWithTimes( Lesson $lesson ): array {
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
|
||||
|
||||
// Prefer the offering's own length; fall back to the slot's when the
|
||||
// offering has none (a generic, duration-less type).
|
||||
$duration = null !== $offering && null !== $offering->durationMinutes
|
||||
? $offering->durationMinutes
|
||||
: $slot?->durationMinutes;
|
||||
|
||||
return $lesson->toArray() + [
|
||||
'start_dt' => $slot?->startDt,
|
||||
'end_dt' => $slot?->endDt,
|
||||
'start_dt' => $slot?->startDt,
|
||||
'end_dt' => $slot?->endDt,
|
||||
'offering_title' => $offering?->title,
|
||||
'duration_minutes' => $duration,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Availability\WeekCalendar;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
@@ -17,6 +18,8 @@ class LessonController {
|
||||
private BookingRepository $repository,
|
||||
private PaymentRepository $payments,
|
||||
private AvailabilityRepository $availability,
|
||||
private OfferingRepository $offerings,
|
||||
private LessonDetail $detail,
|
||||
) {}
|
||||
|
||||
public function renderAdminDashboard(): void {
|
||||
@@ -24,6 +27,10 @@ class LessonController {
|
||||
wp_die( esc_html__( 'You do not have permission to view this page.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( $this->maybeRenderDetail( 'us-scheduler', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handleEtransferUpdate( false );
|
||||
|
||||
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() );
|
||||
@@ -36,6 +43,10 @@ class LessonController {
|
||||
wp_die( esc_html__( 'You do not have permission to view lessons.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( $this->maybeRenderDetail( 'us-my-lessons', true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handleEtransferUpdate( true );
|
||||
|
||||
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
|
||||
@@ -43,6 +54,36 @@ class LessonController {
|
||||
$this->renderLessonsPage( $rows, 'us-my-lessons' );
|
||||
}
|
||||
|
||||
/**
|
||||
* When the request targets a single lesson (`?lesson_id=`), render its detail
|
||||
* view and report that the page has been handled. Instructors may only open
|
||||
* their own lessons; the studio dashboard ($onlyOwn = false) may open any.
|
||||
*/
|
||||
private function maybeRenderDetail( string $pageSlug, bool $onlyOwn ): bool {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only lesson selector.
|
||||
$lessonId = absint( Val::int( $_GET['lesson_id'] ?? 0 ) );
|
||||
if ( $lessonId <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lesson = $this->repository->findById( $lessonId );
|
||||
$backUrl = admin_url( 'admin.php?page=' . $pageSlug );
|
||||
|
||||
if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) {
|
||||
$row = null;
|
||||
$answers = [];
|
||||
$accepts = [];
|
||||
} else {
|
||||
$row = $this->row( $lesson );
|
||||
$answers = $this->detail->answers( $lessonId );
|
||||
$accepts = $this->detail->acceptances( $lessonId );
|
||||
}
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the lessons template with its calendar view state: week (default)
|
||||
* or list, plus which week the week view shows.
|
||||
@@ -111,10 +152,15 @@ class LessonController {
|
||||
$instructor = get_userdata( $lesson->instructorId );
|
||||
$payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null;
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
|
||||
|
||||
return [
|
||||
'lesson_id' => (int) $lesson->id,
|
||||
'student' => $student ? $student->display_name : (string) $lesson->studentId,
|
||||
'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId,
|
||||
'offering' => $offering ? $offering->title : '—',
|
||||
'duration' => null !== $offering && null !== $offering->durationMinutes ? $offering->durationMinutes : 0,
|
||||
'recurrence' => $lesson->recurrence,
|
||||
'time' => $slot ? $this->formatSlotTime( $slot ) : '—',
|
||||
'day' => $slot ? substr( $slot->startDt, 0, 10 ) : '',
|
||||
'time_short' => $slot ? Val::string( mysql2date( 'g:i A', $slot->startDt ) ) : '—',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
|
||||
/**
|
||||
* Builds the display rows for the admin lesson detail view: the intake answers
|
||||
* the student submitted and the policy versions they accepted when booking.
|
||||
*
|
||||
* Scoped to a single lesson (the `lesson` registration type), mirroring the
|
||||
* per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
|
||||
*/
|
||||
class LessonDetail {
|
||||
|
||||
public function __construct(
|
||||
private AnswerRepository $answers,
|
||||
private QuestionRepository $questions,
|
||||
private AcceptanceRepository $acceptances,
|
||||
private PolicyRepository $policies,
|
||||
private PolicyVersionRepository $versions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The intake-question answers recorded for this lesson, in submission order.
|
||||
*
|
||||
* @return list<array{question: string, answer: string}>
|
||||
*/
|
||||
public function answers( int $lessonId ): array {
|
||||
return array_map(
|
||||
function ( Answer $answer ): array {
|
||||
$question = $this->questions->findById( $answer->questionId );
|
||||
$value = $answer->answerValue ?? '';
|
||||
|
||||
return [
|
||||
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
|
||||
'answer' => '' === $value ? '—' : $value,
|
||||
];
|
||||
},
|
||||
$this->answers->findByRegistration( Answer::REG_LESSON, $lessonId )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy versions the student accepted when booking this lesson, with the
|
||||
* captured acceptance time and IP for the audit trail.
|
||||
*
|
||||
* @return list<array{policy: string, version: string, accepted_at: string, ip: string}>
|
||||
*/
|
||||
public function acceptances( int $lessonId ): array {
|
||||
return array_map(
|
||||
function ( PolicyAcceptance $acceptance ): array {
|
||||
$version = $this->versions->findById( $acceptance->policyVersionId );
|
||||
$policy = $version ? $this->policies->findById( $version->policyId ) : null;
|
||||
|
||||
return [
|
||||
'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ),
|
||||
'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
|
||||
'accepted_at' => $acceptance->acceptedAt ?? '',
|
||||
'ip' => $acceptance->ipAddress ?? '',
|
||||
];
|
||||
},
|
||||
$this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $lessonId )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ] );
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ class GroupClassController {
|
||||
* invite-only classes — the list of people invited but not yet enrolled.
|
||||
*
|
||||
* @param list<Enrollment> $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<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
|
||||
* @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<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
|
||||
*/
|
||||
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<int> $studentIds
|
||||
*/
|
||||
private function addDirect( Offering $offering, array $studentIds ): string {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -75,9 +75,18 @@ class UpdateChecker {
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_plugins_{hostname}` filter callback. Returns the incoming
|
||||
* value untouched unless a newer release with a zip asset exists, in
|
||||
* which case it returns the update array core expects.
|
||||
* `update_plugins_{hostname}` filter callback.
|
||||
*
|
||||
* For a newer release with a zip asset, returns the update array core
|
||||
* files under the transient's `response` list (the update offer).
|
||||
* Otherwise — the plugin is current, or the release lookup failed — it
|
||||
* returns a payload with the installed version and no package, which core
|
||||
* files under `no_update`. That `no_update` entry is what sets core's
|
||||
* `update-supported` flag and makes the "Enable auto-updates" toggle
|
||||
* appear on the Plugins screen; without it, an off-directory plugin is
|
||||
* absent from the transient between releases and the toggle never shows.
|
||||
*
|
||||
* The incoming value is only passed through untouched for other plugins.
|
||||
*/
|
||||
public function provideUpdate( mixed $update, mixed $plugin_data, mixed $plugin_file ): mixed {
|
||||
if ( plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) {
|
||||
@@ -86,19 +95,24 @@ class UpdateChecker {
|
||||
|
||||
$release = $this->latestRelease();
|
||||
|
||||
if ( '' === $release['version'] || '' === $release['package'] ) {
|
||||
return $update;
|
||||
}
|
||||
|
||||
if ( version_compare( $release['version'], USC_VERSION, '<=' ) ) {
|
||||
return $update;
|
||||
if ( '' !== $release['version'] && '' !== $release['package']
|
||||
&& version_compare( $release['version'], USC_VERSION, '>' ) ) {
|
||||
return [
|
||||
'slug' => 'unsupervised-schedular',
|
||||
'version' => $release['version'],
|
||||
'url' => self::REPO_URL,
|
||||
'package' => $release['package'],
|
||||
];
|
||||
}
|
||||
|
||||
// No newer release: answer with a `no_update` payload so core keeps
|
||||
// the plugin in the update transient and shows the auto-update toggle.
|
||||
// The empty package leaves core nothing to auto-install, as intended.
|
||||
return [
|
||||
'slug' => 'unsupervised-schedular',
|
||||
'version' => $release['version'],
|
||||
'version' => USC_VERSION,
|
||||
'url' => self::REPO_URL,
|
||||
'package' => $release['package'],
|
||||
'package' => '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array{lesson_id: int, student: string, instructor: string, offering: string, duration: int, recurrence: string, time: string, status: string, notes: string, payment_id: int, currency: string, total: float}|null $row
|
||||
* @var list<array{question: string, answer: string}> $answers
|
||||
* @var list<array{policy: string, version: string, accepted_at: string, ip: string}> $accepts
|
||||
* @var string $backUrl
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Lesson details', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<p><a href="<?php echo esc_url($backUrl); ?>">« <?php esc_html_e('Back to lessons', 'unsupervised-schedular'); ?></a></p>
|
||||
|
||||
<?php if (null === $row) : ?>
|
||||
<p><?php esc_html_e('This lesson could not be found.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="form-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Lesson', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<?php echo esc_html($row['offering']); ?>
|
||||
<?php if ($row['duration'] > 0) : ?>
|
||||
<?php
|
||||
/* translators: %d: lesson length in minutes */
|
||||
echo esc_html(sprintf(__('(%d min)', 'unsupervised-schedular'), $row['duration']));
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Date/Time', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<?php echo esc_html($row['time']); ?>
|
||||
<?php if ('weekly' === $row['recurrence']) : ?>
|
||||
<em>(<?php esc_html_e('weekly', 'unsupervised-schedular'); ?>)</em>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['status']); ?></td>
|
||||
</tr>
|
||||
<?php if ($row['payment_id'] > 0) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['currency'] . ' ' . number_format($row['total'], 2)); ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php if ('' !== $row['notes']) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($row['notes']); ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Policies accepted', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($accepts)) : ?>
|
||||
<p><?php esc_html_e('None recorded for this booking.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Policy', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Version', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Accepted', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('IP address', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($accepts as $acceptance) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($acceptance['policy']); ?></td>
|
||||
<td><?php echo esc_html($acceptance['version']); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['accepted_at'] ? (string) mysql2date('M j, Y g:i A', $acceptance['accepted_at']) : '—'); ?></td>
|
||||
<td><?php echo esc_html('' !== $acceptance['ip'] ? $acceptance['ip'] : '—'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($answers)) : ?>
|
||||
<p><?php esc_html_e('None recorded for this booking.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($answers as $answer) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($answer['question']); ?></td>
|
||||
<td><?php echo esc_html($answer['answer']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -6,10 +6,10 @@ if (! defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<array{student: string, instructor: string, time: string, day: string, time_short: string, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows
|
||||
* @var list<array{lesson_id: int, student: string, instructor: string, offering: string, duration: int, recurrence: string, time: string, day: string, time_short: string, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows
|
||||
* @var 'list'|'week' $view
|
||||
* @var string $weekStart
|
||||
* @var list<array{date: string, items: list<array{student: string, time_short: string, status: string}>}> $weekDays
|
||||
* @var list<array{date: string, items: list<array{lesson_id: int, student: string, offering: string, time_short: string, status: string}>}> $weekDays
|
||||
* @var string $prevWeek
|
||||
* @var string $nextWeek
|
||||
* @var string $baseUrl
|
||||
@@ -58,7 +58,9 @@ if (! defined('ABSPATH')) {
|
||||
<p style="margin:0 0 8px;">
|
||||
<strong><?php echo esc_html($item['time_short']); ?></strong><br>
|
||||
<?php echo esc_html($item['student']); ?><br>
|
||||
<em><?php echo esc_html($item['status']); ?></em>
|
||||
<span><?php echo esc_html($item['offering']); ?></span><br>
|
||||
<em><?php echo esc_html($item['status']); ?></em><br>
|
||||
<a href="<?php echo esc_url(add_query_arg('lesson_id', (string) $item['lesson_id'], $baseUrl)); ?>"><?php esc_html_e('Details', 'unsupervised-schedular'); ?></a>
|
||||
</p>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
@@ -74,12 +76,14 @@ if (! defined('ABSPATH')) {
|
||||
<tr>
|
||||
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Lesson', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Date/Time', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Details', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -87,6 +91,17 @@ if (! defined('ABSPATH')) {
|
||||
<tr>
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||
<td>
|
||||
<?php echo esc_html($row['offering']); ?>
|
||||
<?php if ($row['duration'] > 0) : ?>
|
||||
<span style="color:#666;">
|
||||
<?php
|
||||
/* translators: %d: lesson length in minutes */
|
||||
echo esc_html(sprintf(__('(%d min)', 'unsupervised-schedular'), $row['duration']));
|
||||
?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html($row['time']); ?></td>
|
||||
<td><?php echo esc_html($row['status']); ?></td>
|
||||
<td>
|
||||
@@ -118,6 +133,9 @@ if (! defined('ABSPATH')) {
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html($row['notes']); ?></td>
|
||||
<td>
|
||||
<a href="<?php echo esc_url(add_query_arg('lesson_id', (string) $row['lesson_id'], $baseUrl)); ?>"><?php esc_html_e('View', 'unsupervised-schedular'); ?></a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
||||
@@ -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<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>} $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<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>} $class
|
||||
* @var list<array{id: int, name: string}> $students
|
||||
* @var string $notice
|
||||
* @var string $baseUrl
|
||||
@@ -78,6 +78,12 @@ if (! defined('ABSPATH')) {
|
||||
<th scope="row"><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html(number_format($class['price'], 2) . ' ' . $class['currency']); ?></td>
|
||||
</tr>
|
||||
<?php if ('' !== $class['deadline']) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Enrolment deadline', 'unsupervised-schedular'); ?></th>
|
||||
<td><?php echo esc_html($class['deadline']); ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php if (null !== $class['schedule_note'] && '' !== $class['schedule_note']) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></th>
|
||||
@@ -130,35 +136,51 @@ if (! defined('ABSPATH')) {
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($class['invite_only']) : ?>
|
||||
<h2><?php esc_html_e('Invite & enrol students', 'unsupervised-schedular'); ?></h2>
|
||||
<h2>
|
||||
<?php
|
||||
echo $class['invite_only']
|
||||
? esc_html__('Invite & enrol students', 'unsupervised-schedular')
|
||||
: esc_html__('Add students', 'unsupervised-schedular');
|
||||
?>
|
||||
</h2>
|
||||
<?php if (! $class['enrollment_open']) : ?>
|
||||
<p class="description"><?php esc_html_e('Enrolment has closed for this class. Students you add here are enrolled as late enrolments.', 'unsupervised-schedular'); ?></p>
|
||||
<?php elseif ($class['invite_only']) : ?>
|
||||
<p class="description"><?php esc_html_e('This class is invite only, so students join only when you add or invite them here.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (! empty($class['invited'])) : ?>
|
||||
<h3><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h3>
|
||||
<ul class="ul-disc">
|
||||
<?php foreach ($class['invited'] as $invitee) : ?>
|
||||
<li><?php echo esc_html($invitee['who'] . ' — ' . $invitee['kind']); ?></li>
|
||||
<?php if ($class['invite_only'] && ! empty($class['invited'])) : ?>
|
||||
<h3><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h3>
|
||||
<ul class="ul-disc">
|
||||
<?php foreach ($class['invited'] as $invitee) : ?>
|
||||
<li><?php echo esc_html($invitee['who'] . ' — ' . $invitee['kind']); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="us-group-invite-controls" style="display:flex; flex-wrap:wrap; gap:2em; margin:1em 0 2em;">
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_group_action'); ?>
|
||||
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
|
||||
<h4><?php esc_html_e('Add students directly', 'unsupervised-schedular'); ?></h4>
|
||||
<p class="description">
|
||||
<?php
|
||||
echo $class['enrollment_open']
|
||||
? esc_html__('Enrols them now with a pending payment.', 'unsupervised-schedular')
|
||||
: esc_html__('Enrols them now with a pending payment, past the enrolment deadline.', 'unsupervised-schedular');
|
||||
?>
|
||||
</p>
|
||||
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
|
||||
<?php foreach ($students as $student) : ?>
|
||||
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="us-group-invite-controls" style="display:flex; flex-wrap:wrap; gap:2em; margin:1em 0 2em;">
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_group_action'); ?>
|
||||
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
|
||||
<h4><?php esc_html_e('Add students directly', 'unsupervised-schedular'); ?></h4>
|
||||
<p class="description"><?php esc_html_e('Enrols them now with a pending payment.', 'unsupervised-schedular'); ?></p>
|
||||
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
|
||||
<?php foreach ($students as $student) : ?>
|
||||
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p>
|
||||
<button type="submit" name="usc_action" value="add_direct" class="button"><?php esc_html_e('Add to class', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
</select>
|
||||
<p>
|
||||
<button type="submit" name="usc_action" value="add_direct" class="button"><?php esc_html_e('Add to class', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<?php if ($class['invite_only']) : ?>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_group_action'); ?>
|
||||
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
|
||||
@@ -184,6 +206,6 @@ if (! defined('ABSPATH')) {
|
||||
<button type="submit" name="usc_action" value="invite_email" class="button"><?php esc_html_e('Send invite', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,6 +112,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
||||
<span class="description"><?php esc_html_e('Group classes only — the time each session starts. Combined with the duration to block the instructor’s availability.', 'unsupervised-schedular'); ?></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="enrollment_deadline"><?php esc_html_e('Enrolment deadline', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="date" name="enrollment_deadline" id="enrollment_deadline" value="<?php echo esc_attr($editing->enrollmentDeadline ?? ''); ?>">
|
||||
<p class="description"><?php esc_html_e('Group classes only — the last day students may enrol. Leave blank to default to the first day of the class.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
|
||||
@@ -544,4 +544,22 @@ class BookingEndpointTest extends TestCase
|
||||
self::assertSame('2026-07-01 10:00:00', $data[0]['start_dt']);
|
||||
self::assertSame('2026-07-01 11:00:00', $data[0]['end_dt']);
|
||||
}
|
||||
|
||||
public function testMyLessonsIncludesBookedOfferingName(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, status: Lesson::STATUS_PENDING, id: 77);
|
||||
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->once()->andReturn([$lesson]);
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, 8));
|
||||
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
|
||||
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Piano Lesson', durationMinutes: 60, id: 8)
|
||||
);
|
||||
|
||||
$result = $this->endpoint->myLessons(new \WP_REST_Request([]));
|
||||
|
||||
$data = $result->get_data();
|
||||
self::assertSame('Piano Lesson', $data[0]['offering_title']);
|
||||
self::assertSame(60, $data[0]['duration_minutes']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
@@ -18,6 +20,8 @@ class LessonControllerTest extends TestCase
|
||||
private BookingRepository&Mockery\MockInterface $bookings;
|
||||
private PaymentRepository&Mockery\MockInterface $payments;
|
||||
private AvailabilityRepository&Mockery\MockInterface $availability;
|
||||
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||
private LessonDetail&Mockery\MockInterface $detail;
|
||||
private LessonController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -27,7 +31,9 @@ class LessonControllerTest extends TestCase
|
||||
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||
$this->payments = Mockery::mock(PaymentRepository::class);
|
||||
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->detail = Mockery::mock(LessonDetail::class);
|
||||
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail);
|
||||
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
@@ -176,6 +182,96 @@ class LessonControllerTest extends TestCase
|
||||
self::assertStringNotContainsString('9:00 AM', $html);
|
||||
}
|
||||
|
||||
public function testListViewShowsBookedOfferingName(): void
|
||||
{
|
||||
$_GET['usc_view'] = 'list';
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
|
||||
$slot = new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-07-06 09:00:00',
|
||||
endDt: '2026-07-06 10:00:00',
|
||||
id: 10
|
||||
);
|
||||
$offering = new \Unsupervised\Schedular\Offering\Offering(
|
||||
instructorId: 3,
|
||||
kind: 'private_lesson',
|
||||
title: 'Piano Lesson',
|
||||
durationMinutes: 60,
|
||||
id: 8
|
||||
);
|
||||
|
||||
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([$lesson]);
|
||||
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
|
||||
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Piano Lesson', $html);
|
||||
self::assertStringContainsString('lesson_id=1', $html);
|
||||
}
|
||||
|
||||
public function testLessonIdRoutesToDetailWithAnswersAndPolicies(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
|
||||
$slot = new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-07-06 09:00:00',
|
||||
endDt: '2026-07-06 10:00:00',
|
||||
id: 10
|
||||
);
|
||||
$offering = new \Unsupervised\Schedular\Offering\Offering(
|
||||
instructorId: 3,
|
||||
kind: 'private_lesson',
|
||||
title: 'Piano Lesson',
|
||||
durationMinutes: 60,
|
||||
id: 8
|
||||
);
|
||||
|
||||
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
|
||||
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
|
||||
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
|
||||
$this->detail->shouldReceive('answers')->once()->with(1)->andReturn([
|
||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||
]);
|
||||
$this->detail->shouldReceive('acceptances')->once()->with(1)->andReturn([
|
||||
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'],
|
||||
]);
|
||||
|
||||
// The list of lessons must never be queried when routing to a detail view.
|
||||
$this->bookings->shouldNotReceive('findAllUpcoming');
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Lesson details', $html);
|
||||
self::assertStringContainsString('Piano Lesson', $html);
|
||||
self::assertStringContainsString('Skill level', $html);
|
||||
self::assertStringContainsString('Beginner', $html);
|
||||
self::assertStringContainsString('Cancellation', $html);
|
||||
}
|
||||
|
||||
public function testInstructorCannotOpenAnotherInstructorsLessonDetail(): void
|
||||
{
|
||||
$_GET['lesson_id'] = '1';
|
||||
Functions\when('get_current_user_id')->justReturn(99);
|
||||
|
||||
// The lesson belongs to instructor 3, not the current user (99).
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
|
||||
|
||||
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
|
||||
$this->detail->shouldNotReceive('answers');
|
||||
$this->detail->shouldNotReceive('acceptances');
|
||||
|
||||
ob_start();
|
||||
$this->controller->renderInstructorLessons();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
self::assertStringContainsString('could not be found', $html);
|
||||
self::assertStringNotContainsString('Skill level', $html);
|
||||
}
|
||||
|
||||
private function render(): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class LessonDetailTest extends TestCase
|
||||
{
|
||||
private AnswerRepository&Mockery\MockInterface $answers;
|
||||
private QuestionRepository&Mockery\MockInterface $questions;
|
||||
private AcceptanceRepository&Mockery\MockInterface $acceptances;
|
||||
private PolicyRepository&Mockery\MockInterface $policies;
|
||||
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||
private LessonDetail $detail;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->answers = Mockery::mock(AnswerRepository::class);
|
||||
$this->questions = Mockery::mock(QuestionRepository::class);
|
||||
$this->acceptances = Mockery::mock(AcceptanceRepository::class);
|
||||
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||
|
||||
$this->detail = new LessonDetail(
|
||||
$this->answers,
|
||||
$this->questions,
|
||||
$this->acceptances,
|
||||
$this->policies,
|
||||
$this->versions
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnswersPairEachAnswerWithItsQuestionLabel(): void
|
||||
{
|
||||
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_LESSON, 7)->andReturn([
|
||||
new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: 'Beginner'),
|
||||
new Answer(questionId: 9, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: null),
|
||||
]);
|
||||
|
||||
$this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Skill level', id: 2));
|
||||
$this->questions->shouldReceive('findById')->with(9)->andReturn(null);
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||
['question' => '#9', 'answer' => '—'],
|
||||
],
|
||||
$this->detail->answers(7)
|
||||
);
|
||||
}
|
||||
|
||||
public function testAcceptancesResolvePolicyTitleVersionAndAuditTrail(): void
|
||||
{
|
||||
$this->acceptances->shouldReceive('findByRegistration')->once()->with(PolicyAcceptance::REG_LESSON, 7)->andReturn([
|
||||
new PolicyAcceptance(
|
||||
policyVersionId: 4,
|
||||
studentId: 5,
|
||||
registrationType: PolicyAcceptance::REG_LESSON,
|
||||
registrationId: 7,
|
||||
ipAddress: '1.2.3.4',
|
||||
acceptedAt: '2026-07-01 10:00:00'
|
||||
),
|
||||
]);
|
||||
|
||||
$this->versions->shouldReceive('findById')->with(4)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 4));
|
||||
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
[
|
||||
'policy' => 'Cancellation',
|
||||
'version' => 'v2',
|
||||
'accepted_at' => '2026-07-01 10:00:00',
|
||||
'ip' => '1.2.3.4',
|
||||
],
|
||||
],
|
||||
$this->detail->acceptances(7)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,22 @@ class UpdateCheckerTest extends TestCase
|
||||
return ['name' => $name, 'browser_download_url' => self::PACKAGE_URL];
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload provideUpdate() returns when no newer release is offered.
|
||||
* Core files this under the transient's `no_update` list, which is what
|
||||
* makes the "Enable auto-updates" toggle appear. USC_VERSION is 1.0.0 in
|
||||
* the test bootstrap.
|
||||
*/
|
||||
private function noUpdatePayload(): array
|
||||
{
|
||||
return [
|
||||
'slug' => 'unsupervised-schedular',
|
||||
'version' => '1.0.0',
|
||||
'url' => UpdateChecker::REPO_URL,
|
||||
'package' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public function testRegisterHooksHostnameFilter(): void
|
||||
{
|
||||
Filters\expectAdded('update_plugins_git.unsupervised.ca')->once();
|
||||
@@ -109,7 +125,7 @@ class UpdateCheckerTest extends TestCase
|
||||
self::assertFalse($result);
|
||||
}
|
||||
|
||||
public function testNoUpdateWhenReleaseIsNotNewer(): void
|
||||
public function testNoUpdatePayloadWhenReleaseIsNotNewer(): void
|
||||
{
|
||||
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
||||
Functions\when('get_transient')->justReturn(false);
|
||||
@@ -118,7 +134,9 @@ class UpdateCheckerTest extends TestCase
|
||||
|
||||
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
||||
|
||||
self::assertFalse($result);
|
||||
// Current version → core files this under `no_update` so the
|
||||
// auto-update toggle stays visible; no package to install.
|
||||
self::assertSame($this->noUpdatePayload(), $result);
|
||||
}
|
||||
|
||||
public function testUsesCachedReleaseWithoutHittingApi(): void
|
||||
@@ -134,7 +152,7 @@ class UpdateCheckerTest extends TestCase
|
||||
self::assertSame('2.0.0', $result['version']);
|
||||
}
|
||||
|
||||
public function testApiFailureIsCachedAndReturnsUpdateUnchanged(): void
|
||||
public function testApiFailureIsCachedAndStillReportsUpdateSupport(): void
|
||||
{
|
||||
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
||||
Functions\when('get_transient')->justReturn(false);
|
||||
@@ -150,10 +168,12 @@ class UpdateCheckerTest extends TestCase
|
||||
|
||||
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
||||
|
||||
self::assertFalse($result);
|
||||
// Even with the lookup failed we still return the `no_update` payload,
|
||||
// so the auto-update toggle does not flicker away during a Gitea blip.
|
||||
self::assertSame($this->noUpdatePayload(), $result);
|
||||
}
|
||||
|
||||
public function testNon200ResponseReturnsUpdateUnchanged(): void
|
||||
public function testNon200ResponseReturnsNoUpdatePayload(): void
|
||||
{
|
||||
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
||||
Functions\when('get_transient')->justReturn(false);
|
||||
@@ -162,7 +182,7 @@ class UpdateCheckerTest extends TestCase
|
||||
|
||||
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
||||
|
||||
self::assertFalse($result);
|
||||
self::assertSame($this->noUpdatePayload(), $result);
|
||||
}
|
||||
|
||||
public function testPicksFirstZipAssetAndSkipsOthers(): void
|
||||
@@ -181,7 +201,7 @@ class UpdateCheckerTest extends TestCase
|
||||
self::assertSame(self::PACKAGE_URL, $result['package']);
|
||||
}
|
||||
|
||||
public function testReleaseWithoutZipAssetOffersNoUpdate(): void
|
||||
public function testReleaseWithoutZipAssetOffersNoUpdatePayload(): void
|
||||
{
|
||||
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
||||
Functions\when('get_transient')->justReturn(false);
|
||||
@@ -192,10 +212,12 @@ class UpdateCheckerTest extends TestCase
|
||||
|
||||
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
||||
|
||||
self::assertFalse($result);
|
||||
// No installable package means no update to offer, but we still keep
|
||||
// the plugin in `no_update` so the toggle shows.
|
||||
self::assertSame($this->noUpdatePayload(), $result);
|
||||
}
|
||||
|
||||
public function testMalformedApiBodyOffersNoUpdate(): void
|
||||
public function testMalformedApiBodyOffersNoUpdatePayload(): void
|
||||
{
|
||||
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
||||
Functions\when('get_transient')->justReturn(false);
|
||||
@@ -207,6 +229,6 @@ class UpdateCheckerTest extends TestCase
|
||||
|
||||
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
|
||||
|
||||
self::assertFalse($result);
|
||||
self::assertSame($this->noUpdatePayload(), $result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.0
|
||||
* 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.0');
|
||||
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__));
|
||||
|
||||
Reference in New Issue
Block a user