diff --git a/assets/js/register.js b/assets/js/register.js new file mode 100644 index 0000000..ca56f1c --- /dev/null +++ b/assets/js/register.js @@ -0,0 +1,57 @@ +/** + * Progressive enhancement for the two-step student registration form. + * + * When account-signup questions are configured the form renders two panels + * (`[data-step="1"]` account details, `[data-step="2"]` the questions) inside a + * single form marked `data-steps="1"`. This script hides step two behind a + * "Next" button that only advances once step one passes native validation. + * Without JS both panels stay visible and the single submit still works. + */ +(function () { + 'use strict'; + + function enhance(form) { + var step1 = form.querySelector('[data-step="1"]'); + var step2 = form.querySelector('[data-step="2"]'); + var next = form.querySelector('.us-reg-next'); + var back = form.querySelector('.us-reg-back'); + + if (!step1 || !step2 || !next) { + return; + } + + function show(step) { + step1.hidden = step !== 1; + step2.hidden = step !== 2; + } + + show(1); + + next.addEventListener('click', function () { + var fields = step1.querySelectorAll('input, select, textarea'); + + for (var i = 0; i < fields.length; i++) { + if (!fields[i].checkValidity()) { + fields[i].reportValidity(); + return; + } + } + + show(2); + }); + + if (back) { + back.addEventListener('click', function () { + show(1); + }); + } + } + + document.addEventListener('DOMContentLoaded', function () { + var forms = document.querySelectorAll('.us-register-form form[data-steps="1"]'); + + for (var i = 0; i < forms.length; i++) { + enhance(forms[i]); + } + }); +})(); diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md index a97e741..7e95620 100644 --- a/docs/features/account-registration.md +++ b/docs/features/account-registration.md @@ -76,6 +76,15 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h | `accepted_at` | DATETIME | When accepted; NULL while pending / for group links | | `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) | +## Registration Questions (signup step two) +When the studio has configured **account-scope** registration questions +(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), the +registration form becomes two steps: name/email/password/policies first, then the required +questions. This applies to **every** signup path (invite, group link, self-approval). +Required answers are validated before the account is created, and are stored against the new +user (`us_question_answers`, `registration_type = 'account'`). A studio admin reviews them +under **Registration Information** on the student's admin screen. + ## Policy Acceptance Scope Policies declare **when** they must be accepted via `us_policies.acceptance_scope`: `signup`, `booking`, or `both` (see `policies.md`). The registration form requires diff --git a/docs/features/registration-questions.md b/docs/features/registration-questions.md index 7d33257..08c1572 100644 --- a/docs/features/registration-questions.md +++ b/docs/features/registration-questions.md @@ -1,19 +1,30 @@ # Feature: Registration Questions ## Overview -Each offering can carry a set of intake questions the registrant must answer when booking. Questions are authored per offering by the studio admin or the owning instructor, and answers are stored against the resulting lesson or group enrolment. +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` — questions are scoped per offering | +| `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 offering | +| `sort_order` | INT | Display order within the scope | | `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form | | `created_at` | DATETIME | Insertion time | @@ -23,27 +34,37 @@ Each offering can carry a set of intake questions the registrant must answer whe |---------------------|------------------|--------------------------------------------------------| | `id` | BIGINT UNSIGNED | Primary key | | `question_id` | BIGINT UNSIGNED | FK → `us_questions.id` | -| `registration_type` | VARCHAR(20) | `lesson` or `enrollment` | -| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.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 either a -private lesson or a group enrolment. +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). -## Flow -1. On the registration form, the front-end calls `GET /offerings/{id}/questions`. +## 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 = `. +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 -Questions are edited from each offering's screen (**Offerings → Questions**). -- Studio admin (`manage_questions`) edits questions on any offering. -- Instructor (`manage_questions`) edits questions only on their own offerings. +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 | @@ -52,12 +73,18 @@ Questions are edited from each offering's screen (**Offerings → Questions**). | `DELETE` | `/wp-json/us-scheduler/v1/questions/{id}` | `manage_questions` + owner | ## Implementation -- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository`, `Unsupervised\Schedular\Registration\AnswerRepository` -- Models: `Unsupervised\Schedular\Registration\Question`, `Unsupervised\Schedular\Registration\Answer` +- 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` +- 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) ## 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` diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php index 17e022a..9909a72 100644 --- a/src/Auth/RegistrationPage.php +++ b/src/Auth/RegistrationPage.php @@ -9,6 +9,10 @@ use Unsupervised\Schedular\Policy\Policy; use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyVersionRepository; +use Unsupervised\Schedular\Registration\Answer; +use Unsupervised\Schedular\Registration\AnswerRepository; +use Unsupervised\Schedular\Registration\Question; +use Unsupervised\Schedular\Registration\QuestionRepository; use Unsupervised\Schedular\Val; class RegistrationPage { @@ -32,6 +36,8 @@ class RegistrationPage { private AcceptanceRepository $acceptances, private StudioSettings $settings, private RegistrationMailer $mailer, + private QuestionRepository $questions, + private AnswerRepository $answers, ) {} /** @@ -76,8 +82,14 @@ class RegistrationPage { // Where the post-confirmation prompt sends students to sign in. $loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) ); - $policyForms = $this->signupPolicies(); - $canRegister = $open || $inviteValid; + $policyForms = $this->signupPolicies(); + $accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true ); + $canRegister = $open || $inviteValid; + + // The two-step script only matters when there is a second step to reveal. + if ( $canRegister && '' === $successType && [] !== $accountQuestions ) { + wp_enqueue_script( 'us-scheduler-register' ); + } ob_start(); include USC_PLUGIN_DIR . 'templates/frontend/register-page.php'; @@ -156,6 +168,17 @@ class RegistrationPage { } } + // Account-signup questions (step two) — validate before creating the user so + // a missing required answer never leaves a half-registered account behind. + $accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true ); + $answers = $this->submittedAnswers(); + + foreach ( $accountQuestions as $question ) { + if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) { + return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' ); + } + } + if ( email_exists( $email ) ) { return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' ); } @@ -175,6 +198,7 @@ class RegistrationPage { } $this->recordAcceptances( $policyForms, (int) $userId ); + $this->recordAnswers( $accountQuestions, $answers, (int) $userId ); if ( $inviteValid && ! $invite->isGroup() ) { $this->invites->markAccepted( (int) $invite->id, (int) $userId ); @@ -228,6 +252,52 @@ class RegistrationPage { return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base ); } + /** + * The account-question answers submitted with the form, keyed by question id. + * + * @return array + */ + private function submittedAnswers(): array { + // The submit nonce is verified by the caller (render) before this runs. + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized in the loop below. + $raw = $_POST['us_answers'] ?? []; + if ( ! is_array( $raw ) ) { + return []; + } + + $out = []; + foreach ( $raw as $questionId => $value ) { + $out[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) ); + } + + return $out; + } + + /** + * Persist the submitted answers for each active account-signup question. + * + * @param list $questions + * @param array $answers question_id => submitted value + */ + private function recordAnswers( array $questions, array $answers, int $userId ): void { + foreach ( $questions as $question ) { + $value = trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ); + if ( '' === $value ) { + continue; + } + + $this->answers->insert( + new Answer( + questionId: (int) $question->id, + registrationType: Answer::REG_ACCOUNT, + registrationId: $userId, + studentId: $userId, + answerValue: $value, + ) + ); + } + } + /** * Record account-time acceptances for each signup policy version. * diff --git a/src/Auth/StudentController.php b/src/Auth/StudentController.php index 479def2..5a2fb3b 100644 --- a/src/Auth/StudentController.php +++ b/src/Auth/StudentController.php @@ -145,9 +145,10 @@ class StudentController { $this->enrollments->findByStudent( (int) $student->ID ) ); - $acceptances = $this->history->policyAcceptances( (int) $student->ID ); - $intake = $this->history->intakeAnswers( (int) $student->ID ); - $payments = $canBilling ? $this->history->payments( (int) $student->ID ) : []; + $acceptances = $this->history->policyAcceptances( (int) $student->ID ); + $registrationInfo = $this->history->registrationInfo( (int) $student->ID ); + $intake = $this->history->intakeAnswers( (int) $student->ID ); + $payments = $canBilling ? $this->history->payments( (int) $student->ID ) : []; $backUrl = admin_url( 'admin.php?page=us-students' ); include USC_PLUGIN_DIR . 'templates/admin/student-detail.php'; diff --git a/src/Auth/StudentHistory.php b/src/Auth/StudentHistory.php index de058d2..acde576 100644 --- a/src/Auth/StudentHistory.php +++ b/src/Auth/StudentHistory.php @@ -11,6 +11,7 @@ use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyVersionRepository; use Unsupervised\Schedular\Registration\Answer; use Unsupervised\Schedular\Registration\AnswerRepository; +use Unsupervised\Schedular\Registration\Question; use Unsupervised\Schedular\Registration\QuestionRepository; /** @@ -51,22 +52,58 @@ class StudentHistory { } /** - * Every intake answer the student has submitted, newest registration first. + * Booking/enrolment intake answers the student has submitted, newest first. + * Account-signup answers are excluded — those are shown on their own under + * {@see registrationInfo()}. * * @return list */ public function intakeAnswers( int $studentId ): array { + $bookingAnswers = array_filter( + $this->answers->findByStudent( $studentId ), + static fn( Answer $answer ): bool => Answer::REG_ACCOUNT !== $answer->registrationType + ); + + return array_values( + array_map( + function ( Answer $answer ): array { + $question = $this->questions->findById( $answer->questionId ); + + return [ + 'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ), + 'answer' => $answer->answerValue ?? '—', + 'context' => $this->contextLabel( $answer->registrationType, $answer->registrationId ), + ]; + }, + $bookingAnswers + ) + ); + } + + /** + * The student's answers to the studio-wide account-signup questions: every + * configured account question paired with the student's answer ("—" when + * unanswered, e.g. a question added after they registered). + * + * @return list + */ + public function registrationInfo( int $studentId ): array { + $byQuestion = []; + foreach ( $this->answers->findByRegistration( Answer::REG_ACCOUNT, $studentId ) as $answer ) { + $byQuestion[ $answer->questionId ] = $answer->answerValue ?? ''; + } + return array_map( - function ( Answer $answer ): array { - $question = $this->questions->findById( $answer->questionId ); + static function ( Question $question ) use ( $byQuestion ): array { + $value = $byQuestion[ (int) $question->id ] ?? ''; return [ - 'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ), - 'answer' => $answer->answerValue ?? '—', - 'context' => $this->contextLabel( $answer->registrationType, $answer->registrationId ), + 'question' => $question->label, + 'answer' => '' === $value ? '—' : $value, + 'required' => $question->isRequired, ]; }, - $this->answers->findByStudent( $studentId ) + $this->questions->findByScope( Question::SCOPE_ACCOUNT ) ); } diff --git a/src/Plugin.php b/src/Plugin.php index 82b5df3..bd0066d 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -72,7 +72,7 @@ class Plugin { $bookingPage = new BookingPage(); $loginPage = new LoginPage(); - $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer ); + $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers ); $groupClassPage = new GroupClassPage(); ( new UpdateChecker() )->register(); diff --git a/src/Registration/Answer.php b/src/Registration/Answer.php index 3acbac1..bd9b3d5 100644 --- a/src/Registration/Answer.php +++ b/src/Registration/Answer.php @@ -9,13 +9,15 @@ class Answer { public const REG_LESSON = 'lesson'; public const REG_ENROLLMENT = 'enrollment'; + public const REG_ACCOUNT = 'account'; /** - * Polymorphic registration targets an answer can attach to. + * Polymorphic registration targets an answer can attach to. `account` is used + * by studio-wide questions answered at signup (registration_id = the user ID). * * @var list */ - public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT ]; + public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT, self::REG_ACCOUNT ]; public function __construct( public readonly int $questionId, diff --git a/src/Registration/Question.php b/src/Registration/Question.php index 003a5ea..311dfd0 100644 --- a/src/Registration/Question.php +++ b/src/Registration/Question.php @@ -12,6 +12,12 @@ class Question { public const FIELD_SELECT = 'select'; public const FIELD_CHECKBOX = 'checkbox'; + /** Question is scoped to a single offering, asked at booking/enrolment time. */ + public const SCOPE_OFFERING = 'offering'; + + /** Question is studio-wide, asked once at account signup (no offering). */ + public const SCOPE_ACCOUNT = 'account'; + /** * All valid field types. * @@ -24,19 +30,31 @@ class Question { self::FIELD_CHECKBOX, ]; + /** + * All valid scopes. + * + * @var list + */ + public const VALID_SCOPES = [ + self::SCOPE_OFFERING, + self::SCOPE_ACCOUNT, + ]; + /** * Build an intake question value object. * - * @param list|null $options Choices for a `select` field. + * @param int|null $offeringId The owning offering, or null for account-scoped questions. + * @param list|null $options Choices for a `select` field. */ public function __construct( - public readonly int $offeringId, + public readonly ?int $offeringId, public readonly string $label, public readonly string $fieldType = self::FIELD_TEXT, public readonly ?array $options = null, public readonly bool $isRequired = false, public readonly int $sortOrder = 0, public readonly bool $isActive = true, + public readonly string $scope = self::SCOPE_OFFERING, public readonly ?int $id = null, ) {} @@ -50,13 +68,14 @@ class Question { } return new self( - offeringId: Val::int( $row->offering_id ), + offeringId: Val::intOrNull( $row->offering_id ), label: Val::string( $row->label ), fieldType: Val::string( $row->field_type ), options: $options, isRequired: Val::bool( $row->is_required ), sortOrder: Val::int( $row->sort_order ), isActive: Val::bool( $row->is_active ), + scope: Val::string( $row->scope ), id: Val::int( $row->id ), ); } @@ -70,6 +89,7 @@ class Question { return [ 'id' => $this->id, 'offering_id' => $this->offeringId, + 'scope' => $this->scope, 'label' => $this->label, 'field_type' => $this->fieldType, 'options' => $this->options, diff --git a/src/Registration/QuestionController.php b/src/Registration/QuestionController.php index 4659354..0f55c46 100644 --- a/src/Registration/QuestionController.php +++ b/src/Registration/QuestionController.php @@ -23,8 +23,13 @@ class QuestionController { $userId = get_current_user_id(); $manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS ); - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only offering selector. - $offeringId = absint( Val::int( $_GET['offering_id'] ?? 0 ) ); + // The selector posts either an offering id or the sentinel `account`. + // Account-signup questions are studio-wide, so only studio admins manage them. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only selector. + $selection = sanitize_text_field( Val::string( wp_unslash( $_GET['offering_id'] ?? '' ) ) ); + $accountScope = $manageAll && Question::SCOPE_ACCOUNT === $selection; + + $offeringId = $accountScope ? 0 : absint( Val::int( $selection ) ); $offeringList = $manageAll ? $this->offerings->findAll() : $this->offerings->findAll( $userId ); $selectedOffering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null; @@ -33,7 +38,13 @@ class QuestionController { } $questions = null; - if ( null !== $selectedOffering ) { + if ( $accountScope ) { + if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_question_action' ) ) { + $this->handleFormAction( null ); + } + + $questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT ); + } elseif ( null !== $selectedOffering ) { if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_question_action' ) ) { $this->handleFormAction( $selectedOffering ); } @@ -44,20 +55,24 @@ class QuestionController { include USC_PLUGIN_DIR . 'templates/admin/questions.php'; } - private function handleFormAction( Offering $offering ): void { + /** + * Handle an add/delete action for the given context: an offering, or account + * scope when $offering is null. + */ + private function handleFormAction( ?Offering $offering ): void { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); if ( 'add' === $action ) { - $this->addQuestion( (int) $offering->id ); + $this->addQuestion( $offering ); } if ( 'delete' === $action ) { $questionId = absint( Val::int( $_POST['question_id'] ?? 0 ) ); if ( $questionId > 0 ) { $question = $this->questions->findById( $questionId ); - if ( $question && $question->offeringId === (int) $offering->id ) { + if ( $question && $this->belongsToContext( $question, $offering ) ) { $this->questions->delete( $questionId ); } } @@ -65,7 +80,7 @@ class QuestionController { // phpcs:enable WordPress.Security.NonceVerification.Missing } - private function addQuestion( int $offeringId ): void { + private function addQuestion( ?Offering $offering ): void { // phpcs:disable WordPress.Security.NonceVerification.Missing $label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) ); $fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) ); @@ -76,17 +91,30 @@ class QuestionController { $this->questions->insert( new Question( - offeringId: $offeringId, + offeringId: null === $offering ? null : (int) $offering->id, label: $label, fieldType: $fieldType, options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ), isRequired: isset( $_POST['is_required'] ), sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ), + scope: null === $offering ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING, ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing } + /** + * Whether a question belongs to the current editing context — the given + * offering, or account scope when $offering is null. + */ + private function belongsToContext( Question $question, ?Offering $offering ): bool { + if ( null === $offering ) { + return Question::SCOPE_ACCOUNT === $question->scope; + } + + return $question->offeringId === (int) $offering->id; + } + private function canManageOffering( Offering $offering, int $userId, bool $manageAll ): bool { return $manageAll || $offering->instructorId === $userId; } diff --git a/src/Registration/QuestionEndpoint.php b/src/Registration/QuestionEndpoint.php index 92ebd97..3e9ad01 100644 --- a/src/Registration/QuestionEndpoint.php +++ b/src/Registration/QuestionEndpoint.php @@ -126,6 +126,7 @@ class QuestionEndpoint { isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired, sortOrder: $request->has_param( 'sort_order' ) ? Val::int( $request->get_param( 'sort_order' ) ) : $existing->sortOrder, isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive, + scope: $existing->scope, id: $id, ); @@ -167,8 +168,14 @@ class QuestionEndpoint { /** * Ensure the offering exists and the caller owns it (or is a studio admin). + * Account-scoped questions have no offering and are not managed over REST, so + * a null offering id is rejected as not found. */ - private function requireOfferingOwner( int $offeringId ): ?\WP_Error { + private function requireOfferingOwner( ?int $offeringId ): ?\WP_Error { + if ( null === $offeringId ) { + return new \WP_Error( 'not_found', __( 'Question not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); + } + $offering = $this->offerings->findById( $offeringId ); if ( null === $offering ) { diff --git a/src/Registration/QuestionRepository.php b/src/Registration/QuestionRepository.php index 3d6f1f4..f4b4bb9 100644 --- a/src/Registration/QuestionRepository.php +++ b/src/Registration/QuestionRepository.php @@ -15,7 +15,7 @@ class QuestionRepository { $this->db->insert( $this->table, $this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ], - [ '%d', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ] + [ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ] ); return $this->db->insert_id; @@ -26,7 +26,7 @@ class QuestionRepository { $this->table, $this->columns( $question ), [ 'id' => $id ], - [ '%d', '%s', '%s', '%s', '%d', '%d', '%d' ], + [ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d' ], [ '%d' ] ); } @@ -39,6 +39,7 @@ class QuestionRepository { private function columns( Question $question ): array { return [ 'offering_id' => $question->offeringId, + 'scope' => $question->scope, 'label' => $question->label, 'field_type' => $question->fieldType, 'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ), @@ -69,6 +70,27 @@ class QuestionRepository { return array_map( Question::fromRow( ... ), $rows ?? [] ); } + /** + * Find questions for a scope (e.g. account-signup), ordered for display. + * + * @return list + */ + public function findByScope( string $scope, bool $activeOnly = false ): array { + $sql = 'SELECT * FROM %i WHERE scope = %s'; + $params = [ $this->table, $scope ]; + + if ( $activeOnly ) { + $sql .= ' AND is_active = %d'; + $params[] = 1; + } + + $sql .= ' ORDER BY sort_order ASC, id ASC'; + + $rows = $this->db->get_results( $this->db->prepare( $sql, $params ) ); + + return array_map( Question::fromRow( ... ), $rows ?? [] ); + } + public function findById( int $id ): ?Question { $row = $this->db->get_row( $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) diff --git a/src/Schema.php b/src/Schema.php index ff09a19..da93889 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -75,7 +75,8 @@ class Schema { "CREATE TABLE {$prefix}us_questions ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - offering_id BIGINT UNSIGNED NOT NULL, + offering_id BIGINT UNSIGNED DEFAULT NULL, + scope VARCHAR(20) NOT NULL DEFAULT 'offering', label VARCHAR(255) NOT NULL, field_type VARCHAR(20) NOT NULL DEFAULT 'text', options TEXT, @@ -85,6 +86,7 @@ class Schema { created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY offering_id (offering_id), + KEY scope (scope), KEY is_active (is_active) ) {$charset};", diff --git a/src/ShortcodeRegistrar.php b/src/ShortcodeRegistrar.php index 6c7fc21..c1da0b8 100644 --- a/src/ShortcodeRegistrar.php +++ b/src/ShortcodeRegistrar.php @@ -69,5 +69,8 @@ class ShortcodeRegistrar { wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-payment' ], USC_VERSION, true ); wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-payment' ], USC_VERSION, true ); + + // Progressive enhancement for the two-step registration form (no dependencies). + wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true ); } } diff --git a/templates/admin/questions.php b/templates/admin/questions.php index c9bae6d..102dd96 100644 --- a/templates/admin/questions.php +++ b/templates/admin/questions.php @@ -10,6 +10,8 @@ if (! defined('ABSPATH')) { /** * @var list<\Unsupervised\Schedular\Offering\Offering> $offeringList * @var \Unsupervised\Schedular\Offering\Offering|null $selectedOffering + * @var bool $accountScope Whether the studio-wide account-signup questions are selected. + * @var bool $manageAll Whether the current user is a studio admin (may edit account questions). * @var list<\Unsupervised\Schedular\Registration\Question>|null $questions */ ?> @@ -18,21 +20,31 @@ if (! defined('ABSPATH')) {
- +
- -

