Three bug fixes for the 1.2.1 section:
- Fixed-size fields (question labels, offering titles/notes/e-transfer
email, policy titles/slugs) no longer silently fail to save when the
value exceeds its column length. The REST endpoints reject over-long
values with a 400, the admin controllers refuse to insert them, and the
form inputs carry a maxlength so the browser blocks over-long entry.
Limits are MAX_* constants on the value objects, kept in lockstep with
the schema columns.
- Students are kept out of wp-admin entirely. New StudentAdminGuard
redirects front-end-only users (no back-office capability) away from the
dashboard and hides the admin bar for them, while administrators, studio
admins, and instructors keep full access.
- The Add/Edit Offering instructor picker now includes WordPress
administrators when they act as instructors (the default single-account
setup), so a solo studio owner is selectable instead of the dropdown
being empty.
composer test (618), composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Three registration fixes reported from live use:
- Accepting an invite now keeps the student signed in. The form was
processed inside render() during the_content, so wp_set_auth_cookie()
ran after headers were sent and the cookie never persisted — the new
student was bounced back to the logged-out registration page. The
submission is now handled on template_redirect (before output) with a
post/redirect/get, so the cookie sticks and the student lands logged in.
- The "registration is by invitation only" message is now customisable via
a new block attribute (inviteOnlyMessage / shortcode invite_only_message),
falling back to the default wording when blank.
- Account-registration questions save again. dbDelta does not reliably
relax a column from NOT NULL to NULL, so sites created before account-
scope questions kept us_questions.offering_id NOT NULL and rejected
account inserts ("Column 'offering_id' cannot be null"). A one-time,
self-healing migration (guarded by its own option, not the version gate)
re-applies the nullable definition on next load.
composer test, composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Group classes now carry an optional per-class withdrawal deadline. Up to
that day a student may withdraw themselves from the class; the withdrawal
frees the seat and voids any pending payment but never issues an account
credit. After the deadline self-withdrawal closes and a studio admin must
withdraw the student by hand (the admin path is never subject to the
deadline). A blank deadline keeps self-withdrawal open indefinitely.
Also make the Add/Edit Offering form show only the fields relevant to the
selected kind: group settings for group classes, weekly reservation for
private lessons. Progressive enhancement — without JS every field renders.
- New nullable us_offerings.withdrawal_deadline column; Offering model gains
$withdrawalDeadline + isWithdrawalOpen().
- New student endpoint POST /enrollments/{id}/withdraw, gated by the deadline
(403 withdrawal_closed), ownership-checked, idempotent.
- Front-end group-class page shows a Withdraw button while open.
- No USC_VERSION bump: 1.2.0 is unreleased and accumulates schema changes
under its section, matching the scheduled-billing and credit features.
Tests: composer test (596), composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Cancelling a lesson that was already paid for now credits the student
that money instead of leaving it as a manual refund, and the daily
scheduled-billing scan applies any available credit against their due
charges before emailing the notice.
- New us_credits ledger + us_payments.credit_applied column (Payment::netDue).
- PaymentService::creditForCancelledLesson issues a per-lesson share of the
covering payment's total; wired into all three cancel paths (student
self-cancel, instructor status update, admin student-detail cancel).
- PaymentService::applyCredits draws credit down FIFO across a run's charges,
marking a fully-covered charge paid-by-credit; the notice shows the credit
applied and reduced total, and the admin queue shows net due.
- Student detail page shows a student's credit balance and history.
Ships as part of the unreleased 1.2.0 (same release as scheduled billing).
Tests: composer test (585), composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The plugin header carries 1.2.0 but CHANGELOG.md still topped out at the
untagged 1.1.3 section, and two shipped features (#104 lesson booking
detail, #105 weekly/monthly scheduled billing) were unrecorded. Neither
1.1.2 nor 1.1.3 was ever tagged, so their changes belong to the 1.2.0
release. Merge the untagged sections into a single 1.2.0 section and add
the two missing features so the release workflow publishes real notes.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Offerings can now bill weekly (a pending payment 24h before each lesson)
or monthly (one payment on the 1st for that month's lessons), alongside
one-time and full-term. Applies to both private lessons and group classes.
- Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()`
- Booking/enrolment defer payment for scheduled modes; a single lesson
booked after its due date has passed (e.g. an add-on in an already-billed
month) is charged at booking instead
- ScheduledBillingRunner: daily WP-Cron scan generates due payments across
four cases (private/group × weekly/monthly), deduped via lesson.payment_id
and payments.period_key
- PaymentDueMailer: one consolidated itemised email per student per scan
- Notice batch: payments emailed together share a reference; the admin
Payments queue groups them with a lump-sum total for e-transfer reconciliation
- Cancellation never voids a scheduled payment (Payment::isScheduled())
- Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0
composer test, composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Front end: the student "upcoming lessons" panel now shows each booked
offering's name and length next to the time, and renders only the soonest
five lessons with a "Show all" reveal. GET /bookings returns offering_title
and duration_minutes so the list needs no extra request.
Admin: the Scheduler and My Lessons week/list views now show the booked
offering, and each lesson links to a detail view showing the policy versions
the student accepted (with acceptance time and IP) and their intake answers.
On My Lessons an instructor may only open their own lessons; the studio
Scheduler may open any.
composer test / composer lint / composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The front end used the deadline only to gate the Enrol button; students had
no way to see when enrolment closes. Add an "Enrol by <date>" line to each
class card, shown while enrolment is still open, for the effective deadline
(the instructor's date, or the first class day by default).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Group classes gain an instructor-set enrolment deadline (new
us_offerings.enrollment_deadline column) that defaults to the first day of
the class (term_start). Past the deadline students can no longer self-enrol:
the enrolment endpoint rejects it (403 enrollment_closed) and the front-end
class list shows "Enrolment has closed." in place of the Enrol button.
Instructors keep a manual path: the "Add students directly" control on each
class's details page now renders for public classes too (not just
invite-only) and deliberately bypasses the deadline and capacity, so a
student can be added as a late enrolment after the class has closed. Past
the deadline the details page labels these as late enrolments.
Bumps USC_VERSION to 1.1.3 for the schema change.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
WordPress only renders the "Enable auto-updates" toggle for a plugin that
appears in the update_plugins transient's response or no_update list, which
is what sets core's update-supported flag. UpdateChecker only populated the
response side (when a newer release existed), so between releases the plugin
was absent from the transient and the toggle never showed.
provideUpdate() now returns a no_update payload (installed version, empty
package) whenever no newer release is offered — including when the release
lookup fails — so the plugin stays in the transient and the toggle appears.
The response path (one-click and unattended updates) is unchanged.
Bumps to 1.1.1 so the fix ships to installed sites via the self-updater.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add Auth\UserName::format(), which prefers a user's first + last name, then
their nickname, avoiding display_name (which can be the login/username).
Route the instructor name through it in both the front-end offerings response
(instructor_name) and the back-end group-class summary and details views.
Tests: composer test (513), composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Group classes now carry a specific class time (alongside date and duration)
and an assigned instructor:
- Schema: add `class_time` (TIME) to `us_offerings`; `Offering` gains
`normalizeTime`/`sessionWindows`. (Rides the pending 1.0.0->1.1.0 dbDelta
upgrade, so no version bump.)
- Offering form: class-time field, plus a studio-admin instructor picker
(plain instructors always own their own classes).
- `ClassSlotReconciler`: assigning an instructor clears their open booking
slots overlapping each session and flags already-booked lessons that clash
(a booked lesson is never deleted). Uses new
`AvailabilityRepository::findOverlapping`.
- Front end: `GET /offerings` exposes `instructor_name`; the enrolment page
shows who teaches each class and when it meets.
Back-office group-class views redesigned:
- Instructor **My Group Classes** and studio-admin **Group Classes** are now
per-class summaries with enrolment counts, not flat student lists.
- Each links through (`?class_id=<id>`) to a per-class **details page**
(schedule panel, roster with payment status, and — for invite-only classes
— the add/make-available/invite-by-email controls). Invite-only membership
is managed entirely from this page.
- Invite actions are allowed for the class's owning instructor or any
`view_all_lessons` studio admin, so an owner-operator (studio admin who also
teaches) can reach every class's roster and invites from the Group Classes
page.
Tests: composer test (508), composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add CHANGELOG.md (one section per version, newest first; the top section
always reflects the current plugin header version — release status is
purely a matter of tagging).
The Release workflow now extracts the tagged version's changelog section
and publishes it as the Gitea release body (POST on create, PATCH when the
release was pre-created via the UI). After a stable release, a new
bump-version job bumps the plugin to the next patch version, opens a fresh
changelog section, and opens a PR. Pre-release tags are skipped.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Group classes can now be marked invite-only (us_offerings.access_mode).
Invite-only classes are hidden from the public catalog and reachable only
when the instructor lets someone in via one of three paths, managed from
My Lessons -> My Group Classes:
- Add students directly: enrols them now with a pending payment.
- Make available: grants registered students access to self-enrol through
the normal paid flow (multi-select, emailed a notice).
- Invite by email: tokenised registration invite tied to the class for a
non-account address; after they register the class becomes enrollable.
Reuses an existing pending invite instead of sending a second link.
New us_group_access table records grants; GET /offerings merges granted
invite-only classes for the caller; enrolment requires a grant
(403 invite_required) and flips it to enrolled on success.
composer test (487), composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Instructors can now see their own group classes under My Lessons →
My Group Classes (view_own_lessons): each class shows its active
enrolment count against capacity plus a roster of enrolled students
with enrolment and payment status.
GroupClassController gains renderInstructorPage(), backed by the
existing per-instructor enrolment query and a newly injected
PaymentRepository for payment status. Wired as a submenu under the
existing My Lessons menu, inside the same !view_all_lessons guard so
owner-operators don't get a duplicate item.
Closes#71
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The Studio Settings cutoff field now takes an integer number of days (step 1,
coerced with Val::int) instead of allowing half-day fractions, and displays the
stored hours rounded to whole days.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Students can no longer cancel their own lesson online once it starts within a
configured window; instructors and studio admins can always cancel.
- Studio default `us_cancellation_cutoff_hours` (stored/computed in hours,
entered and displayed in days under Studio Settings → Cancellations).
- Optional per-offering override `cancellation_cutoff_hours` (entered in hours);
blank inherits the studio default, 0 allows anytime cancellation.
- `Booking\CancellationPolicy` resolves the effective window and decides;
`BookingEndpoint::cancel()` returns a 403 `cancellation_closed` when too late.
The instructor status endpoint and studio-admin student actions bypass it.
Closes#93
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Make the "By Unsupervised" author link go to https://unsupervised.ca
(via a new Author URI header) and the plugin site / "View details" link
point at the Gitea project instead of WordPress.org.
Core's "View details" link opened a thickbox iframe against the
WordPress.org plugin-information API, which 404s ("Plugin not found")
for this off-directory plugin. A plugin_row_meta filter now replaces it
with a new-tab link to the matching Gitea release tag page; embedding
Gitea in the iframe is blocked by its X-Frame-Options anyway.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Studio admins can now define registration questions that every new student
answers as a required second step during signup, with each student's answers
shown under a "Registration Information" section in the admin.
Extends the existing Registration domain: us_questions gains a scope column
(offering | account) and a nullable offering_id, and account answers reuse
us_question_answers with registration_type = 'account'. Authoring reuses the
Offerings -> Questions page via an "Account signup" scope (studio-admin only).
The registration form becomes two steps (progressive enhancement via
assets/js/register.js; works without JS); required answers are validated before
the account is created and apply to all signup paths (invite, group link,
self-approval). StudentHistory::registrationInfo() powers the admin section.
Bumps the plugin version to 1.1.0 so dbDelta runs the schema migration.
Closes#90
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Version header, USC_VERSION, and the README version line move from
1.0.0-rc.3 (README was stale at rc.1) to 1.0.0. Tag v1.0.0 on the merge
commit to publish the release.
Co-Authored-By: Claude Fable 5 <[email protected]>
PR #83 added kind and expires_at to us_invites and the repository started
writing them, but USC_VERSION stayed at 1.0.0-rc.2 — Plugin::boot() only
re-runs Installer/dbDelta on a version mismatch, so upgraded sites never got
the columns. Every invite insert then failed silently: nothing appeared under
Pending Invites while the admin was still shown a registration link whose
token hash was never stored.
- Version / USC_VERSION -> 1.0.0-rc.3 (triggers dbDelta on next load).
- InviteRepository::insert() returns 0 on failure instead of a stale
insert_id, and the Invites page now shows an error notice instead of a
dead link when creation fails (personal and group forms), including
clearer validation messages.
- CLAUDE.md: schema changes must bump the version.
Closes#87
Co-Authored-By: Claude Fable 5 <[email protected]>
Scheduler (view_all_lessons) is a superset of My Lessons — same template,
every instructor's lessons, same payment edit forms — so for an
owner-operator both menu items showed the same data twice. The My Lessons
menu item is now only registered for users without view_all_lessons;
instructors are unaffected.
Closes#85
Co-Authored-By: Claude Fable 5 <[email protected]>
Follow-up demo feedback: the My Availability page now opens in its weekly
calendar (usc_view=list opts back into the table, which keeps the bulk-delete
form), matching the new lessons defaults.
Co-Authored-By: Claude Fable 5 <[email protected]>
A studio admin can generate a shareable group invite link (e.g. for a
newsletter) from the Invites page, choosing a required expiry date. Anyone
with the link may register while it is valid, in any registration mode: the
form collects their own email, they must confirm it via the usual hashed
token, and confirming approves the account immediately — group signups never
enter the Pending Students queue.
- us_invites grows kind (personal/group) and expires_at; an explicit expiry
wins over the personal 14-day window. Group links stay pending (multi-use)
until revoked or expired.
- RegistrationPage: group signups create the account pending with the
us_auto_approve marker and send the confirmation email; no auto-login.
- EmailConfirmationHandler: auto-approve accounts are approved on
confirmation, emailed the approved notice, and redirected to a new
us_confirmed=ready notice with a sign-in link.
Closes#77
Co-Authored-By: Claude Fable 5 <[email protected]>
The register form keyed the read-only, prefilled email off any invite row
matching the token. A stale token (expired / accepted / revoked) with open
registration on therefore showed the stale invite's address read-only while
the submit handler took the open branch and required a posted email the
locked field never submits, dead-ending the form. The lock now applies
exactly when the invite is acceptable; otherwise the editable field renders.
Closes#78
Co-Authored-By: Claude Fable 5 <[email protected]>
A weekly booking on a per-lesson (one_time) priced offering was creating its
single upfront payment for one week's price while reserving up to 12 weeks,
and settling that payment confirmed only the anchor lesson, leaving the rest
of the series pending forever.
- BookingEndpoint now charges price x claimed occurrences for one_time
billing; a full_term price is still charged once since it covers the term.
- PaymentService::confirmRegistration resolves the anchor lesson's series and
confirms every non-cancelled row via the new
BookingRepository::updateStatusForSeries().
Closes#79
Co-Authored-By: Claude Fable 5 <[email protected]>
The front-end booking calendar now opens in the Week view (anchored to the
week of the earliest open slot) with List still available. The Scheduler and
My Lessons admin pages gain a week calendar (usc_view/usc_week, bucketed via
a new generic WeekCalendar::bucket()) and open in it by default; the original
table remains as the List view since it carries the HST / e-transfer forms.
Closes#76
Co-Authored-By: Claude Fable 5 <[email protected]>
Adds the #70 follow-up onto the student detail page: studio admins can now
cancel an upcoming lesson (same path as student cancellation — slot freed,
pending payment voided), withdraw an active group-class enrolment (seat
freed, pending payment voided), and edit the student's display name and
email with validation and uniqueness checks.
Action logic lives in the new Auth\StudentActions (unit-tested with mocked
repositories); the controller routes nonce-protected POSTs to it and shows
success/error notices.
Closes#70
Co-Authored-By: Claude Fable 5 <[email protected]>
The student-administration spec deferred three detail-view sections until
Payments landed. Adds them now: policy-acceptance history (title, version,
context, date), intake answers (label, answer, context), and — gated on
manage_billing — payment history with HST breakdown and receipt numbers.
New Auth\StudentHistory builds the display rows from per-student queries
added to AcceptanceRepository, AnswerRepository, and PaymentRepository;
the Payment model now carries created_at so unpaid rows still have a date.
Closes#69
Co-Authored-By: Claude Fable 5 <[email protected]>
The README still listed Payments as partial with the Stripe card charge
pending, and group-classes.md still described the pre-#7 payment seam.
Both are behind the code: StripeGateway/PaymentEndpoint ship the live
card charge, and enrolments create and link payments via PaymentService.
Fixes#73
Co-Authored-By: Claude Fable 5 <[email protected]>
Closes#67
When a student lands on the registration page from the confirmation
email (?us_confirmed=1), replace the registration form with the
confirmation message and a "Sign in to your account" link — the form
is useless at that point and re-submitting would only produce an
"account already exists" error. A confirmed-but-unapproved student can
already log in (the pending gate only withholds booking), so signing in
is the natural next step.
The link target follows the booking block's pattern: a loginPageId
block attribute (page picker in the editor sidebar) or login_page_id
shortcode attribute, falling back to wp_login_url(). The expired-link
notice keeps the form as before.
Co-Authored-By: Claude Fable 5 <[email protected]>
Closes#65
Declare an Update URI header and answer core's update_plugins_{hostname}
filter from a new Update\UpdateChecker that offers the latest published
Gitea release's zip asset when it is newer than the installed version,
with transient caching and silent degradation on API failures.
Add a release workflow that fires on v* tag pushes: verifies the tag
matches the plugin Version header, runs the tests, builds the plugin zip,
and attaches it to the release (reusing a UI-created release, flagging
hyphenated versions as pre-release so /releases/latest skips them).
Co-Authored-By: Claude Fable 5 <[email protected]>
Students could previously join by invite only. Add an optional
self-approval mode, toggled from Studio Settings → Registration: anyone
may sign up on the existing [us_student_register] page, confirm their
email via a tokenised link, and then be approved by a studio admin
before the account is usable.
- Enabling the toggle mirrors WordPress's own membership settings
(users_can_register + default_role = us_student) and snapshots their
previous values so disabling restores them.
- WordPress's native registration form is blocked while open
registration is on (login_init redirect + registration_errors
fail-safe + register_url) so it cannot bypass signup policy acceptance.
- Pending accounts: unconfirmed email cannot log in; confirmed but
unapproved can log in but the booking capability is withheld and the
booking page shows an "awaiting approval" screen.
- Approve/reject from Students → Pending Students; reject hard-deletes
the account so the email is freed to re-apply.
- Invite registration is unchanged; both modes coexist.
Account lifecycle lives in user meta (RegistrationStatus); no new tables.
Closes#63
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The class list now loads the student's own enrolments alongside the
catalog; a class they already have an active enrolment in shows "You
are enrolled in this class." instead of the Enrol button, in both the
browse-all catalog and the single-class embed mode. Previously the
button always rendered and a duplicate attempt walked the student
through the whole questions/policies flow before failing with 409
already_enrolled. A cancelled enrolment does not block re-enrolling,
matching the server-side duplicate rule.
Closes#61
Co-Authored-By: Claude Fable 5 <[email protected]>
Group class offerings now carry real dates: the add/edit form takes a
start date plus a sessions control (one-off, or weekly for N sessions;
the end date is computed as start + (N-1) weeks via
Offering::weeklyTermEnd). Dates are validated strictly (Y-m-d) and shown
in the offerings list and on the student-facing class card, including
the weekly session count.
[us_group_classes offering="<id>"] (block attribute offeringId, chosen
from a dropdown of active classes fetched from the public offerings
endpoint) restricts the page to a single class so the enrolment flow can
be embedded on a page dedicated to that class; a pinned class that is no
longer offered reports itself closed instead of falling back to the
catalog.
Offerings are now editable from the admin screen: an Edit button
prefills the shared add/edit form and saving posts usc_action=update.
Updates always preserve the original owner and currency, and non-admin
instructors can only load and update their own offerings. The form also
gains the previously missing description field and an Active toggle (the
admin-UI counterpart of the REST is_active flag) so an edit cannot wipe
data the form never collected.
Closes#59
Co-Authored-By: Claude Fable 5 <[email protected]>
The list view of Current Slots gets a checkbox per unbooked slot, a
select-all header checkbox, and a Delete selected button submitting a
new bulk_delete form action. Each id is ownership-checked through the
same path as single delete; the repository's is_booked guard refuses
booked slots as a second layer. Row checkboxes attach to the bulk form
via the HTML form attribute because the table already contains the
per-row delete forms and forms cannot nest.
Closes#57
Co-Authored-By: Claude Fable 5 <[email protected]>
Generic slots (no tied offering) were bookable with no offering at all:
free, instantly confirmed, and with no intake questions. POST /bookings
now rejects offering-less bookings (400 offering_required), and a
student-chosen offering must be an active private-lesson type owned by
the slot's instructor whose duration matches the slot.
The registration form gains a Lesson type field: locked to the slot's
tied offering (title, duration, price) so the student sees what they
are booking, or a required picker of fitting offerings for generic
slots, with intake questions following the selection.
Fixes#55
Co-Authored-By: Claude Fable 5 <[email protected]>
Adds POST /bookings/{id}/cancel (owner-only, idempotent): marks the lesson
cancelled, releases the availability slot for rebooking, and voids a
still-pending payment so it leaves the admin confirmation queue. Paid
payments are untouched — refunds stay a manual admin decision.
The instructor PATCH /bookings/{id}/status path now does the same slot
release and payment voiding on cancellation (previously cancelled lessons
left their slot permanently booked), and reinstating a cancelled lesson
re-claims the slot, rejecting with 409 if the freed time was rebooked.
The "Your upcoming lessons" panel gets a Cancel button with a confirm
prompt; on success both the lesson list and the slot calendar refresh.
Co-Authored-By: Claude Fable 5 <[email protected]>
Booking a slot with no priced offering created the lesson but no payment,
yet the front end still called POST /payments/intent, which 400ed with
"Could not start payment for this registration" — the student saw an error
while the backend held a claimed slot and a lesson stuck at pending.
- POST /bookings and POST /enrollments now return a `payment` summary
({id, method, status}) or null when nothing is owed; the JS only runs
the payment step when a payment exists.
- Bookings with nothing owed are confirmed at creation — there is no
payment step that would ever confirm them later.
- The booking page now shows the student's upcoming lessons (GET /bookings,
now scoped to upcoming non-cancelled lessons with slot start/end times)
with a pending-payment/confirmed status badge.
Fixes#53
Co-Authored-By: Claude Fable 5 <[email protected]>
The booking block gains a loginPageId attribute choosing which page its
logged-out "log in to book a lesson" link points to (default remains the
WordPress login screen), and the student-login block gains a
bookingPageId attribute controlling the logged-in "View available
lessons" link and the post-login redirect target (default remains the
current page). Both blocks also gain an autoRedirect toggle, off by
default, that sends the visitor straight to the target page; block
rendering starts after output, so the redirect runs on
template_redirect by parsing the queried page's content for the block,
with a self-target guard against redirect loops. The link targets are
also available to the shortcodes as login_page_id/booking_page_id.
Also fixes a pre-existing fatal: WordPress passes an empty string (not
an array) to shortcode callbacks when a shortcode is used without
attributes, so bare [us_booking] etc. threw a TypeError against the
strictly-typed render(array $atts) methods. ShortcodeRegistrar now
wraps each callback to normalize non-array attribute values.
Closes#51
Co-Authored-By: Claude Fable 5 <[email protected]>
The unanchored dd\( pattern matched the substring in DateTimeImmutable::add(),
failing the check on non-debug code.
Co-Authored-By: Claude Fable 5 <[email protected]>
Availability windows were stored and served as a single bookable row, so a
9:00 AM-4:00 PM window showed to students as one giant slot and booking it
consumed the whole day; past and multi-day windows also leaked into the
booking page as nonsense entries.
- Split windows into consecutive lesson-length slots on save (REST and admin
form); each chunk is independently bookable and weekly recurrence creates a
series per chunk so "reserve this time weekly" holds the same hour each week
- Reject windows spanning multiple days or shorter than the lesson length
(400 invalid_window)
- Never return slots whose start has passed from GET /availability
- Migrate pre-split rows: Plugin::boot re-runs the Installer on version change
and AvailabilityRepository::splitOversizedWindows() rewrites unbooked
same-day oversized windows in place
- Display all times in 12-hour AM/PM form (booking page, wp-admin lists,
editor previews)
- Add a List | Week view toggle to the student booking page and the
instructor availability page, with previous/next-week navigation honouring
the site's start_of_week option (new WeekCalendar helper)
Co-Authored-By: Claude Fable 5 <[email protected]>
The admin dashboard and instructor My Lessons pages showed the raw
availability-slot database ID, which is meaningless to admins and
instructors. LessonController now takes AvailabilityRepository, looks up
each lesson's slot, and renders its window as e.g.
"Jul 6, 2026 9:00 AM-10:00 AM" via mysql2date. The date is repeated on
the end time only when a slot crosses midnight, and lessons whose slot
row no longer exists show an em dash.
Closes#47
Co-Authored-By: Claude Fable 5 <[email protected]>
- Bump phpstan/phpstan ^2.0 and szepeviktor/phpstan-wordpress ^2.0
- Move the analysis level into phpstan.neon (single source) and raise it to 10
- Add Val, a runtime coercion helper that narrows untyped WordPress boundary
values (wpdb rows, REST params, superglobals, options) with explicit checks
instead of blind casts, plus unit tests
- Type value-object fromRow() params as stdClass (what wpdb returns) and map
columns through Val so unexpected shapes degrade safely
- Use %i identifier placeholders for table names in all wpdb::prepare() calls
so every query string is a literal and identifiers are escaped by WordPress;
raises the minimum WordPress version to 6.2 where %i was introduced
- Guard wpdb::prepare() null result before wpdb::query() in updateTax()
- Fix nullable get_permalink()/strtotime() handling, list types at REST and
capability call sites, dead null-coalescing on checked superglobals, and
narrow get_users() results before mapping
- Register Val method names with the ValidatedSanitizedInput sniff so it
validates the real sanitizer around each superglobal read
- Update repository unit tests for the %i placeholder arguments
Co-Authored-By: Claude Fable 5 <[email protected]>
Wrap the four shortcodes (us_booking, us_student_login,
us_student_register, us_group_classes) in dynamic blocks so pages can be
previewed and styled in the block editor. Front-end rendering delegates
to the same page objects the shortcodes use; in the editor's
block-renderer REST preview a static, script-free BlockPreview is
rendered instead (no live REST calls, redirects, or Stripe.js). The
editor script (vanilla JS, no build step) registers each block with
wp.serverSideRender previews and shortcode transforms; frontend.css is
attached as the block style so previews pick up theme styling.
Resolves#44
Co-Authored-By: Claude Fable 5 <[email protected]>
Four fixes from a security review pass:
- Neutralise CSV formula injection in the payments export: fields with a
leading =, +, -, @, tab, or CR (e.g. a hostile student display name) are
apostrophe-prefixed in PaymentReport::csvLine() so they open as text in
Excel/Google Sheets. Fixes#39.
- Sanitise policy bodies with wp_kses_post at output in
PolicyEndpoint::index() (the booking JS renders that HTML raw), so a
future write path that forgets kses can never become stored XSS.
Fixes#40.
- Store invite tokens hashed (SHA-256) at rest: a database leak can no
longer redeem pending invites. The registration link is shown once, at
creation; the pending list shows email/invited date; lookups hash the
submitted token. Existing plaintext pending invites must be re-issued.
Fixes#41.
- Validate availability slot datetimes on both creation paths (REST and
admin form) via AvailabilitySlot::normalizeDateTime(): canonical and
datetime-local forms normalise to Y-m-d H:i:s, garbage and end <= start
are rejected (REST 400) instead of reaching the DATETIME column or
throwing inside the weekly-series date arithmetic. Fixes#42.
composer test (204 tests, 594 assertions), PHPStan L6, and PHPCS all green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Security fixes from a pen-test review (issues #31–#37):
- #31 Booking no longer trusts a client-supplied offering_id: a slot-tied
offering is authoritative and any offering used must belong to the slot's
instructor, closing a free/misrouted-payment bypass.
- #34 Availability slot creation rejects an offering the instructor does not
own (AvailabilityEndpoint now takes OfferingRepository).
- #32 Offering/question/policy listing endpoints now require book_lesson
instead of being public (no anonymous consumer exists); Offering::toArray
also omits etransfer_email from listings as defense-in-depth.
- #33 Slots are claimed atomically (UPDATE ... WHERE is_booked = 0) before a
lesson is inserted, preventing a double-booking race.
- #35 A single weekly booking is capped (MAX_WEEKLY_OCCURRENCES) and only
creates lessons for slots it actually claimed.
- #36 Stripe secret/webhook keys are write-only in the settings UI and a blank
submit keeps the stored value; secrets are never echoed back into HTML.
- #37 Pending invites expire after 14 days (Invite::isAcceptable), enforced at
registration and surfaced on the admin invites list.
Adds BookingEndpointTest plus Invite/Offering/AvailabilityRepository coverage
and minimal WP_REST_Request/WP_REST_Response stubs. composer test (200),
lint, and cs all green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Completes the instructor-management half of #9: the studio admin can now
create instructor accounts and toggle each instructor's capabilities.
- InstructorController (manage_instructors): list instructors, create a
us_instructor WP user (emailing a set-password link), and a per-instructor
capability detail view.
- InstructorCapabilities: pure, unit-tested rules for which managed caps an
admin may assign and how a submitted form maps to assignments. Managed caps
are manage_offerings, manage_questions, view_own_payments, export_payments;
manage_availability and view_own_lessons are core to every instructor.
- A studio admin can never grant a capability it does not itself hold: only
held caps (checked via current_user_can, so an administrator's dynamic grant
counts) are offered, and on creation any managed cap the admin lacks is
denied on the new instructor so they never exceed their creator. The role
grants the managed caps by default; the page layers per-user overrides.
- AdminMenu: register the Instructors page in the people section.
- Tests for the capability logic; docs/features/user-roles.md updated.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A WordPress administrator previously inherited the studio-admin
capabilities but not `manage_availability`, so the studio owner running
as an admin had no way to reach "My Availability" or act as the
instructor — breaking single-instructor businesses.
Grant the instructor capabilities to administrators as well (via the
existing `user_has_cap` filter), and make both grants — studio-admin and
instructor — independently toggleable from a new Access admin page.
- RoleManager: extract `INSTRUCTOR_CAPS`; apply studio and instructor
cap sets to administrators, each gated on a stored toggle (default on).
- AccessSettings + templates/admin/access.php: two options
(`us_admin_grant_studio` / `us_admin_grant_instructor`), gated on the
core `manage_options` capability so disabling a grant can never lock an
administrator out of re-enabling it.
- AdminMenu: register the Access page after Studio Settings; keep the
studio sidebar separator visible for any administrator.
- Tests for the toggles and the new settings reader; docs updated.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Completes the deferred half of payments: real credit-card processing on
top of the existing ledger/e-transfer/comp foundation.
- StripeGateway wraps stripe/stripe-php: creates idempotent PaymentIntents
(amount in cents, registration ids in metadata) and verifies webhook
signatures. Stripe calls sit behind protected seams for unit testing.
- PaymentService::createIntent resolves the client-side step for a new
registration (card → client secret; e-transfer → display data; comp →
none) with caller-ownership enforcement.
- PaymentService::handleWebhook finalises a payment exactly once on
payment_intent.succeeded (mark paid → confirm → receipt) and marks it
failed on payment_intent.payment_failed.
- PaymentEndpoint: POST /payments/intent (book_lesson) and public,
signature-verified POST /payments/webhook.
- PaymentRepository: setStripeIntentId / findByStripeIntentId.
- StudioSettings: us_stripe_webhook_secret option, with the webhook URL
and required events surfaced on the settings page.
- Front end: shared payment.js mounts Stripe Payment Elements and confirms
the card (or shows e-transfer instructions); Stripe.js enqueued only when
configured. Wired into booking and group-class flows.
Tests: new StripeGatewayTest; PaymentService card-intent + webhook cases;
repository coverage. composer test/lint/cs all green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Studio Settings gains a default HST rate; the rate is frozen onto each
payment at booking and computed against the pre-tax subtotal, with the
total billed as subtotal + tax. The rate is overridable per booking on
My Lessons while unpaid (recomputing the tax amount), comped
registrations are never taxed, and receipts break out subtotal/HST/total.
Builds the payments report (roadmap #8) from us_payments: a monthly
per-instructor view with subtotal, HST collected, and grand-total
aggregation, plus a nonce-protected CSV export via admin-post. Studio
admins see all instructors and can filter; instructors are scoped to
their own rows. The Payment Report menu is gated on export_payments so
instructors (who lack manage_billing) can reach it.
Co-Authored-By: Claude Opus 4.8 <[email protected]>