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
+4 -2
View File
@@ -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<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 readonly int $questionId,
+23 -3
View File
@@ -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<string>
*/
public const VALID_SCOPES = [
self::SCOPE_OFFERING,
self::SCOPE_ACCOUNT,
];
/**
* Build an intake question value object.
*
* @param list<string>|null $options Choices for a `select` field.
* @param int|null $offeringId The owning offering, or null for account-scoped questions.
* @param list<string>|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,
+36 -8
View File
@@ -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;
}
+8 -1
View File
@@ -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 ) {
+24 -2
View File
@@ -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<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 {
$row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )