Files
unsupervised-scheduler/docs/features/registration-questions.md
T
thatguygriffandClaude Opus 5 4a41ba96fb
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m45s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Failing after 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m57s
Let parents register once and book for their children
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]>
2026-07-29 16:00:34 -03:00

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

  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 step two)

  1. The [us_student_register] page (Auth\RegistrationPage) loads active account-scope questions via QuestionRepository::findByScope('account').
  2. The form renders as two steps: step one is email/name/password/policies, step two is the questions. assets/js/register.js reveals 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).
  3. 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_answers with registration_type = 'account', registration_id = student_id = <new user ID>.
  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.
  • 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, nullable offeringId), 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 + nullable us_questions.offering_id (requires a plugin version bump so dbDelta runs)
  • 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/Auth/RegistrationPageTest.php
  • tests/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.