Files
unsupervised-scheduler/docs/features/registration-questions.md
T
thatguygriffandClaude Opus 5 8c21a3fa9d
CI / Tests (PHP 8.1) (pull_request) Successful in 6m39s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m59s
CI / Tests (PHP 8.5) (pull_request) Successful in 3m31s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m28s
CI / Build Plugin Zip (pull_request) Skipped
Let the studio book lessons and record intake collected elsewhere
Two related gaps, closed together because the second is created by the first.

A private lesson could only be booked by the student or their guardian, so a
booking taken over the phone had no way in — where group classes have had "Add
students directly" all along. "Book a lesson for a student" is now a panel on
Scheduler and My Lessons: student, open time, lesson type, with weekly term
reservations and a no-charge option for make-up lessons. The booking core is
extracted to Booking\LessonBooker and shared with POST /bookings, so the two
paths cannot drift on offering rules, slot claiming, or billing.

That leaves a registration with no intake answers and no policy acceptances,
because nobody was at a keyboard to give them — already true of every directly
added group-class student. Ticking the boxes on a student's behalf would be an
audit trail that says something untrue, so instead the answers are collected
another way and recorded afterwards, from a lesson's or an enrolment's detail
page. Every recording must say how it was collected, which is stamped on each
row along with who typed it and shown in a new "How it was given" column: a
policy ticked online and one transcribed from paper must never look alike.

Only staff-made registrations qualify (us_lessons.booked_by,
us_group_enrollments.enrolled_by) — one the student made already holds their
own answers. Only what is still missing can be recorded, re-checked at write
time, so a stale or double-posted form cannot duplicate or overwrite. No IP is
stored for a transcription, and accepted_by stays the student while recorded_by
names the staff member.

Intake is now generic over Registration\IntakeSubject, which Lesson and
Enrollment both implement; LessonDetail became Registration\IntakeAudit and is
shared by both detail views rather than duplicated.

Closes #182

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QfHt6CyJHz6KkA4RuaS7WK
2026-08-24 14:06:16 -03:00

133 lines
11 KiB
Markdown

