\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\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|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 ); } }