+ +

-

title)); ?>

+ +

+

+ +

title)); ?>

+

@@ -74,7 +86,7 @@ if (! defined('ABSPATH')) {

-

+

diff --git a/templates/admin/student-detail.php b/templates/admin/student-detail.php index fe58041..6a845dc 100644 --- a/templates/admin/student-detail.php +++ b/templates/admin/student-detail.php @@ -11,6 +11,7 @@ if (! defined('ABSPATH')) { * @var list $past * @var list $enrolments * @var list $acceptances + * @var list $registrationInfo * @var list $intake * @var list $payments * @var string $backUrl @@ -101,6 +102,33 @@ $renderLessons = static function (array $rows, bool $withActions = false): void +

+ +

+ +
+ + + + + + + + + + + + + + +
+ + + + +
+ +

diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php index fe0a891..6cce5a2 100644 --- a/templates/frontend/register-page.php +++ b/templates/frontend/register-page.php @@ -1,6 +1,8 @@ $policyForms + * @var list $accountQuestions Studio-wide questions answered as step two. */ + +/** + * Render one account-signup question's input, named `us_answers[]`. + */ +$renderQuestionField = static function (Question $question): void { + $id = (int) $question->id; + $name = 'us_answers[' . $id . ']'; + $fieldId = 'us-reg-q-' . $id; + $required = $question->isRequired ? ' required' : ''; + ?> +

+ + fieldType === Question::FIELD_TEXTAREA) : ?> + + fieldType === Question::FIELD_SELECT) : ?> + + fieldType === Question::FIELD_CHECKBOX) : ?> + > + + > + +

