CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m47s
CI / PHPStan (pull_request) Successful in 3m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Three bug fixes for the 1.2.1 section: - Fixed-size fields (question labels, offering titles/notes/e-transfer email, policy titles/slugs) no longer silently fail to save when the value exceeds its column length. The REST endpoints reject over-long values with a 400, the admin controllers refuse to insert them, and the form inputs carry a maxlength so the browser blocks over-long entry. Limits are MAX_* constants on the value objects, kept in lockstep with the schema columns. - Students are kept out of wp-admin entirely. New StudentAdminGuard redirects front-end-only users (no back-office capability) away from the dashboard and hides the admin bar for them, while administrators, studio admins, and instructors keep full access. - The Add/Edit Offering instructor picker now includes WordPress administrators when they act as instructors (the default single-account setup), so a solo studio owner is selectable instead of the dropdown being empty. composer test (618), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 4.8 <[email protected]>
141 lines
4.9 KiB
PHP
141 lines
4.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Registration;
|
|
|
|
use Unsupervised\Schedular\Auth\RoleManager;
|
|
use Unsupervised\Schedular\Offering\Offering;
|
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
|
use Unsupervised\Schedular\Val;
|
|
|
|
class QuestionController {
|
|
|
|
public function __construct(
|
|
private QuestionRepository $questions,
|
|
private OfferingRepository $offerings,
|
|
) {}
|
|
|
|
public function renderPage(): void {
|
|
if ( ! current_user_can( RoleManager::CAP_MANAGE_QUESTIONS ) ) {
|
|
wp_die( esc_html__( 'You do not have permission to manage questions.', 'unsupervised-schedular' ) );
|
|
}
|
|
|
|
$userId = get_current_user_id();
|
|
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
|
|
|
|
// 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;
|
|
|
|
if ( null !== $selectedOffering && ! $this->canManageOffering( $selectedOffering, $userId, $manageAll ) ) {
|
|
$selectedOffering = null;
|
|
}
|
|
|
|
$questions = null;
|
|
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 );
|
|
}
|
|
|
|
$questions = $this->questions->findByOffering( (int) $selectedOffering->id );
|
|
}
|
|
|
|
include USC_PLUGIN_DIR . 'templates/admin/questions.php';
|
|
}
|
|
|
|
/**
|
|
* 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( $offering );
|
|
}
|
|
|
|
if ( 'delete' === $action ) {
|
|
$questionId = absint( Val::int( $_POST['question_id'] ?? 0 ) );
|
|
if ( $questionId > 0 ) {
|
|
$question = $this->questions->findById( $questionId );
|
|
if ( $question && $this->belongsToContext( $question, $offering ) ) {
|
|
$this->questions->delete( $questionId );
|
|
}
|
|
}
|
|
}
|
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
|
}
|
|
|
|
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 ) ) );
|
|
|
|
if ( '' === $label || mb_strlen( $label ) > Question::MAX_LABEL_LENGTH || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
|
|
return;
|
|
}
|
|
|
|
$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,
|
|
)
|
|
);
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Parse a newline-separated textarea into a list of option strings.
|
|
*
|
|
* @return list<string>|null
|
|
*/
|
|
private function parseOptions( string $raw ): ?array {
|
|
$lines = preg_split( '/\r\n|\r|\n/', $raw );
|
|
$options = array_values(
|
|
array_filter(
|
|
array_map(
|
|
static fn( string $line ): string => sanitize_text_field( trim( $line ) ),
|
|
false === $lines ? [] : $lines
|
|
)
|
|
)
|
|
);
|
|
|
|
return [] === $options ? null : $options;
|
|
}
|
|
}
|