Files
unsupervised-scheduler/src/Registration/Question.php
T
thatguygriffandClaude Opus 5 434fe801ba
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
Ask some registration questions of students only
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]>
2026-07-30 13:51:52 -03:00

173 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Registration;
use Unsupervised\Schedular\Val;
class Question {
public const FIELD_TEXT = 'text';
public const FIELD_TEXTAREA = 'textarea';
public const FIELD_SELECT = 'select';
public const FIELD_CHECKBOX = 'checkbox';
/** Maximum length of a question label, matching the `label` VARCHAR(255) column. */
public const MAX_LABEL_LENGTH = 255;
/** 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';
/** 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.
*
* @var list<string>
*/
public const VALID_FIELD_TYPES = [
self::FIELD_TEXT,
self::FIELD_TEXTAREA,
self::FIELD_SELECT,
self::FIELD_CHECKBOX,
];
/**
* All valid scopes.
*
* @var list<string>
*/
public const VALID_SCOPES = [
self::SCOPE_OFFERING,
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.
*/
public function __construct(
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 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 ) {
$decoded = json_decode( Val::string( $row->options ), true );
$options = is_array( $decoded )
? array_values( array_map( static fn( mixed $v ): string => Val::string( $v ), $decoded ) )
: 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 ),
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 ),
);
}
/**
* Returns a plain array representation of the question.
*
* @return array<string, mixed>
*/
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,
'audience' => $this->audience,
'is_required' => $this->isRequired,
'is_required_child' => $this->isRequiredChild,
'sort_order' => $this->sortOrder,
'is_active' => $this->isActive,
];
}
}