A parent registers once and manages lessons for one or more children, who need no login of their own. A child is a real wp_users row with the student role but no usable login — so student_id keeps meaning "a WordPress user" on every table, and booking, credits, policies and enrolments work unchanged. A us_guardians link table maps guardian to child. The signup form gains a parent/guardian tick that reveals a block per child, with the account-signup questions asked per child rather than per guardian — they describe the student, not the account holder. Signup policies are recorded once per child with the guardian as the acceptor, which is the record that actually means something. A family that half-creates is rolled back entirely rather than leaving a guardian who cannot re-register. The booking and enrolment forms gain a "Who is this for?" picker listing children first, so the default selection is never the parent — booking for the wrong child is correctable, quietly billing a parent for their kid's lesson is not. POST /bookings and POST /enrollments take an optional student_id honoured only for that child's guardian; anything else is a 403. That check is the authorisation boundary of the feature. Payments and credits gain a payer: the charge names the child it was for and the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step reach the parent. Credit is held by the payer, so one child's cancellation can settle a sibling's charge, and the daily billing scan sends a guardian one notice covering every child. Closes #132 Co-Authored-By: Claude Opus 5 <[email protected]>
7.8 KiB
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, as a required second step after choosing 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 |
is_required |
TINYINT(1) | 1 = registrant 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 |
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) |
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
- On the booking form, the front-end calls
GET /offerings/{id}/questions. - Required questions block submission until answered.
- Answers are sent in the
answers[]array onPOST /bookingsorPOST /enrollmentsand written tous_question_answersalongside the new registration row.
Account-scope Flow (signup step two)
- The
[us_student_register]page (Auth\RegistrationPage) loads active account-scope questions viaQuestionRepository::findByScope('account'). - The form renders as two steps: step one is email/name/password/policies, step two is the questions.
assets/js/register.jsreveals step two behind a "Next" button (progressive enhancement — without JS both steps show and the single submit still works). This applies to every signup path (invite, group link, self-approval). - On submit, required answers are validated before the user is created (a missing answer returns an error and creates no account); after creation each answered question is written to
us_question_answerswithregistration_type = 'account',registration_id = student_id = <new user ID>. - 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.
- 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.
| 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, nullableofferingId),Unsupervised\Schedular\Registration\Answer(REG_ACCOUNT) - Admin controller:
Unsupervised\Schedular\Registration\QuestionController - REST endpoint:
Unsupervised\Schedular\Registration\QuestionEndpoint(offering scope only) - Signup step two:
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+ nullableus_questions.offering_id(requires a plugin version bump sodbDeltaruns) - Nullability repair:
dbDeltadoes not reliably relax a column fromNOT NULLtoNULL, so sites created before account-scope questions keptoffering_id NOT NULLand rejected account inserts.QuestionRepository::ensureOfferingNullable()re-applies the nullable definition (idempotentALTER … MODIFY);Plugin::boot()runs it once, guarded by theus_questions_offering_nullableoption rather than the version gate (affected sites may already be on the current version)
Tests
tests/Unit/Registration/QuestionRepositoryTest.phptests/Unit/Registration/AnswerRepositoryTest.phptests/Unit/Registration/QuestionTest.phptests/Unit/Registration/AnswerTest.phptests/Unit/Auth/RegistrationPageTest.phptests/Unit/Auth/StudentHistoryTest.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. See
parent-guardian-accounts.md.