Files
unsupervised-scheduler/docs/features/policies.md
T
thatguygriffandClaude Opus 5 cb347ffca0
CI / Tests (PHP 8.1) (pull_request) Successful in 1m0s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m0s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m8s
CI / Build Plugin Zip (pull_request) Skipped
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
Demo follow-ups: editable policy name, one-page signup, group classes in upcoming lessons, deletion cleanup
Five items from the latest demo pass:

- A policy's title can be edited from the Policies screen. Only the title
  moves; the slug is what the gates resolve policies by, so a rename can
  never detach a policy from acceptances already recorded against it.
- Signup is one page again. The studio's registration questions move from
  a second step behind "Next" onto the main form, in an "About you" panel
  above the students being added, and that panel also asks an adult
  student for their birth year (the same us_birth_year meta a child's
  uses). register.js disables and hides the whole panel for a pure
  guardian, since the questions describe a student.
- The password is re-scored on submit, not only as it is typed. zxcvbn's
  dictionary arrives after page load, so a password typed straight away
  was never scored at all and the first the student heard of it was the
  server rejecting the whole form.
- Group-class sessions appear alongside lessons wherever upcoming lessons
  are listed: the [us_scheduler] panel (students and instructors) and the
  admin student detail page. GroupClass\SessionSchedule derives them from
  Offering::sessionWindows(), the same derivation the billing scan uses.
  They carry kind = 'group_class' and no Cancel action - a session is one
  date in a term, not a booked slot.
- Deleting a user releases what the account was holding: each upcoming
  lesson is cancelled, its slot freed for rebooking, its pending payment
  voided, and active class enrolments cancelled. Past lessons and paid
  history are left alone.

Tests: composer test (851), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 11:45:04 -03:00

7.8 KiB

Feature: Policies

Overview

The studio admin drafts, versions, and publishes policies (e.g. cancellation, payment, code of conduct). Registrants must read and accept the current published version of every policy before they can book. Acceptance is recorded against the specific version, so when a policy is updated students must re-accept it at their next booking.

Data Model — {prefix}us_policies

Column Type Notes
id BIGINT UNSIGNED Primary key
title VARCHAR(191) Display name
slug VARCHAR(191) Unique key, e.g. cancellation
current_version_id BIGINT UNSIGNED Nullable FK → us_policy_versions.id (published)
acceptance_scope VARCHAR(20) signup / booking / both — when it must be accepted
created_at DATETIME Insertion time

Data Model — {prefix}us_policy_versions

Column Type Notes
id BIGINT UNSIGNED Primary key
policy_id BIGINT UNSIGNED FK → us_policies.id
version_number INT Increments per policy, starting at 1
body LONGTEXT The policy text (HTML/markdown)
status VARCHAR(20) draft / published / archived
published_at DATETIME When published; NULL for drafts
created_at DATETIME Insertion time

Data Model — {prefix}us_policy_acceptances

Column Type Notes
id BIGINT UNSIGNED Primary key
policy_version_id BIGINT UNSIGNED FK → us_policy_versions.id (the exact version accepted)
student_id BIGINT UNSIGNED WordPress user ID
registration_type VARCHAR(20) lesson or enrollment
registration_id BIGINT UNSIGNED FK → us_lessons.id or us_group_enrollments.id
accepted_at DATETIME Timestamp of acceptance
ip_address VARCHAR(45) IP captured at acceptance (audit trail)

Versioning & Acceptance Rules

  • Editing a published policy creates a new draft version; the old version stays published until the draft is published.
  • Editing a draft version rewrites it in place — nobody has accepted it yet, so there is nothing to preserve and no new version is created. PATCH /policies/{id}/versions/{vid} allows only this case; the admin page also accepts an edit to a published or archived version and branches a new draft from it.
  • Publishing a draft sets it published, stamps published_at, archives the prior version, and points us_policies.current_version_id at it.
  • The registration gate requires acceptance of the current_version_id of every policy. Because acceptance is tied to policy_version_id, a newly published version is unaccepted and must be re-accepted at the student's next booking.

