Add studio-defined account-registration questions
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m46s
CI / PHPStan (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped

Studio admins can now define registration questions that every new student
answers as a required second step during signup, with each student's answers
shown under a "Registration Information" section in the admin.

Extends the existing Registration domain: us_questions gains a scope column
(offering | account) and a nullable offering_id, and account answers reuse
us_question_answers with registration_type = 'account'. Authoring reuses the
Offerings -> Questions page via an "Account signup" scope (studio-admin only).
The registration form becomes two steps (progressive enhancement via
assets/js/register.js; works without JS); required answers are validated before
the account is created and apply to all signup paths (invite, group link,
self-approval). StudentHistory::registrationInfo() powers the admin section.

Bumps the plugin version to 1.1.0 so dbDelta runs the schema migration.

Closes #90

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-07-23 09:59:59 -03:00
co-authored by Claude Opus 4.8
parent 34e8f660ab
commit 49c59a950c
23 changed files with 702 additions and 98 deletions
+57
View File
@@ -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]);
}
});
})();
+9
View File
@@ -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 | | `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) | | `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 ## Policy Acceptance Scope
Policies declare **when** they must be accepted via `us_policies.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 `signup`, `booking`, or `both` (see `policies.md`). The registration form requires
+42 -15
View File
@@ -1,19 +1,30 @@
# Feature: Registration Questions # Feature: Registration Questions
## Overview ## 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` ## Data Model — `{prefix}us_questions`
| Column | Type | Notes | | Column | Type | Notes |
|---------------|------------------|-------------------------------------------------------------| |---------------|------------------|-------------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key | | `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 | | `label` | VARCHAR(255) | The question text shown to the registrant |
| `field_type` | VARCHAR(20) | `text` / `textarea` / `select` / `checkbox` | | `field_type` | VARCHAR(20) | `text` / `textarea` / `select` / `checkbox` |
| `options` | TEXT | JSON array of choices (for `select`); NULL otherwise | | `options` | TEXT | JSON array of choices (for `select`); NULL otherwise |
| `is_required` | TINYINT(1) | 1 = registrant must answer to continue | | `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 | | `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form |
| `created_at` | DATETIME | Insertion time | | `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 | | `id` | BIGINT UNSIGNED | Primary key |
| `question_id` | BIGINT UNSIGNED | FK → `us_questions.id` | | `question_id` | BIGINT UNSIGNED | FK → `us_questions.id` |
| `registration_type` | VARCHAR(20) | `lesson` or `enrollment` | | `registration_type` | VARCHAR(20) | `lesson`, `enrollment`, or `account` |
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.id` | | `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) | | `student_id` | BIGINT UNSIGNED | WordPress user ID (denormalised for fast lookup) |
| `answer_value` | TEXT | The submitted answer (checkbox stored as `0`/`1`) | | `answer_value` | TEXT | The submitted answer (checkbox stored as `0`/`1`) |
| `created_at` | DATETIME | Insertion time | | `created_at` | DATETIME | Insertion time |
The `registration_type` + `registration_id` pair is a polymorphic reference shared The `registration_type` + `registration_id` pair is a polymorphic reference shared
with `us_policy_acceptances` (see `policies.md`), letting answers attach to either a with `us_policy_acceptances` (see `policies.md`), letting answers attach to a private
private lesson or a group enrolment. lesson, a group enrolment, or an account signup (`account` + the user ID).
## Flow ## Offering-scope Flow
1. On the registration form, the front-end calls `GET /offerings/{id}/questions`. 1. On the booking form, the front-end calls `GET /offerings/{id}/questions`.
2. Required questions block submission until answered. 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. 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 ## Admin Interface
Questions are edited from each offering's screen (**Offerings → Questions**). Both scopes are edited from **Offerings → Questions** (`Registration\QuestionController`):
- Studio admin (`manage_questions`) edits questions on any offering. - Pick an offering to edit its questions, or **"Account signup (all registrations)"** for the account-scope questions.
- Instructor (`manage_questions`) edits questions only on their own offerings. - 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 ## 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 | | Method | Endpoint | Permission |
|----------|---------------------------------------------------|----------------------| |----------|---------------------------------------------------|----------------------|
| `GET` | `/wp-json/us-scheduler/v1/offerings/{id}/questions`| Public | | `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 | | `DELETE` | `/wp-json/us-scheduler/v1/questions/{id}` | `manage_questions` + owner |
## Implementation ## Implementation
- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository`, `Unsupervised\Schedular\Registration\AnswerRepository` - Repositories: `Unsupervised\Schedular\Registration\QuestionRepository` (`findByOffering`, `findByScope`), `Unsupervised\Schedular\Registration\AnswerRepository`
- Models: `Unsupervised\Schedular\Registration\Question`, `Unsupervised\Schedular\Registration\Answer` - Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`)
- Admin controller: `Unsupervised\Schedular\Registration\QuestionController` - 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
- `tests/Unit/Registration/QuestionRepositoryTest.php` - `tests/Unit/Registration/QuestionRepositoryTest.php`
- `tests/Unit/Registration/AnswerRepositoryTest.php` - `tests/Unit/Registration/AnswerRepositoryTest.php`
- `tests/Unit/Registration/QuestionTest.php` - `tests/Unit/Registration/QuestionTest.php`
- `tests/Unit/Registration/AnswerTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php`
- `tests/Unit/Auth/StudentHistoryTest.php`
+70
View File
@@ -9,6 +9,10 @@ use Unsupervised\Schedular\Policy\Policy;
use Unsupervised\Schedular\Policy\PolicyAcceptance; use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository; 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; use Unsupervised\Schedular\Val;
class RegistrationPage { class RegistrationPage {
@@ -32,6 +36,8 @@ class RegistrationPage {
private AcceptanceRepository $acceptances, private AcceptanceRepository $acceptances,
private StudioSettings $settings, private StudioSettings $settings,
private RegistrationMailer $mailer, private RegistrationMailer $mailer,
private QuestionRepository $questions,
private AnswerRepository $answers,
) {} ) {}
/** /**
@@ -77,8 +83,14 @@ class RegistrationPage {
$loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) ); $loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
$policyForms = $this->signupPolicies(); $policyForms = $this->signupPolicies();
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$canRegister = $open || $inviteValid; $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(); ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php'; include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
return (string) ob_get_clean(); return (string) ob_get_clean();
@@ -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 ) ) { if ( email_exists( $email ) ) {
return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' ); return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
} }
@@ -175,6 +198,7 @@ class RegistrationPage {
} }
$this->recordAcceptances( $policyForms, (int) $userId ); $this->recordAcceptances( $policyForms, (int) $userId );
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
if ( $inviteValid && ! $invite->isGroup() ) { if ( $inviteValid && ! $invite->isGroup() ) {
$this->invites->markAccepted( (int) $invite->id, (int) $userId ); $this->invites->markAccepted( (int) $invite->id, (int) $userId );
@@ -228,6 +252,52 @@ class RegistrationPage {
return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base ); return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base );
} }
/**
* The account-question answers submitted with the form, keyed by question id.
*
* @return array<int, string>
*/
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<Question> $questions
* @param array<int, string> $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. * Record account-time acceptances for each signup policy version.
* *
+1
View File
@@ -146,6 +146,7 @@ class StudentController {
); );
$acceptances = $this->history->policyAcceptances( (int) $student->ID ); $acceptances = $this->history->policyAcceptances( (int) $student->ID );
$registrationInfo = $this->history->registrationInfo( (int) $student->ID );
$intake = $this->history->intakeAnswers( (int) $student->ID ); $intake = $this->history->intakeAnswers( (int) $student->ID );
$payments = $canBilling ? $this->history->payments( (int) $student->ID ) : []; $payments = $canBilling ? $this->history->payments( (int) $student->ID ) : [];
+40 -3
View File
@@ -11,6 +11,7 @@ use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository; use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\Answer; use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository; use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionRepository; use Unsupervised\Schedular\Registration\QuestionRepository;
/** /**
@@ -51,12 +52,20 @@ 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<array{question: string, answer: string, context: string}> * @return list<array{question: string, answer: string, context: string}>
*/ */
public function intakeAnswers( int $studentId ): array { public function intakeAnswers( int $studentId ): array {
return array_map( $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 { function ( Answer $answer ): array {
$question = $this->questions->findById( $answer->questionId ); $question = $this->questions->findById( $answer->questionId );
@@ -66,7 +75,35 @@ class StudentHistory {
'context' => $this->contextLabel( $answer->registrationType, $answer->registrationId ), 'context' => $this->contextLabel( $answer->registrationType, $answer->registrationId ),
]; ];
}, },
$this->answers->findByStudent( $studentId ) $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<array{question: string, answer: string, required: bool}>
*/
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(
static function ( Question $question ) use ( $byQuestion ): array {
$value = $byQuestion[ (int) $question->id ] ?? '';
return [
'question' => $question->label,
'answer' => '' === $value ? '—' : $value,
'required' => $question->isRequired,
];
},
$this->questions->findByScope( Question::SCOPE_ACCOUNT )
); );
} }
+1 -1
View File
@@ -72,7 +72,7 @@ class Plugin {
$bookingPage = new BookingPage(); $bookingPage = new BookingPage();
$loginPage = new LoginPage(); $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(); $groupClassPage = new GroupClassPage();
( new UpdateChecker() )->register(); ( new UpdateChecker() )->register();
+4 -2
View File
@@ -9,13 +9,15 @@ class Answer {
public const REG_LESSON = 'lesson'; public const REG_LESSON = 'lesson';
public const REG_ENROLLMENT = 'enrollment'; 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<string> * @var list<string>
*/ */
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 function __construct(
public readonly int $questionId, public readonly int $questionId,
+22 -2
View File
@@ -12,6 +12,12 @@ class Question {
public const FIELD_SELECT = 'select'; public const FIELD_SELECT = 'select';
public const FIELD_CHECKBOX = 'checkbox'; 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. * All valid field types.
* *
@@ -24,19 +30,31 @@ class Question {
self::FIELD_CHECKBOX, self::FIELD_CHECKBOX,
]; ];
/**
* All valid scopes.
*
* @var list<string>
*/
public const VALID_SCOPES = [
self::SCOPE_OFFERING,
self::SCOPE_ACCOUNT,
];
/** /**
* Build an intake question value object. * Build an intake question value object.
* *
* @param int|null $offeringId The owning offering, or null for account-scoped questions.
* @param list<string>|null $options Choices for a `select` field. * @param list<string>|null $options Choices for a `select` field.
*/ */
public function __construct( public function __construct(
public readonly int $offeringId, public readonly ?int $offeringId,
public readonly string $label, public readonly string $label,
public readonly string $fieldType = self::FIELD_TEXT, public readonly string $fieldType = self::FIELD_TEXT,
public readonly ?array $options = null, public readonly ?array $options = null,
public readonly bool $isRequired = false, public readonly bool $isRequired = false,
public readonly int $sortOrder = 0, public readonly int $sortOrder = 0,
public readonly bool $isActive = true, public readonly bool $isActive = true,
public readonly string $scope = self::SCOPE_OFFERING,
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
@@ -50,13 +68,14 @@ class Question {
} }
return new self( return new self(
offeringId: Val::int( $row->offering_id ), offeringId: Val::intOrNull( $row->offering_id ),
label: Val::string( $row->label ), label: Val::string( $row->label ),
fieldType: Val::string( $row->field_type ), fieldType: Val::string( $row->field_type ),
options: $options, options: $options,
isRequired: Val::bool( $row->is_required ), isRequired: Val::bool( $row->is_required ),
sortOrder: Val::int( $row->sort_order ), sortOrder: Val::int( $row->sort_order ),
isActive: Val::bool( $row->is_active ), isActive: Val::bool( $row->is_active ),
scope: Val::string( $row->scope ),
id: Val::int( $row->id ), id: Val::int( $row->id ),
); );
} }
@@ -70,6 +89,7 @@ class Question {
return [ return [
'id' => $this->id, 'id' => $this->id,
'offering_id' => $this->offeringId, 'offering_id' => $this->offeringId,
'scope' => $this->scope,
'label' => $this->label, 'label' => $this->label,
'field_type' => $this->fieldType, 'field_type' => $this->fieldType,
'options' => $this->options, 'options' => $this->options,
+36 -8
View File
@@ -23,8 +23,13 @@ class QuestionController {
$userId = get_current_user_id(); $userId = get_current_user_id();
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS ); $manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only offering selector. // The selector posts either an offering id or the sentinel `account`.
$offeringId = absint( Val::int( $_GET['offering_id'] ?? 0 ) ); // 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 ); $offeringList = $manageAll ? $this->offerings->findAll() : $this->offerings->findAll( $userId );
$selectedOffering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null; $selectedOffering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
@@ -33,7 +38,13 @@ class QuestionController {
} }
$questions = null; $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' ) ) { if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_question_action' ) ) {
$this->handleFormAction( $selectedOffering ); $this->handleFormAction( $selectedOffering );
} }
@@ -44,20 +55,24 @@ class QuestionController {
include USC_PLUGIN_DIR . 'templates/admin/questions.php'; 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. // Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) { if ( 'add' === $action ) {
$this->addQuestion( (int) $offering->id ); $this->addQuestion( $offering );
} }
if ( 'delete' === $action ) { if ( 'delete' === $action ) {
$questionId = absint( Val::int( $_POST['question_id'] ?? 0 ) ); $questionId = absint( Val::int( $_POST['question_id'] ?? 0 ) );
if ( $questionId > 0 ) { if ( $questionId > 0 ) {
$question = $this->questions->findById( $questionId ); $question = $this->questions->findById( $questionId );
if ( $question && $question->offeringId === (int) $offering->id ) { if ( $question && $this->belongsToContext( $question, $offering ) ) {
$this->questions->delete( $questionId ); $this->questions->delete( $questionId );
} }
} }
@@ -65,7 +80,7 @@ class QuestionController {
// phpcs:enable WordPress.Security.NonceVerification.Missing // phpcs:enable WordPress.Security.NonceVerification.Missing
} }
private function addQuestion( int $offeringId ): void { private function addQuestion( ?Offering $offering ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) ); $label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) );
$fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) ); $fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) );
@@ -76,17 +91,30 @@ class QuestionController {
$this->questions->insert( $this->questions->insert(
new Question( new Question(
offeringId: $offeringId, offeringId: null === $offering ? null : (int) $offering->id,
label: $label, label: $label,
fieldType: $fieldType, fieldType: $fieldType,
options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ), options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ),
isRequired: isset( $_POST['is_required'] ), isRequired: isset( $_POST['is_required'] ),
sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ), sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ),
scope: null === $offering ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING,
) )
); );
// phpcs:enable WordPress.Security.NonceVerification.Missing // 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 { private function canManageOffering( Offering $offering, int $userId, bool $manageAll ): bool {
return $manageAll || $offering->instructorId === $userId; return $manageAll || $offering->instructorId === $userId;
} }
+8 -1
View File
@@ -126,6 +126,7 @@ class QuestionEndpoint {
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired, 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, 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, isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
scope: $existing->scope,
id: $id, id: $id,
); );
@@ -167,8 +168,14 @@ class QuestionEndpoint {
/** /**
* Ensure the offering exists and the caller owns it (or is a studio admin). * 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 ); $offering = $this->offerings->findById( $offeringId );
if ( null === $offering ) { if ( null === $offering ) {
+24 -2
View File
@@ -15,7 +15,7 @@ class QuestionRepository {
$this->db->insert( $this->db->insert(
$this->table, $this->table,
$this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ], $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; return $this->db->insert_id;
@@ -26,7 +26,7 @@ class QuestionRepository {
$this->table, $this->table,
$this->columns( $question ), $this->columns( $question ),
[ 'id' => $id ], [ 'id' => $id ],
[ '%d', '%s', '%s', '%s', '%d', '%d', '%d' ], [ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d' ],
[ '%d' ] [ '%d' ]
); );
} }
@@ -39,6 +39,7 @@ class QuestionRepository {
private function columns( Question $question ): array { private function columns( Question $question ): array {
return [ return [
'offering_id' => $question->offeringId, 'offering_id' => $question->offeringId,
'scope' => $question->scope,
'label' => $question->label, 'label' => $question->label,
'field_type' => $question->fieldType, 'field_type' => $question->fieldType,
'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ), 'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ),
@@ -69,6 +70,27 @@ class QuestionRepository {
return array_map( Question::fromRow( ... ), $rows ?? [] ); return array_map( Question::fromRow( ... ), $rows ?? [] );
} }
/**
* Find questions for a scope (e.g. account-signup), ordered for display.
*
* @return list<Question>
*/
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 { public function findById( int $id ): ?Question {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
+3 -1
View File
@@ -75,7 +75,8 @@ class Schema {
"CREATE TABLE {$prefix}us_questions ( "CREATE TABLE {$prefix}us_questions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, 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, label VARCHAR(255) NOT NULL,
field_type VARCHAR(20) NOT NULL DEFAULT 'text', field_type VARCHAR(20) NOT NULL DEFAULT 'text',
options TEXT, options TEXT,
@@ -85,6 +86,7 @@ class Schema {
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
KEY offering_id (offering_id), KEY offering_id (offering_id),
KEY scope (scope),
KEY is_active (is_active) KEY is_active (is_active)
) {$charset};", ) {$charset};",
+3
View File
@@ -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', 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 ); 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 );
} }
} }
+18 -6
View File
@@ -10,6 +10,8 @@ if (! defined('ABSPATH')) {
/** /**
* @var list<\Unsupervised\Schedular\Offering\Offering> $offeringList * @var list<\Unsupervised\Schedular\Offering\Offering> $offeringList
* @var \Unsupervised\Schedular\Offering\Offering|null $selectedOffering * @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 * @var list<\Unsupervised\Schedular\Registration\Question>|null $questions
*/ */
?> ?>
@@ -18,21 +20,31 @@ if (! defined('ABSPATH')) {
<form method="get"> <form method="get">
<input type="hidden" name="page" value="us-questions"> <input type="hidden" name="page" value="us-questions">
<label for="offering_id"><?php esc_html_e('Offering', 'unsupervised-schedular'); ?></label> <label for="offering_id"><?php esc_html_e('Questions for', 'unsupervised-schedular'); ?></label>
<select name="offering_id" id="offering_id" onchange="this.form.submit()"> <select name="offering_id" id="offering_id" onchange="this.form.submit()">
<option value="0"><?php esc_html_e('— Select an offering —', 'unsupervised-schedular'); ?></option> <option value="0"><?php esc_html_e('— Select —', 'unsupervised-schedular'); ?></option>
<?php if ($manageAll) : ?>
<option value="<?php echo esc_attr(Question::SCOPE_ACCOUNT); ?>" <?php selected($accountScope); ?>>
<?php esc_html_e('Account signup (all registrations)', 'unsupervised-schedular'); ?>
</option>
<?php endif; ?>
<?php foreach ($offeringList as $offering) : ?> <?php foreach ($offeringList as $offering) : ?>
<option value="<?php echo esc_attr((string) $offering->id); ?>" <?php selected($selectedOffering && $selectedOffering->id === $offering->id); ?>> <option value="<?php echo esc_attr((string) $offering->id); ?>" <?php selected(! $accountScope && $selectedOffering && $selectedOffering->id === $offering->id); ?>>
<?php echo esc_html($offering->title); ?> <?php echo esc_html($offering->title); ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</form> </form>
<?php if (null === $selectedOffering) : ?> <?php if (! $accountScope && null === $selectedOffering) : ?>
<p><?php esc_html_e('Choose an offering to manage its intake questions.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('Choose an offering to manage its intake questions, or "Account signup" for the questions every new student answers when registering.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<?php if ($accountScope) : ?>
<h2><?php esc_html_e('Account signup questions', 'unsupervised-schedular'); ?></h2>
<p><?php esc_html_e('Every new student answers these required-if-marked questions as a second step after choosing their name and password.', 'unsupervised-schedular'); ?></p>
<?php else : ?> <?php else : ?>
<h2><?php echo esc_html(sprintf(/* translators: %s: offering title */ __('Questions for "%s"', 'unsupervised-schedular'), $selectedOffering->title)); ?></h2> <h2><?php echo esc_html(sprintf(/* translators: %s: offering title */ __('Questions for "%s"', 'unsupervised-schedular'), $selectedOffering->title)); ?></h2>
<?php endif; ?>
<h3><?php esc_html_e('Add Question', 'unsupervised-schedular'); ?></h3> <h3><?php esc_html_e('Add Question', 'unsupervised-schedular'); ?></h3>
<form method="post"> <form method="post">
@@ -74,7 +86,7 @@ if (! defined('ABSPATH')) {
<h3><?php esc_html_e('Current Questions', 'unsupervised-schedular'); ?></h3> <h3><?php esc_html_e('Current Questions', 'unsupervised-schedular'); ?></h3>
<?php if (empty($questions)) : ?> <?php if (empty($questions)) : ?>
<p><?php esc_html_e('No questions configured for this offering.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('No questions configured yet.', 'unsupervised-schedular'); ?></p>
<?php else : ?> <?php else : ?>
<table class="wp-list-table widefat fixed striped"> <table class="wp-list-table widefat fixed striped">
<thead> <thead>
+28
View File
@@ -11,6 +11,7 @@ if (! defined('ABSPATH')) {
* @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past * @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
* @var list<array{id: int, offering: string, status: string}> $enrolments * @var list<array{id: int, offering: string, status: string}> $enrolments
* @var list<array{policy: string, version: string, context: string, accepted_at: string}> $acceptances * @var list<array{policy: string, version: string, context: string, accepted_at: string}> $acceptances
* @var list<array{question: string, answer: string, required: bool}> $registrationInfo
* @var list<array{question: string, answer: string, context: string}> $intake * @var list<array{question: string, answer: string, context: string}> $intake
* @var list<array{created_at: string, context: string, method: string, status: string, amount: float, tax_amount: float, total: float, currency: string, receipt: string}> $payments * @var list<array{created_at: string, context: string, method: string, status: string, amount: float, tax_amount: float, total: float, currency: string, receipt: string}> $payments
* @var string $backUrl * @var string $backUrl
@@ -101,6 +102,33 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
<?php submit_button(esc_html__('Save account details', 'unsupervised-schedular'), 'secondary', 'submit', false); ?> <?php submit_button(esc_html__('Save account details', 'unsupervised-schedular'), 'secondary', 'submit', false); ?>
</form> </form>
<h2><?php esc_html_e('Registration Information', 'unsupervised-schedular'); ?></h2>
<?php if (empty($registrationInfo)) : ?>
<p><?php esc_html_e('No registration questions are configured.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($registrationInfo as $row) : ?>
<tr>
<td>
<?php echo esc_html($row['question']); ?>
<?php if ($row['required']) : ?>
<span class="us-required" aria-hidden="true">*</span>
<?php endif; ?>
</td>
<td><?php echo esc_html($row['answer']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($canBilling) : ?> <?php if ($canBilling) : ?>
<h2><?php esc_html_e('Billing method', 'unsupervised-schedular'); ?></h2> <h2><?php esc_html_e('Billing method', 'unsupervised-schedular'); ?></h2>
<form method="post"> <form method="post">
+62 -1
View File
@@ -1,6 +1,8 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use Unsupervised\Schedular\Registration\Question;
if (! defined('ABSPATH')) { if (! defined('ABSPATH')) {
exit; exit;
} }
@@ -16,7 +18,42 @@ if (! defined('ABSPATH')) {
* @var string $loginUrl Where the post-confirmation sign-in link points. * @var string $loginUrl Where the post-confirmation sign-in link points.
* @var string $error * @var string $error
* @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms * @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
* @var list<Question> $accountQuestions Studio-wide questions answered as step two.
*/ */
/**
* Render one account-signup question's input, named `us_answers[<id>]`.
*/
$renderQuestionField = static function (Question $question): void {
$id = (int) $question->id;
$name = 'us_answers[' . $id . ']';
$fieldId = 'us-reg-q-' . $id;
$required = $question->isRequired ? ' required' : '';
?>
<p>
<label for="<?php echo esc_attr($fieldId); ?>">
<?php echo esc_html($question->label); ?>
<?php if ($question->isRequired) : ?>
<span class="us-required" aria-hidden="true">*</span>
<?php endif; ?>
</label>
<?php if ($question->fieldType === Question::FIELD_TEXTAREA) : ?>
<textarea name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>" rows="4"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>></textarea>
<?php elseif ($question->fieldType === Question::FIELD_SELECT) : ?>
<select name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>>
<option value=""><?php esc_html_e('— Select —', 'unsupervised-schedular'); ?></option>
<?php foreach ((array) $question->options as $option) : ?>
<option value="<?php echo esc_attr((string) $option); ?>"><?php echo esc_html((string) $option); ?></option>
<?php endforeach; ?>
</select>
<?php elseif ($question->fieldType === Question::FIELD_CHECKBOX) : ?>
<input type="checkbox" name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>" value="1"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>>
<?php else : ?>
<input type="text" name="<?php echo esc_attr($name); ?>" id="<?php echo esc_attr($fieldId); ?>"<?php echo $required; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- literal attribute string. ?>>
<?php endif; ?>
</p>
<?php
};
?> ?>
<div class="us-register-form"> <div class="us-register-form">
<?php if ($successType === 'invite') : ?> <?php if ($successType === 'invite') : ?>
@@ -43,10 +80,12 @@ if (! defined('ABSPATH')) {
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p> <p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
<?php endif; ?> <?php endif; ?>
<form method="post" action=""> <?php $hasQuestions = ! empty($accountQuestions); ?>
<form method="post" action="" <?php echo $hasQuestions ? 'data-steps="1"' : ''; ?>>
<?php wp_nonce_field('us_student_register'); ?> <?php wp_nonce_field('us_student_register'); ?>
<input type="hidden" name="us_invite" value="<?php echo esc_attr($token); ?>"> <input type="hidden" name="us_invite" value="<?php echo esc_attr($token); ?>">
<div class="us-reg-step" data-step="1">
<p> <p>
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label> <label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
<?php if ($inviteValid && $invite !== null && ! $invite->isGroup()) : ?> <?php if ($inviteValid && $invite !== null && ! $invite->isGroup()) : ?>
@@ -83,9 +122,31 @@ if (! defined('ABSPATH')) {
</fieldset> </fieldset>
<?php endif; ?> <?php endif; ?>
<?php if ($hasQuestions) : ?>
<p>
<button type="button" class="us-reg-next"><?php esc_html_e('Next', 'unsupervised-schedular'); ?></button>
</p>
<?php else : ?>
<p> <p>
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>"> <input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
</p> </p>
<?php endif; ?>
</div>
<?php if ($hasQuestions) : ?>
<div class="us-reg-step" data-step="2">
<fieldset class="us-reg-questions">
<legend><?php esc_html_e('Registration information', 'unsupervised-schedular'); ?></legend>
<?php foreach ($accountQuestions as $question) : ?>
<?php $renderQuestionField($question); ?>
<?php endforeach; ?>
</fieldset>
<p>
<button type="button" class="us-reg-back"><?php esc_html_e('Back', 'unsupervised-schedular'); ?></button>
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
</p>
</div>
<?php endif; ?>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
+68
View File
@@ -15,6 +15,10 @@ use Unsupervised\Schedular\Policy\Policy;
use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersion; use Unsupervised\Schedular\Policy\PolicyVersion;
use Unsupervised\Schedular\Policy\PolicyVersionRepository; 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; use Unsupervised\Schedular\Tests\Unit\TestCase;
class RegistrationPageTest extends TestCase class RegistrationPageTest extends TestCase
@@ -28,16 +32,24 @@ class RegistrationPageTest extends TestCase
Functions\when('wp_unslash')->alias(static fn ($v) => $v); Functions\when('wp_unslash')->alias(static fn ($v) => $v);
Functions\when('sanitize_text_field')->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('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'); Functions\when('current_time')->justReturn('2024-01-01 00:00:00');
$invites = Mockery::mock(InviteRepository::class); $invites = Mockery::mock(InviteRepository::class);
$policies = Mockery::mock(PolicyRepository::class); $policies = Mockery::mock(PolicyRepository::class);
$questions = Mockery::mock(QuestionRepository::class);
$answers = Mockery::mock(AnswerRepository::class);
$policies->shouldReceive('findForScope')->andReturn([])->byDefault(); $policies->shouldReceive('findForScope')->andReturn([])->byDefault();
$questions->shouldReceive('findByScope')->andReturn([])->byDefault();
$answers->shouldReceive('insert')->andReturn(1)->byDefault();
$this->ctx = [ $this->ctx = [
'invites' => $invites, 'invites' => $invites,
'policies' => $policies, 'policies' => $policies,
'questions' => $questions,
'answers' => $answers,
'mailer' => Mockery::mock(RegistrationMailer::class), 'mailer' => Mockery::mock(RegistrationMailer::class),
'settings' => Mockery::mock(StudioSettings::class), 'settings' => Mockery::mock(StudioSettings::class),
]; ];
@@ -49,6 +61,8 @@ class RegistrationPageTest extends TestCase
Mockery::mock(AcceptanceRepository::class), Mockery::mock(AcceptanceRepository::class),
$this->ctx['settings'], $this->ctx['settings'],
$this->ctx['mailer'], $this->ctx['mailer'],
$questions,
$answers,
); );
$_POST = []; $_POST = [];
@@ -309,4 +323,58 @@ class RegistrationPageTest extends TestCase
self::assertNotSame('confirm', $result); self::assertNotSame('confirm', $result);
self::assertNotSame('', $result); self::assertNotSame('', $result);
} }
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
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: '[email protected]', token: 'hash');
self::assertSame('invite', $this->submit($invite, false));
}
} }
+38
View File
@@ -125,6 +125,44 @@ class StudentHistoryTest extends TestCase
self::assertSame('—', $rows[0]['answer']); 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 public function testPaymentsBuildDisplayRows(): void
{ {
$this->payments->shouldReceive('findByStudent')->once()->with(5)->andReturn([ $this->payments->shouldReceive('findByStudent')->once()->with(5)->andReturn([
+10
View File
@@ -53,5 +53,15 @@ class AnswerTest extends TestCase
{ {
self::assertContains(Answer::REG_LESSON, Answer::VALID_REGISTRATION_TYPES); self::assertContains(Answer::REG_LESSON, Answer::VALID_REGISTRATION_TYPES);
self::assertContains(Answer::REG_ENROLLMENT, 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);
} }
} }
@@ -115,6 +115,7 @@ class QuestionRepositoryTest extends TestCase
$row = (object) [ $row = (object) [
'id' => '3', 'id' => '3',
'offering_id' => '7', 'offering_id' => '7',
'scope' => Question::SCOPE_OFFERING,
'label' => 'Q', 'label' => 'Q',
'field_type' => Question::FIELD_TEXT, 'field_type' => Question::FIELD_TEXT,
'options' => null, 'options' => null,
@@ -132,6 +133,68 @@ class QuestionRepositoryTest extends TestCase
self::assertInstanceOf(Question::class, $questions[0]); 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 public function testFindByIdReturnsNullWhenNotFound(): void
{ {
$this->db->shouldReceive('prepare')->andReturn('SELECT ...'); $this->db->shouldReceive('prepare')->andReturn('SELECT ...');
+40 -1
View File
@@ -19,14 +19,24 @@ class QuestionTest extends TestCase
self::assertFalse($question->isRequired); self::assertFalse($question->isRequired);
self::assertSame(0, $question->sortOrder); self::assertSame(0, $question->sortOrder);
self::assertTrue($question->isActive); self::assertTrue($question->isActive);
self::assertSame(Question::SCOPE_OFFERING, $question->scope);
self::assertNull($question->id); 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 public function testFromRowDecodesOptionsJson(): void
{ {
$row = (object) [ $row = (object) [
'id' => '3', 'id' => '3',
'offering_id' => '7', 'offering_id' => '7',
'scope' => Question::SCOPE_OFFERING,
'label' => 'Pick a level', 'label' => 'Pick a level',
'field_type' => Question::FIELD_SELECT, 'field_type' => Question::FIELD_SELECT,
'options' => '["Beginner","Advanced"]', 'options' => '["Beginner","Advanced"]',
@@ -42,6 +52,7 @@ class QuestionTest extends TestCase
self::assertSame(['Beginner', 'Advanced'], $question->options); self::assertSame(['Beginner', 'Advanced'], $question->options);
self::assertTrue($question->isRequired); self::assertTrue($question->isRequired);
self::assertSame(2, $question->sortOrder); self::assertSame(2, $question->sortOrder);
self::assertSame(Question::SCOPE_OFFERING, $question->scope);
} }
public function testFromRowHandlesNullOptions(): void public function testFromRowHandlesNullOptions(): void
@@ -49,6 +60,7 @@ class QuestionTest extends TestCase
$row = (object) [ $row = (object) [
'id' => '4', 'id' => '4',
'offering_id' => '7', 'offering_id' => '7',
'scope' => Question::SCOPE_OFFERING,
'label' => 'Notes', 'label' => 'Notes',
'field_type' => Question::FIELD_TEXTAREA, 'field_type' => Question::FIELD_TEXTAREA,
'options' => null, 'options' => null,
@@ -63,12 +75,33 @@ class QuestionTest extends TestCase
self::assertFalse($question->isActive); 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 public function testToArrayContainsExpectedKeys(): void
{ {
$question = new Question(7, 'Label', Question::FIELD_TEXT, id: 9); $question = new Question(7, 'Label', Question::FIELD_TEXT, id: 9);
$arr = $question->toArray(); $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); 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_SELECT, Question::VALID_FIELD_TYPES);
self::assertContains(Question::FIELD_CHECKBOX, 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);
}
} }
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler * Plugin Name: Unsupervised Scheduler
* Plugin URI: https://unsupervised.ca * Plugin URI: https://unsupervised.ca
* Description: Instructor/student lesson scheduling for WordPress. * Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.0.0 * Version: 1.1.0
* Requires at least: 6.2 * Requires at least: 6.2
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: Unsupervised * Author: Unsupervised
@@ -20,7 +20,7 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
define('USC_VERSION', '1.0.0'); define('USC_VERSION', '1.1.0');
define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));