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]>
258 lines
8.8 KiB
PHP
258 lines
8.8 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Registration;
|
|
|
|
use Unsupervised\Schedular\Auth\RoleManager;
|
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
|
use Unsupervised\Schedular\Val;
|
|
|
|
class QuestionEndpoint {
|
|
|
|
public function __construct(
|
|
private QuestionRepository $questions,
|
|
private OfferingRepository $offerings,
|
|
) {}
|
|
|
|
/**
|
|
* Registers this endpoint's REST routes.
|
|
*
|
|
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
|
|
*/
|
|
public function registerRoutes( string $route_namespace ): void {
|
|
register_rest_route(
|
|
$route_namespace,
|
|
'/offerings/(?P<id>\d+)/questions',
|
|
[
|
|
[
|
|
'methods' => \WP_REST_Server::READABLE,
|
|
'callback' => [ $this, 'index' ],
|
|
'permission_callback' => [ $this, 'canBook' ],
|
|
],
|
|
]
|
|
);
|
|
|
|
register_rest_route(
|
|
$route_namespace,
|
|
'/questions',
|
|
[
|
|
[
|
|
'methods' => \WP_REST_Server::CREATABLE,
|
|
'callback' => [ $this, 'create' ],
|
|
'permission_callback' => [ $this, 'canManage' ],
|
|
],
|
|
]
|
|
);
|
|
|
|
register_rest_route(
|
|
$route_namespace,
|
|
'/questions/(?P<id>\d+)',
|
|
[
|
|
[
|
|
'methods' => \WP_REST_Server::EDITABLE,
|
|
'callback' => [ $this, 'update' ],
|
|
'permission_callback' => [ $this, 'canManage' ],
|
|
],
|
|
[
|
|
'methods' => \WP_REST_Server::DELETABLE,
|
|
'callback' => [ $this, 'delete' ],
|
|
'permission_callback' => [ $this, 'canManage' ],
|
|
],
|
|
]
|
|
);
|
|
}
|
|
|
|
public function index( \WP_REST_Request $request ): \WP_REST_Response {
|
|
$questions = $this->questions->findByOffering( absint( Val::int( $request->get_param( 'id' ) ) ), activeOnly: true );
|
|
|
|
return new \WP_REST_Response( array_map( fn( Question $q ) => $q->toArray(), $questions ), 200 );
|
|
}
|
|
|
|
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
|
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
|
|
$ownerCheck = $this->requireOfferingOwner( $offeringId );
|
|
if ( $ownerCheck instanceof \WP_Error ) {
|
|
return $ownerCheck;
|
|
}
|
|
|
|
$label = sanitize_text_field( Val::string( $request->get_param( 'label' ) ) );
|
|
if ( '' === $label ) {
|
|
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
|
|
}
|
|
if ( mb_strlen( $label ) > Question::MAX_LABEL_LENGTH ) {
|
|
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
|
|
}
|
|
|
|
$fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
|
|
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
|
|
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: $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 );
|
|
|
|
return new \WP_REST_Response( [ 'id' => $id ], 201 );
|
|
}
|
|
|
|
public function update( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
|
$id = absint( Val::int( $request->get_param( 'id' ) ) );
|
|
$existing = $this->questions->findById( $id );
|
|
|
|
if ( null === $existing ) {
|
|
return new \WP_Error( 'not_found', __( 'Question not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
|
}
|
|
|
|
$ownerCheck = $this->requireOfferingOwner( $existing->offeringId );
|
|
if ( $ownerCheck instanceof \WP_Error ) {
|
|
return $ownerCheck;
|
|
}
|
|
|
|
$fieldType = $request->has_param( 'field_type' ) ? Val::string( $request->get_param( 'field_type' ) ) : $existing->fieldType;
|
|
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
|
|
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
|
|
}
|
|
|
|
$label = $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label;
|
|
if ( '' === $label ) {
|
|
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
|
|
}
|
|
if ( mb_strlen( $label ) > Question::MAX_LABEL_LENGTH ) {
|
|
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: $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 );
|
|
|
|
return new \WP_REST_Response( $question->toArray(), 200 );
|
|
}
|
|
|
|
public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
|
$id = absint( Val::int( $request->get_param( 'id' ) ) );
|
|
$existing = $this->questions->findById( $id );
|
|
|
|
if ( null === $existing ) {
|
|
return new \WP_Error( 'not_found', __( 'Question not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
|
}
|
|
|
|
$ownerCheck = $this->requireOfferingOwner( $existing->offeringId );
|
|
if ( $ownerCheck instanceof \WP_Error ) {
|
|
return $ownerCheck;
|
|
}
|
|
|
|
$this->questions->delete( $id );
|
|
|
|
return new \WP_REST_Response( null, 204 );
|
|
}
|
|
|
|
public function canManage(): bool {
|
|
return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_QUESTIONS );
|
|
}
|
|
|
|
/**
|
|
* An offering's registration questions are only read by the logged-in student
|
|
* booking/enrolment flow, so reading them requires the booking capability —
|
|
* there is no anonymous consumer.
|
|
*/
|
|
public function canBook(): bool {
|
|
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
if ( null === $offeringId ) {
|
|
return new \WP_Error( 'not_found', __( 'Question not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
|
}
|
|
|
|
$offering = $this->offerings->findById( $offeringId );
|
|
|
|
if ( null === $offering ) {
|
|
return new \WP_Error( 'not_found', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
|
}
|
|
|
|
$ownsOrManagesAll = get_current_user_id() === $offering->instructorId
|
|
|| current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
|
|
|
|
if ( ! $ownsOrManagesAll ) {
|
|
return new \WP_Error( 'forbidden', __( 'You cannot manage questions for this offering.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Normalise a submitted options array into a clean list of strings.
|
|
*
|
|
* @return list<string>|null
|
|
*/
|
|
private function sanitizeOptions( mixed $value ): ?array {
|
|
if ( ! is_array( $value ) || [] === $value ) {
|
|
return null;
|
|
}
|
|
|
|
$options = array_values(
|
|
array_filter(
|
|
array_map(
|
|
static fn( mixed $option ): string => sanitize_text_field( Val::string( $option ) ),
|
|
$value
|
|
)
|
|
)
|
|
);
|
|
|
|
return [] === $options ? null : $options;
|
|
}
|
|
|
|
private function invalid( string $message ): \WP_Error {
|
|
return new \WP_Error( 'invalid_question', $message, [ 'status' => 400 ] );
|
|
}
|
|
|
|
/**
|
|
* Build a uniform "too long" validation message for a named field.
|
|
*/
|
|
private function tooLongMessage( string $field, int $max ): string {
|
|
return sprintf(
|
|
/* translators: 1: field name, 2: maximum character count. */
|
|
__( 'The %1$s must be %2$d characters or fewer.', 'unsupervised-schedular' ),
|
|
$field,
|
|
$max
|
|
);
|
|
}
|
|
}
|