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]>
101 lines
7.8 KiB
Markdown
101 lines
7.8 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**, 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`.
|