+
@@ -43,49 +80,73 @@ if (! defined('ABSPATH')) { - + + > -

- - isGroup()) : ?> - - - +

+

+ + isGroup()) : ?> + + + + +

+

+ + +

+

+ + +

+ + +
+ + +
+

title); ?>

+
body); ?>
+ +
+ +
-

-

- - -

-

- - -

- -
- - -
-

title); ?>

-
body); ?>
- -
- -
+ +

+ +

+ +

+ +

+ +
+ + +
+
+ + + + +
+

+ + +

+
- -

- -

diff --git a/tests/Unit/Auth/RegistrationPageTest.php b/tests/Unit/Auth/RegistrationPageTest.php index 43ba2dd..bf12412 100644 --- a/tests/Unit/Auth/RegistrationPageTest.php +++ b/tests/Unit/Auth/RegistrationPageTest.php @@ -15,6 +15,10 @@ use Unsupervised\Schedular\Policy\Policy; use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyVersion; use Unsupervised\Schedular\Policy\PolicyVersionRepository; +use Unsupervised\Schedular\Registration\Answer; +use Unsupervised\Schedular\Registration\AnswerRepository; +use Unsupervised\Schedular\Registration\Question; +use Unsupervised\Schedular\Registration\QuestionRepository; use Unsupervised\Schedular\Tests\Unit\TestCase; class RegistrationPageTest extends TestCase @@ -28,18 +32,26 @@ class RegistrationPageTest extends TestCase Functions\when('wp_unslash')->alias(static fn ($v) => $v); Functions\when('sanitize_text_field')->alias(static fn ($v) => $v); + Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v); Functions\when('sanitize_email')->alias(static fn ($v) => $v); + Functions\when('absint')->alias(static fn ($v) => (int) $v); Functions\when('current_time')->justReturn('2024-01-01 00:00:00'); - $invites = Mockery::mock(InviteRepository::class); - $policies = Mockery::mock(PolicyRepository::class); + $invites = Mockery::mock(InviteRepository::class); + $policies = Mockery::mock(PolicyRepository::class); + $questions = Mockery::mock(QuestionRepository::class); + $answers = Mockery::mock(AnswerRepository::class); $policies->shouldReceive('findForScope')->andReturn([])->byDefault(); + $questions->shouldReceive('findByScope')->andReturn([])->byDefault(); + $answers->shouldReceive('insert')->andReturn(1)->byDefault(); $this->ctx = [ - 'invites' => $invites, - 'policies' => $policies, - 'mailer' => Mockery::mock(RegistrationMailer::class), - 'settings' => Mockery::mock(StudioSettings::class), + 'invites' => $invites, + 'policies' => $policies, + 'questions' => $questions, + 'answers' => $answers, + 'mailer' => Mockery::mock(RegistrationMailer::class), + 'settings' => Mockery::mock(StudioSettings::class), ]; $this->ctx['page'] = new RegistrationPage( @@ -49,6 +61,8 @@ class RegistrationPageTest extends TestCase Mockery::mock(AcceptanceRepository::class), $this->ctx['settings'], $this->ctx['mailer'], + $questions, + $answers, ); $_POST = []; @@ -309,4 +323,58 @@ class RegistrationPageTest extends TestCase self::assertNotSame('confirm', $result); self::assertNotSame('', $result); } + + public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void + { + $_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; + + Functions\when('is_email')->justReturn(true); + + $question = new Question(null, 'Emergency contact', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 5); + $this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]); + + // A missing required answer must be caught before any account is created. + Functions\expect('wp_insert_user')->never(); + $this->ctx['answers']->shouldReceive('insert')->never(); + + $result = $this->submit(null, true); + + self::assertNotSame('confirm', $result); + self::assertNotSame('', $result); + } + + public function testRecordsAccountAnswersOnSuccess(): void + { + $_POST = [ + 'password' => 'password123', + 'display_name' => 'Ada', + 'us_answers' => [ '5' => 'By a friend' ], + ]; + + Functions\when('email_exists')->justReturn(false); + Functions\when('wp_insert_user')->justReturn(42); + Functions\when('is_wp_error')->justReturn(false); + Functions\expect('wp_set_current_user')->once(); + Functions\expect('wp_set_auth_cookie')->once(); + $this->ctx['invites']->shouldReceive('markAccepted')->once(); + + $question = new Question(null, 'How did you hear about us?', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 5); + $this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]); + + // The answer is written against the new user (account scope). + $this->ctx['answers']->shouldReceive('insert') + ->once() + ->with(Mockery::on(static function (Answer $answer): bool { + return $answer->registrationType === Answer::REG_ACCOUNT + && $answer->registrationId === 42 + && $answer->studentId === 42 + && $answer->questionId === 5 + && $answer->answerValue === 'By a friend'; + })) + ->andReturn(1); + + $invite = new Invite(email: 'a@b.test', token: 'hash'); + + self::assertSame('invite', $this->submit($invite, false)); + } } diff --git a/tests/Unit/Auth/StudentHistoryTest.php b/tests/Unit/Auth/StudentHistoryTest.php index eea4174..98dde2b 100644 --- a/tests/Unit/Auth/StudentHistoryTest.php +++ b/tests/Unit/Auth/StudentHistoryTest.php @@ -125,6 +125,44 @@ class StudentHistoryTest extends TestCase self::assertSame('—', $rows[0]['answer']); } + public function testIntakeAnswersExcludeAccountScopeAnswers(): void + { + $this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([ + new Answer(9, Answer::REG_ACCOUNT, 5, 5, 'By a friend', 2), + new Answer(4, Answer::REG_LESSON, 12, 5, 'Beginner', 1), + ]); + // Only the booking-scoped answer is resolved; the account answer is dropped. + $this->questions->shouldReceive('findById')->with(4) + ->andReturn(new Question(1, 'Experience level', id: 4)); + + $rows = $this->history->intakeAnswers(5); + + self::assertCount(1, $rows); + self::assertSame('Experience level', $rows[0]['question']); + self::assertSame('Lesson #12', $rows[0]['context']); + } + + public function testRegistrationInfoPairsAccountQuestionsWithAnswers(): void + { + $this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_ACCOUNT, 5)->andReturn([ + new Answer(4, Answer::REG_ACCOUNT, 5, 5, 'Yes', 1), + ]); + $this->questions->shouldReceive('findByScope')->once()->with(Question::SCOPE_ACCOUNT)->andReturn([ + new Question(null, 'Consent to email', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 4), + new Question(null, 'Anything else?', isRequired: false, scope: Question::SCOPE_ACCOUNT, id: 7), + ]); + + $rows = $this->history->registrationInfo(5); + + self::assertSame( + [ + [ 'question' => 'Consent to email', 'answer' => 'Yes', 'required' => true ], + [ 'question' => 'Anything else?', 'answer' => '—', 'required' => false ], + ], + $rows + ); + } + public function testPaymentsBuildDisplayRows(): void { $this->payments->shouldReceive('findByStudent')->once()->with(5)->andReturn([ diff --git a/tests/Unit/Registration/AnswerTest.php b/tests/Unit/Registration/AnswerTest.php index e3ba439..a4e941c 100644 --- a/tests/Unit/Registration/AnswerTest.php +++ b/tests/Unit/Registration/AnswerTest.php @@ -53,5 +53,15 @@ class AnswerTest extends TestCase { self::assertContains(Answer::REG_LESSON, Answer::VALID_REGISTRATION_TYPES); self::assertContains(Answer::REG_ENROLLMENT, Answer::VALID_REGISTRATION_TYPES); + self::assertContains(Answer::REG_ACCOUNT, Answer::VALID_REGISTRATION_TYPES); + } + + public function testAccountAnswerTargetsTheUser(): void + { + $answer = new Answer(3, Answer::REG_ACCOUNT, 42, 42, 'By a friend'); + + self::assertSame(Answer::REG_ACCOUNT, $answer->registrationType); + self::assertSame(42, $answer->registrationId); + self::assertSame(42, $answer->studentId); } } diff --git a/tests/Unit/Registration/QuestionRepositoryTest.php b/tests/Unit/Registration/QuestionRepositoryTest.php index 17e1d9a..996129d 100644 --- a/tests/Unit/Registration/QuestionRepositoryTest.php +++ b/tests/Unit/Registration/QuestionRepositoryTest.php @@ -115,6 +115,7 @@ class QuestionRepositoryTest extends TestCase $row = (object) [ 'id' => '3', 'offering_id' => '7', + 'scope' => Question::SCOPE_OFFERING, 'label' => 'Q', 'field_type' => Question::FIELD_TEXT, 'options' => null, @@ -132,6 +133,68 @@ class QuestionRepositoryTest extends TestCase self::assertInstanceOf(Question::class, $questions[0]); } + public function testInsertAccountQuestionStoresScopeAndNullOffering(): void + { + Functions\expect('current_time')->andReturn('2026-04-01 12:00:00'); + + $this->db->shouldReceive('insert') + ->once() + ->with( + 'wp_us_questions', + Mockery::on(static function (array $data): bool { + return $data['offering_id'] === null + && $data['scope'] === Question::SCOPE_ACCOUNT + && $data['label'] === 'Emergency contact'; + }), + Mockery::type('array') + ); + + $this->db->insert_id = 30; + + $question = new Question(null, 'Emergency contact', scope: Question::SCOPE_ACCOUNT); + + self::assertSame(30, $this->repo->insert($question)); + } + + public function testFindByScopeActiveOnlyPreparesQuery(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::pattern('/scope = %s AND is_active = %d/'), + Mockery::on(static fn (array $p): bool => $p === ['wp_us_questions', Question::SCOPE_ACCOUNT, 1]) + ) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_results')->andReturn([]); + + self::assertSame([], $this->repo->findByScope(Question::SCOPE_ACCOUNT, activeOnly: true)); + } + + public function testFindByScopeReturnsQuestions(): void + { + $row = (object) [ + 'id' => '5', + 'offering_id' => null, + 'scope' => Question::SCOPE_ACCOUNT, + 'label' => 'How did you hear about us?', + 'field_type' => Question::FIELD_TEXT, + 'options' => null, + 'is_required' => '1', + 'sort_order' => '0', + 'is_active' => '1', + ]; + + $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); + $this->db->shouldReceive('get_results')->andReturn([$row]); + + $questions = $this->repo->findByScope(Question::SCOPE_ACCOUNT); + + self::assertCount(1, $questions); + self::assertNull($questions[0]->offeringId); + self::assertSame(Question::SCOPE_ACCOUNT, $questions[0]->scope); + } + public function testFindByIdReturnsNullWhenNotFound(): void { $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); diff --git a/tests/Unit/Registration/QuestionTest.php b/tests/Unit/Registration/QuestionTest.php index 4fbb66b..353dcb4 100644 --- a/tests/Unit/Registration/QuestionTest.php +++ b/tests/Unit/Registration/QuestionTest.php @@ -19,14 +19,24 @@ class QuestionTest extends TestCase self::assertFalse($question->isRequired); self::assertSame(0, $question->sortOrder); self::assertTrue($question->isActive); + self::assertSame(Question::SCOPE_OFFERING, $question->scope); self::assertNull($question->id); } + public function testAccountScopeQuestionHasNoOffering(): void + { + $question = new Question(null, 'Emergency contact', scope: Question::SCOPE_ACCOUNT); + + self::assertNull($question->offeringId); + self::assertSame(Question::SCOPE_ACCOUNT, $question->scope); + } + public function testFromRowDecodesOptionsJson(): void { $row = (object) [ 'id' => '3', 'offering_id' => '7', + 'scope' => Question::SCOPE_OFFERING, 'label' => 'Pick a level', 'field_type' => Question::FIELD_SELECT, 'options' => '["Beginner","Advanced"]', @@ -42,6 +52,7 @@ class QuestionTest extends TestCase self::assertSame(['Beginner', 'Advanced'], $question->options); self::assertTrue($question->isRequired); self::assertSame(2, $question->sortOrder); + self::assertSame(Question::SCOPE_OFFERING, $question->scope); } public function testFromRowHandlesNullOptions(): void @@ -49,6 +60,7 @@ class QuestionTest extends TestCase $row = (object) [ 'id' => '4', 'offering_id' => '7', + 'scope' => Question::SCOPE_OFFERING, 'label' => 'Notes', 'field_type' => Question::FIELD_TEXTAREA, 'options' => null, @@ -63,12 +75,33 @@ class QuestionTest extends TestCase self::assertFalse($question->isActive); } + public function testFromRowHandlesAccountScopeWithNullOffering(): void + { + $row = (object) [ + 'id' => '5', + 'offering_id' => null, + 'scope' => Question::SCOPE_ACCOUNT, + 'label' => 'How did you hear about us?', + 'field_type' => Question::FIELD_TEXT, + 'options' => null, + 'is_required' => '1', + 'sort_order' => '0', + 'is_active' => '1', + ]; + + $question = Question::fromRow($row); + + self::assertNull($question->offeringId); + self::assertSame(Question::SCOPE_ACCOUNT, $question->scope); + self::assertTrue($question->isRequired); + } + public function testToArrayContainsExpectedKeys(): void { $question = new Question(7, 'Label', Question::FIELD_TEXT, id: 9); $arr = $question->toArray(); - foreach (['id', 'offering_id', 'label', 'field_type', 'options', 'is_required', 'sort_order', 'is_active'] as $key) { + foreach (['id', 'offering_id', 'scope', 'label', 'field_type', 'options', 'is_required', 'sort_order', 'is_active'] as $key) { self::assertArrayHasKey($key, $arr); } } @@ -80,4 +113,10 @@ class QuestionTest extends TestCase self::assertContains(Question::FIELD_SELECT, Question::VALID_FIELD_TYPES); self::assertContains(Question::FIELD_CHECKBOX, Question::VALID_FIELD_TYPES); } + + public function testValidScopeConstants(): void + { + self::assertContains(Question::SCOPE_OFFERING, Question::VALID_SCOPES); + self::assertContains(Question::SCOPE_ACCOUNT, Question::VALID_SCOPES); + } } diff --git a/unsupervised-schedular.php b/unsupervised-schedular.php index 62dead1..118bd55 100644 --- a/unsupervised-schedular.php +++ b/unsupervised-schedular.php @@ -3,7 +3,7 @@ * Plugin Name: Unsupervised Scheduler * Plugin URI: https://unsupervised.ca * Description: Instructor/student lesson scheduling for WordPress. - * Version: 1.0.0 + * Version: 1.1.0 * Requires at least: 6.2 * Requires PHP: 8.1 * Author: Unsupervised @@ -20,7 +20,7 @@ if (! defined('ABSPATH')) { exit; } -define('USC_VERSION', '1.0.0'); +define('USC_VERSION', '1.1.0'); define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));