Ask some registration questions of students only
CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / Tests (PHP 8.1) (pull_request) Successful in 58s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / PHPStan (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped

Every account-signup question was asked of everybody who registered, on the
same terms: "school and grade" had to be put to an adult signing themselves
up, and a question a studio needed answered for each student could only be
made required by demanding it of everyone.

A question now carries an audience — everyone, or only the students someone
registers on behalf of — and its own required flag for each side, so optional
for you and required for every student you enrol is expressible. Both settings
are account-scope only: an offering asks its questions once, about the student
being booked, so there is no second audience to differ from, and an offering
question mirrors its single "required" into both columns.

Every caller reads askedOfSelf()/isRequiredForSelf()/isRequiredForChild()
rather than the raw flags, so a students-only question can neither block the
account holder nor have an answer filed against them by a crafted post. The
family screen, which only ever adds a student, is held to the students' rule.

is_required_child arrives from dbDelta defaulting to 0, which would quietly
stop every existing required question being required of the students a
guardian registers — the case it most likely existed for. A one-time backfill
copies is_required across, guarded by its own option so a question later made
optional for students stays that way.

Closes #163

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-07-30 13:51:52 -03:00
co-authored by Claude Opus 5
parent 84378e856b
commit 434fe801ba
20 changed files with 809 additions and 85 deletions
+18 -5
View File
@@ -319,6 +319,13 @@ class RegistrationPage {
// student themselves. "Both" is both.
$registeringFor = $this->submittedRegisteringFor();
// A "students only" question is never put to the account holder, so it is
// dropped before their answers are validated or stored — a crafted post
// cannot file one against them.
$selfQuestions = array_values(
array_filter( $accountQuestions, static fn( Question $question ): bool => $question->askedOfSelf() )
);
// "Students" and "both" collect student blocks; only "self" does not.
$isGuardian = self::FOR_SELF !== $registeringFor;
@@ -355,7 +362,7 @@ class RegistrationPage {
// Checked as two passes rather than one so the message can say *whose*
// answers are missing — under "both" a single message could not.
foreach ( array_column( $children, 'answers' ) as $set ) {
if ( $this->hasUnansweredRequired( $accountQuestions, $set ) ) {
if ( $this->hasUnansweredRequired( $accountQuestions, $set, forChild: true ) ) {
return esc_html__( 'Please answer all required registration questions for each student.', 'unsupervised-schedular' );
}
}
@@ -369,7 +376,7 @@ class RegistrationPage {
return esc_html( GuardianService::ownBirthYearError() );
}
if ( $asksSelf && $this->hasUnansweredRequired( $accountQuestions, $answers ) ) {
if ( $asksSelf && $this->hasUnansweredRequired( $selfQuestions, $answers ) ) {
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
}
@@ -420,7 +427,7 @@ class RegistrationPage {
// After the children, so a rollback that deletes this account cannot
// leave its answers behind pointing at a user that no longer exists.
if ( $asksSelf ) {
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
$this->recordAnswers( $selfQuestions, $answers, (int) $userId );
}
if ( $inviteValid && ! $invite->isGroup() ) {
@@ -564,12 +571,18 @@ class RegistrationPage {
/**
* Whether any required question in `$questions` is left blank in `$answers`.
*
* `$forChild` picks which required-ness applies: a question can be optional
* for the account holder answering about themselves and still required of
* every student they register.
*
* @param list<Question> $questions
* @param array<int, string> $answers
*/
private function hasUnansweredRequired( array $questions, array $answers ): bool {
private function hasUnansweredRequired( array $questions, array $answers, bool $forChild = false ): bool {
foreach ( $questions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
$required = $forChild ? $question->isRequiredForChild() : $question->isRequiredForSelf();
if ( $required && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
return true;
}
}
+5 -1
View File
@@ -162,12 +162,16 @@ class FamilyPage {
* The first required question left unanswered, as the error to show — or null
* when every required question has a value.
*
* This screen only ever adds a student the guardian registers, so the
* students' required-ness is the one that applies — the same rule the child
* blocks on the signup form are held to.
*
* @param list<Question> $questions
* @param array<int, string> $answers question_id => submitted value
*/
private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error {
foreach ( $questions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
if ( $question->isRequiredForChild() && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this student.', 'unsupervised-schedular' ) );
}
}
+9
View File
@@ -72,6 +72,15 @@ class Plugin {
update_option( 'us_questions_offering_nullable', '1' );
}
// One-time backfill of us_questions.is_required_child, which dbDelta adds
// defaulting to 0 — leaving every question that *was* required no longer
// required of the students a guardian registers. Runs after the version
// gate above, so the column it writes to exists. Guarded so a question
// later made optional for students stays that way.
if ( '1' !== get_option( 'us_questions_child_required_backfilled', '' ) && $questions->backfillChildRequired() ) {
update_option( 'us_questions_child_required_backfilled', '1' );
}
$answers = new AnswerRepository( $wpdb );
$policies = new PolicyRepository( $wpdb );
$policyVersions = new PolicyVersionRepository( $wpdb );
+86 -18
View File
@@ -21,6 +21,16 @@ class Question {
/** Question is studio-wide, asked once at account signup (no offering). */
public const SCOPE_ACCOUNT = 'account';
/** Asked of everyone: the account holder as a student, and each student they register. */
public const AUDIENCE_ALL = 'all';
/**
* Asked only of the students someone registers on behalf of — never of the
* account holder's own "About you" panel. For the questions that only make
* sense about a child ("school and grade", "who may collect them").
*/
public const AUDIENCE_CHILD = 'child';
/**
* All valid field types.
*
@@ -43,9 +53,29 @@ class Question {
self::SCOPE_ACCOUNT,
];
/**
* All valid audiences.
*
* @var list<string>
*/
public const VALID_AUDIENCES = [
self::AUDIENCE_ALL,
self::AUDIENCE_CHILD,
];
/**
* Build an intake question value object.
*
* `$isRequired` and `$isRequiredChild` are deliberately separate: a studio may
* want an answer from every student it enrols without demanding the same of an
* adult signing themselves up. Read them through {@see isRequiredForSelf()} and
* {@see isRequiredForChild()} rather than directly, so the audience is applied
* with them.
*
* Both `$audience` and `$isRequiredChild` are meaningless for offering scope,
* where a booking asks its questions once about the student being booked and
* there is no separate account-holder form to differ from.
*
* @param int|null $offeringId The owning offering, or null for account-scoped questions.
* @param list<string>|null $options Choices for a `select` field.
*/
@@ -58,9 +88,36 @@ class Question {
public readonly int $sortOrder = 0,
public readonly bool $isActive = true,
public readonly string $scope = self::SCOPE_OFFERING,
public readonly string $audience = self::AUDIENCE_ALL,
public readonly bool $isRequiredChild = false,
public readonly ?int $id = null,
) {}
/**
* Whether the account holder is asked this question in their own right — true
* for everything except a child-audience question.
*/
public function askedOfSelf(): bool {
return self::AUDIENCE_CHILD !== $this->audience;
}
/**
* Whether the account holder must answer before the form will submit. A
* child-audience question never reaches them, so it can never block them.
*/
public function isRequiredForSelf(): bool {
return $this->isRequired && $this->askedOfSelf();
}
/**
* Whether each student being registered must answer before the form will
* submit. Every question is asked in the student blocks whatever its audience,
* so this stands on its own.
*/
public function isRequiredForChild(): bool {
return $this->isRequiredChild;
}
public static function fromRow( \stdClass $row ): self {
$options = null;
if ( null !== $row->options && '' !== $row->options ) {
@@ -70,16 +127,25 @@ class Question {
: null;
}
// `audience` and `is_required_child` arrived after the table did, so a row
// read on a site whose dbDelta has not run yet simply lacks them: the
// pre-existing behaviour (asked of everyone, required of nobody in
// particular) is the right reading of a question authored before the
// distinction existed.
$audience = Val::string( $row->audience ?? '' );
return new self(
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 ),
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 ),
audience: in_array( $audience, self::VALID_AUDIENCES, true ) ? $audience : self::AUDIENCE_ALL,
isRequiredChild: Val::bool( $row->is_required_child ?? false ),
id: Val::int( $row->id ),
);
}
@@ -90,15 +156,17 @@ class Question {
*/
public function toArray(): array {
return [
'id' => $this->id,
'offering_id' => $this->offeringId,
'scope' => $this->scope,
'label' => $this->label,
'field_type' => $this->fieldType,
'options' => $this->options,
'is_required' => $this->isRequired,
'sort_order' => $this->sortOrder,
'is_active' => $this->isActive,
'id' => $this->id,
'offering_id' => $this->offeringId,
'scope' => $this->scope,
'label' => $this->label,
'field_type' => $this->fieldType,
'options' => $this->options,
'audience' => $this->audience,
'is_required' => $this->isRequired,
'is_required_child' => $this->isRequiredChild,
'sort_order' => $this->sortOrder,
'is_active' => $this->isActive,
];
}
}
+17 -7
View File
@@ -89,15 +89,25 @@ class QuestionController {
return;
}
// Audience and the students' own required-ness are asked for on the
// account-scope form only; an offering's questions are answered once about
// the student being booked, so there is no second audience to differ from.
// An offering question therefore mirrors its single "required" into both
// columns rather than storing a distinction it does not have.
$accountScope = null === $offering;
$audience = sanitize_key( Val::string( wp_unslash( $_POST['audience'] ?? '' ) ) );
$this->questions->insert(
new Question(
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,
offeringId: $accountScope ? 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: $accountScope ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING,
audience: $accountScope && in_array( $audience, Question::VALID_AUDIENCES, true ) ? $audience : Question::AUDIENCE_ALL,
isRequiredChild: $accountScope ? isset( $_POST['is_required_child'] ) : isset( $_POST['is_required'] ),
)
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
+30 -16
View File
@@ -88,14 +88,21 @@ class QuestionEndpoint {
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
}
$isRequired = (bool) $request->get_param( 'is_required' );
$question = new Question(
offeringId: $offeringId,
label: $label,
fieldType: $fieldType,
options: $this->sanitizeOptions( $request->get_param( 'options' ) ),
isRequired: (bool) $request->get_param( 'is_required' ),
sortOrder: Val::int( $request->get_param( 'sort_order' ) ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
offeringId: $offeringId,
label: $label,
fieldType: $fieldType,
options: $this->sanitizeOptions( $request->get_param( 'options' ) ),
isRequired: $isRequired,
sortOrder: Val::int( $request->get_param( 'sort_order' ) ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
// An offering asks its questions once, about the student being booked,
// so there is no second audience to differ from: the single "required"
// stands for both, the same way the upgrade backfill left every
// question authored before the two could differ.
isRequiredChild: $isRequired,
);
$id = $this->questions->insert( $question );
@@ -129,16 +136,23 @@ class QuestionEndpoint {
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
}
// Only offering-scope questions reach here — an account-scope one has no
// offering to own it and is turned away as not found above — so the same
// single "required" applies to everyone asked. See create().
$isRequired = $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired;
$question = new Question(
offeringId: $existing->offeringId,
label: $label,
fieldType: $fieldType,
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
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,
offeringId: $existing->offeringId,
label: $label,
fieldType: $fieldType,
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
isRequired: $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,
audience: $existing->audience,
isRequiredChild: $isRequired,
id: $id,
);
$this->questions->update( $id, $question );
+9 -3
View File
@@ -21,12 +21,18 @@ class QuestionField {
* the HTML attribute, for a block the browser must not block submission on
* because it may not apply at all — the child blocks, which only count when
* the parent/guardian box is ticked. The server validates those either way.
*
* `$isRequired` overrides which of the question's two required flags applies
* here — a question can be optional for the account holder and required for
* each student they register, and only the caller knows which block this is.
* Null falls back to the question's own {@see Question::$isRequired}.
*/
public static function render( Question $question, string $name, string $id, bool $enforceRequired = true ): string {
$required = $question->isRequired && $enforceRequired ? ' required' : '';
public static function render( Question $question, string $name, string $id, bool $enforceRequired = true, ?bool $isRequired = null ): string {
$mustAnswer = $isRequired ?? $question->isRequired;
$required = $mustAnswer && $enforceRequired ? ' required' : '';
$label = '<label for="' . esc_attr( $id ) . '">' . esc_html( $question->label )
. ( $question->isRequired ? ' <span class="us-required" aria-hidden="true">*</span>' : '' )
. ( $mustAnswer ? ' <span class="us-required" aria-hidden="true">*</span>' : '' )
. '</label>';
return '<p>' . $label . self::input( $question, $name, $id, $required ) . '</p>';
+38 -10
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', '%s', '%d', '%d', '%d', '%s' ]
[ '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%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', '%s', '%d', '%d', '%d' ],
[ '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d' ],
[ '%d' ]
);
}
@@ -38,14 +38,16 @@ 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 ),
'is_required' => $question->isRequired ? 1 : 0,
'sort_order' => $question->sortOrder,
'is_active' => $question->isActive ? 1 : 0,
'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 ),
'audience' => $question->audience,
'is_required' => $question->isRequired ? 1 : 0,
'is_required_child' => $question->isRequiredChild ? 1 : 0,
'sort_order' => $question->sortOrder,
'is_active' => $question->isActive ? 1 : 0,
];
}
@@ -128,4 +130,30 @@ class QuestionRepository {
return null !== $sql && false !== $this->db->query( $sql );
}
/**
* Give every question authored before students had a required-ness of their
* own the one it used to have.
*
* `is_required_child` arrives with `DEFAULT 0`, so without this a question the
* studio had marked required would quietly stop being required of the students
* a guardian registers — the case it most likely existed for. Copying
* `is_required` across preserves exactly the old behaviour: required of
* everyone, or of nobody.
*
* Run once, guarded by an option in {@see \Unsupervised\Schedular\Plugin::boot()},
* so a question deliberately made optional for students afterwards is not
* quietly made required again.
*
* @return bool True when the statement ran, false if it could not be prepared
* or the query failed.
*/
public function backfillChildRequired(): bool {
$sql = $this->db->prepare(
'UPDATE %i SET is_required_child = 1 WHERE is_required = 1',
$this->table
);
return null !== $sql && false !== $this->db->query( $sql );
}
}
+2
View File
@@ -85,7 +85,9 @@ class Schema {
label VARCHAR(255) NOT NULL,
field_type VARCHAR(20) NOT NULL DEFAULT 'text',
options TEXT,
audience VARCHAR(20) NOT NULL DEFAULT 'all',
is_required TINYINT(1) NOT NULL DEFAULT 0,
is_required_child TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,