# Feature: Registration Questions
## Overview
Questions come in two **scopes**:
- **Offering scope** (`scope = 'offering'`) — intake questions a registrant answers when
booking a specific offering; authored per offering by the studio admin or the owning
instructor, and stored against the resulting lesson or group enrolment.
- **Account scope** (`scope = 'account'`) — studio-wide questions every new student answers
**once at account signup**, on the same page as their name and password. Authored by the
studio admin only, and stored against the new user account.
Both scopes share the `us_questions` / `us_question_answers` tables, the same field types,
and the same authoring page (**Offerings → Questions**).
## Data Model — `{prefix}us_questions`
| Column | Type | Notes |
|---------------|------------------|-------------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` for offering-scoped questions; NULL for account-scoped |
| `scope` | VARCHAR(20) | `offering` (default) or `account` |
| `label` | VARCHAR(255) | The question text shown to the registrant |
| `field_type` | VARCHAR(20) | `text` / `textarea` / `select` / `checkbox` |
| `options` | TEXT | JSON array of choices (for `select`); NULL otherwise |
| `audience` | VARCHAR(20) | `all` (default) or `child` — who the question is asked of (account scope) |
| `is_required` | TINYINT(1) | 1 = the **account holder** must answer to continue |
| `is_required_child` | TINYINT(1) | 1 = each **student being registered** must answer to continue |
| `sort_order` | INT | Display order within the scope |
| `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form |
| `created_at` | DATETIME | Insertion time |
## Audience and Required-ness (account scope)
An account-scope question is asked in two places, and the two are configured separately:
- **The account holder's own "About you" panel** — shown when they are registering
themselves (`self` or `both`). Governed by `audience` (a `child` question is not asked
here at all) and by `is_required`.
- **Each student block** — one per person they are registering on behalf of, on the signup
form and on the guardian's family screen. Every question is asked here regardless of
`audience`; `is_required_child` decides whether it blocks submission.
That split is what lets a studio ask "School and grade" of children only, or make
"Previous experience" optional for an adult signing themselves up but required for every
child they enrol. `audience = 'child'` leaves `is_required` moot — the question never
reaches the account holder's panel.
`audience` and `is_required_child` are ignored for offering-scope questions: booking and
enrolment ask their intake questions once, about the student being booked, with no separate
account-holder form to differ from.
## Data Model — `{prefix}us_question_answers`
| Column | Type | Notes |
|---------------------|------------------|--------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `question_id` | BIGINT UNSIGNED | FK → `us_questions.id` |
| `registration_type` | VARCHAR(20) | `lesson`, `enrollment`, or `account` |
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id`, `us_group_enrollments.id`, or the user ID (account scope) |
| `collected_via` | VARCHAR(20) | How the answer reached the studio when it was not given online (`paper` / `in_person` / `phone` / `email` / `other`); NULL means online |
| `collected_note` | VARCHAR(191) | Free-text detail for the above; required for `other` |
| `recorded_by` | BIGINT UNSIGNED | Staff member who typed a collected-elsewhere answer in; 0 otherwise |
| `student_id` | BIGINT UNSIGNED | WordPress user ID (denormalised for fast lookup) |
| `answer_value` | TEXT | The submitted answer (checkbox stored as `0`/`1`) |
| `created_at` | DATETIME | Insertion time |
The `registration_type` + `registration_id` pair is a polymorphic reference shared
with `us_policy_acceptances` (see `policies.md`), letting answers attach to a private
lesson, a group enrolment, or an account signup (`account` + the user ID).
## Offering-scope Flow
1. On the booking form, the front-end calls `GET /offerings/{id}/questions`.
2. Required questions block submission until answered.
3. Answers are sent in the `answers[]` array on `POST /bookings` or `POST /enrollments` and written to `us_question_answers` alongside the new registration row.
## Account-scope Flow (signup)
1. The `[us_student_register]` page (`Auth\RegistrationPage`) loads active account-scope questions via `QuestionRepository::findByScope('account')`.
2. The form is a single page. The questions sit in an **About you** panel, alongside the account holder's birth year, between the "Who are you registering?" choice and the students being added — minus any `audience = 'child'` question, which is never asked of the account holder. `assets/js/register.js` disables and hides that whole panel when the choice is "on behalf of students" — the questions describe a student and a pure guardian is not one — and puts the full question set in every child block instead. Progressive enhancement: without JS every panel shows and the single submit still works. This applies to **every** signup path (invite, group link, self-approval).
3. On submit, required answers are validated **before** the user is created (a missing answer returns an error and creates no account) — `is_required` against the account holder's panel, `is_required_child` against each student block; after creation each answered question is written to `us_question_answers` with `registration_type = 'account'`, `registration_id = student_id = <new user ID>`. An answer posted for a `child`-audience question against the account holder is discarded, not stored.
4. A studio admin reviews the answers on the student's admin screen under **Registration Information** (`Auth\StudentHistory::registrationInfo()` lists every account question paired with the student's answer, "—" when unanswered). These rows are excluded from the offering-scope "Intake answers" table.
## Admin Interface
Both scopes are edited from **Offerings → Questions** (`Registration\QuestionController`):
- Pick an offering to edit its questions, or **"Account signup (all registrations)"** for the account-scope questions.
- The account-scope form adds **Asked of** (everyone / students only) and a second **Required** checkbox for students; both are hidden for offering scope, where they have no meaning.
- Studio admin (`manage_questions` + `manage_instructors`) edits any offering's questions and the account-scope questions.
- Instructor (`manage_questions`) edits questions only on their own offerings; the account-scope option is hidden.
## REST API
Only offering-scope questions are exposed over REST. Account-scope questions are managed
through the server-rendered admin page and read directly by `RegistrationPage` — a request
naming one is turned away as not found, since the owner check has no offering to check
against, so REST can neither read nor overwrite an `audience`. An offering question written
over REST mirrors its single `is_required` into `is_required_child`, as the admin form and
the upgrade backfill both do.
| Method | Endpoint | Permission |
|----------|---------------------------------------------------|----------------------|
| `GET` | `/wp-json/us-scheduler/v1/offerings/{id}/questions`| Public |
| `POST` | `/wp-json/us-scheduler/v1/questions` | `manage_questions` |
| `PATCH` | `/wp-json/us-scheduler/v1/questions/{id}` | `manage_questions` + owner |
| `DELETE` | `/wp-json/us-scheduler/v1/questions/{id}` | `manage_questions` + owner |
## Implementation
- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository` (`findByOffering`, `findByScope`), `Unsupervised\Schedular\Registration\AnswerRepository`
- Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`, `audience`, `isRequiredChild`, and the `askedOfSelf()` / `isRequiredForSelf()` / `isRequiredForChild()` readers every caller uses instead of touching `isRequired` directly), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`)
- Admin controller: `Unsupervised\Schedular\Registration\QuestionController`
- REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint` (offering scope only)
- Signup form: `Unsupervised\Schedular\Auth\RegistrationPage`, `templates/frontend/register-page.php`, `assets/js/register.js`
- Admin review: `Unsupervised\Schedular\Auth\StudentHistory::registrationInfo()`, `templates/admin/student-detail.php`
- Schema: `us_questions.scope` + nullable `us_questions.offering_id`, `us_questions.audience`, `us_questions.is_required_child` (each requires a plugin version bump so `dbDelta` runs)
- Required-for-students backfill: `is_required_child` arrives with `DEFAULT 0`, which would quietly make every existing required question optional for students. `QuestionRepository::backfillChildRequired()` copies `is_required` into it once; `Plugin::boot()` runs it guarded by the `us_questions_child_required_backfilled` option, after the version gate has let `dbDelta` add the column
- Nullability repair: `dbDelta` does **not** reliably relax a column from `NOT NULL` to `NULL`, so sites created before account-scope questions kept `offering_id NOT NULL` and rejected account inserts. `QuestionRepository::ensureOfferingNullable()` re-applies the nullable definition (idempotent `ALTER … MODIFY`); `Plugin::boot()` runs it once, guarded by the `us_questions_offering_nullable` option rather than the version gate (affected sites may already be on the current version)
## Tests
- `tests/Unit/Registration/QuestionRepositoryTest.php`
- `tests/Unit/Registration/AnswerRepositoryTest.php`
- `tests/Unit/Registration/QuestionTest.php`
- `tests/Unit/Registration/AnswerTest.php`
- `tests/Unit/Registration/QuestionFieldTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php`
- `tests/Unit/Auth/StudentHistoryTest.php`
- `tests/Unit/Guardian/FamilyPageTest.php`
## Per-Child Answers
For a parent/guardian signup, **account-scope** questions are asked **once per
child** rather than once per guardian — in practice they describe the student
(instrument, level, school), not the account holder. Each answer's `student_id`
and `registration_id` are the child's user ID, so a studio admin reading a
child's screen sees the information that describes them. The guardian's family
screen asks the same questions when a child is added later, under the same
`is_required_child` rule as the signup form. See `parent-guardian-accounts.md`.