Admin Interface

Policies in wp-admin (manage_policies, studio admin only):

  • Create a policy; draft version bodies
  • Rename the selected policy (rename_policy, PolicyRepository::updateTitle()). Only the title changes: the slug is the identifier findBySlug() and the gates resolve policies by, so renaming can never detach a policy from versions students have already accepted. A blank title, or one longer than Policy::MAX_TITLE_LENGTH, is ignored
  • View the content of any version (?page=us-policies&policy_id={id}&version_id={vid}), whatever its status
  • Edit from the viewer: a draft is saved in place; editing a published or archived version instead saves the text as a new draft version (the viewer follows to it), so text students have already accepted is never rewritten
  • Publish a draft version; view acceptance history per version

Rendering a Policy Body

Bodies are typed into a plain textarea, so most are written as blank-line-separated prose with no markup. PolicyVersion::bodyHtml() is the single render path — wp_kses_post() then wpautop(), the same treatment WordPress gives post content — so unmarked-up text arrives as real paragraphs and bodies that do carry markup are left alone. It feeds the booking/enrolment JSON (GET /policies), the signup form, and the admin version viewer, which therefore previews exactly what students see.

The acceptance markup (.us-policy / .us-policy-body) is styled in assets/css/frontend.css as a bounded, vertically scrolling reading box with overflow-wrap: break-word, so a long policy or a pasted URL cannot force a horizontal scrollbar or push the accept checkbox out of view. RegistrationPage enqueues that stylesheet for the signup gate; BookingPage and GroupClassPage already did.

REST API

Method Endpoint Permission
GET /wp-json/us-scheduler/v1/policies Public (current published versions)
POST /wp-json/us-scheduler/v1/policies manage_policies
POST /wp-json/us-scheduler/v1/policies/{id}/versions manage_policies
PATCH /wp-json/us-scheduler/v1/policies/{id}/versions/{vid} manage_policies
POST /wp-json/us-scheduler/v1/policies/{id}/versions/{vid}/publish manage_policies

Acceptances are not posted directly — they are written as part of POST /bookings and POST /enrollments via the accepted_policy_version_ids[] field, which must cover every policy's current version or the registration is rejected.

Implementation

  • Repositories: Unsupervised\Schedular\Policy\PolicyRepository, Unsupervised\Schedular\Policy\PolicyVersionRepository, Unsupervised\Schedular\Policy\AcceptanceRepository
  • Models: Unsupervised\Schedular\Policy\Policy, Unsupervised\Schedular\Policy\PolicyVersion, Unsupervised\Schedular\Policy\PolicyAcceptance
  • Service: Unsupervised\Schedular\Policy\PolicyService — orchestrates create / add-draft / publish across the policies and versions tables (archive prior current version, stamp published_at, repoint current_version_id)
  • Admin controller: Unsupervised\Schedular\Policy\PolicyController
  • REST endpoint: Unsupervised\Schedular\Policy\PolicyEndpoint

Tests

  • tests/Unit/Policy/PolicyValueObjectsTest.php
  • tests/Unit/Policy/PolicyRepositoryTest.php
  • tests/Unit/Policy/PolicyVersionRepositoryTest.php
  • tests/Unit/Policy/AcceptanceRepositoryTest.php
  • tests/Unit/Policy/PolicyServiceTest.php
  • tests/Unit/Policy/PolicyControllerTest.php
  • tests/Unit/Policy/PolicyEndpointTest.php

Who Accepted

us_policy_acceptances.accepted_by records the person who actually ticked the box, where that differs from student_id — a guardian agreeing on a child's behalf. It defaults to 0, read back as "the student agreed for themselves" (PolicyAcceptance::acceptorOrStudent()). See parent-guardian-accounts.md.