Add studio-defined account-registration questions
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m46s
CI / PHPStan (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m46s
CI / PHPStan (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Studio admins can now define registration questions that every new student answers as a required second step during signup, with each student's answers shown under a "Registration Information" section in the admin. Extends the existing Registration domain: us_questions gains a scope column (offering | account) and a nullable offering_id, and account answers reuse us_question_answers with registration_type = 'account'. Authoring reuses the Offerings -> Questions page via an "Account signup" scope (studio-admin only). The registration form becomes two steps (progressive enhancement via assets/js/register.js; works without JS); required answers are validated before the account is created and apply to all signup paths (invite, group link, self-approval). StudentHistory::registrationInfo() powers the admin section. Bumps the plugin version to 1.1.0 so dbDelta runs the schema migration. Closes #90 Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -9,6 +9,10 @@ use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class RegistrationPage {
|
||||
@@ -32,6 +36,8 @@ class RegistrationPage {
|
||||
private AcceptanceRepository $acceptances,
|
||||
private StudioSettings $settings,
|
||||
private RegistrationMailer $mailer,
|
||||
private QuestionRepository $questions,
|
||||
private AnswerRepository $answers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -76,8 +82,14 @@ class RegistrationPage {
|
||||
// Where the post-confirmation prompt sends students to sign in.
|
||||
$loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
|
||||
|
||||
$policyForms = $this->signupPolicies();
|
||||
$canRegister = $open || $inviteValid;
|
||||
$policyForms = $this->signupPolicies();
|
||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$canRegister = $open || $inviteValid;
|
||||
|
||||
// The two-step script only matters when there is a second step to reveal.
|
||||
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
|
||||
wp_enqueue_script( 'us-scheduler-register' );
|
||||
}
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
|
||||
@@ -156,6 +168,17 @@ class RegistrationPage {
|
||||
}
|
||||
}
|
||||
|
||||
// Account-signup questions (step two) — validate before creating the user so
|
||||
// a missing required answer never leaves a half-registered account behind.
|
||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$answers = $this->submittedAnswers();
|
||||
|
||||
foreach ( $accountQuestions as $question ) {
|
||||
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
||||
}
|
||||
}
|
||||
|
||||
if ( email_exists( $email ) ) {
|
||||
return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
|
||||
}
|
||||
@@ -175,6 +198,7 @@ class RegistrationPage {
|
||||
}
|
||||
|
||||
$this->recordAcceptances( $policyForms, (int) $userId );
|
||||
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
|
||||
|
||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
||||
@@ -228,6 +252,52 @@ class RegistrationPage {
|
||||
return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base );
|
||||
}
|
||||
|
||||
/**
|
||||
* The account-question answers submitted with the form, keyed by question id.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function submittedAnswers(): array {
|
||||
// The submit nonce is verified by the caller (render) before this runs.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized in the loop below.
|
||||
$raw = $_POST['us_answers'] ?? [];
|
||||
if ( ! is_array( $raw ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ( $raw as $questionId => $value ) {
|
||||
$out[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the submitted answers for each active account-signup question.
|
||||
*
|
||||
* @param list<Question> $questions
|
||||
* @param array<int, string> $answers question_id => submitted value
|
||||
*/
|
||||
private function recordAnswers( array $questions, array $answers, int $userId ): void {
|
||||
foreach ( $questions as $question ) {
|
||||
$value = trim( (string) ( $answers[ (int) $question->id ] ?? '' ) );
|
||||
if ( '' === $value ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->answers->insert(
|
||||
new Answer(
|
||||
questionId: (int) $question->id,
|
||||
registrationType: Answer::REG_ACCOUNT,
|
||||
registrationId: $userId,
|
||||
studentId: $userId,
|
||||
answerValue: $value,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record account-time acceptances for each signup policy version.
|
||||
*
|
||||
|
||||
@@ -145,9 +145,10 @@ class StudentController {
|
||||
$this->enrollments->findByStudent( (int) $student->ID )
|
||||
);
|
||||
|
||||
$acceptances = $this->history->policyAcceptances( (int) $student->ID );
|
||||
$intake = $this->history->intakeAnswers( (int) $student->ID );
|
||||
$payments = $canBilling ? $this->history->payments( (int) $student->ID ) : [];
|
||||
$acceptances = $this->history->policyAcceptances( (int) $student->ID );
|
||||
$registrationInfo = $this->history->registrationInfo( (int) $student->ID );
|
||||
$intake = $this->history->intakeAnswers( (int) $student->ID );
|
||||
$payments = $canBilling ? $this->history->payments( (int) $student->ID ) : [];
|
||||
|
||||
$backUrl = admin_url( 'admin.php?page=us-students' );
|
||||
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
|
||||
|
||||
@@ -11,6 +11,7 @@ use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
|
||||
/**
|
||||
@@ -51,22 +52,58 @@ class StudentHistory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every intake answer the student has submitted, newest registration first.
|
||||
* Booking/enrolment intake answers the student has submitted, newest first.
|
||||
* Account-signup answers are excluded — those are shown on their own under
|
||||
* {@see registrationInfo()}.
|
||||
*
|
||||
* @return list<array{question: string, answer: string, context: string}>
|
||||
*/
|
||||
public function intakeAnswers( int $studentId ): array {
|
||||
$bookingAnswers = array_filter(
|
||||
$this->answers->findByStudent( $studentId ),
|
||||
static fn( Answer $answer ): bool => Answer::REG_ACCOUNT !== $answer->registrationType
|
||||
);
|
||||
|
||||
return array_values(
|
||||
array_map(
|
||||
function ( Answer $answer ): array {
|
||||
$question = $this->questions->findById( $answer->questionId );
|
||||
|
||||
return [
|
||||
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
|
||||
'answer' => $answer->answerValue ?? '—',
|
||||
'context' => $this->contextLabel( $answer->registrationType, $answer->registrationId ),
|
||||
];
|
||||
},
|
||||
$bookingAnswers
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The student's answers to the studio-wide account-signup questions: every
|
||||
* configured account question paired with the student's answer ("—" when
|
||||
* unanswered, e.g. a question added after they registered).
|
||||
*
|
||||
* @return list<array{question: string, answer: string, required: bool}>
|
||||
*/
|
||||
public function registrationInfo( int $studentId ): array {
|
||||
$byQuestion = [];
|
||||
foreach ( $this->answers->findByRegistration( Answer::REG_ACCOUNT, $studentId ) as $answer ) {
|
||||
$byQuestion[ $answer->questionId ] = $answer->answerValue ?? '';
|
||||
}
|
||||
|
||||
return array_map(
|
||||
function ( Answer $answer ): array {
|
||||
$question = $this->questions->findById( $answer->questionId );
|
||||
static function ( Question $question ) use ( $byQuestion ): array {
|
||||
$value = $byQuestion[ (int) $question->id ] ?? '';
|
||||
|
||||
return [
|
||||
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
|
||||
'answer' => $answer->answerValue ?? '—',
|
||||
'context' => $this->contextLabel( $answer->registrationType, $answer->registrationId ),
|
||||
'question' => $question->label,
|
||||
'answer' => '' === $value ? '—' : $value,
|
||||
'required' => $question->isRequired,
|
||||
];
|
||||
},
|
||||
$this->answers->findByStudent( $studentId )
|
||||
$this->questions->findByScope( Question::SCOPE_ACCOUNT )
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ class Plugin {
|
||||
|
||||
$bookingPage = new BookingPage();
|
||||
$loginPage = new LoginPage();
|
||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer );
|
||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers );
|
||||
$groupClassPage = new GroupClassPage();
|
||||
|
||||
( new UpdateChecker() )->register();
|
||||
|
||||
@@ -9,13 +9,15 @@ class Answer {
|
||||
|
||||
public const REG_LESSON = 'lesson';
|
||||
public const REG_ENROLLMENT = 'enrollment';
|
||||
public const REG_ACCOUNT = 'account';
|
||||
|
||||
/**
|
||||
* Polymorphic registration targets an answer can attach to.
|
||||
* Polymorphic registration targets an answer can attach to. `account` is used
|
||||
* by studio-wide questions answered at signup (registration_id = the user ID).
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT ];
|
||||
public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT, self::REG_ACCOUNT ];
|
||||
|
||||
public function __construct(
|
||||
public readonly int $questionId,
|
||||
|
||||
@@ -12,6 +12,12 @@ class Question {
|
||||
public const FIELD_SELECT = 'select';
|
||||
public const FIELD_CHECKBOX = 'checkbox';
|
||||
|
||||
/** 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';
|
||||
|
||||
/**
|
||||
* All valid field types.
|
||||
*
|
||||
@@ -24,19 +30,31 @@ class Question {
|
||||
self::FIELD_CHECKBOX,
|
||||
];
|
||||
|
||||
/**
|
||||
* All valid scopes.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_SCOPES = [
|
||||
self::SCOPE_OFFERING,
|
||||
self::SCOPE_ACCOUNT,
|
||||
];
|
||||
|
||||
/**
|
||||
* Build an intake question value object.
|
||||
*
|
||||
* @param list<string>|null $options Choices for a `select` field.
|
||||
* @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 ?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 ?int $id = null,
|
||||
) {}
|
||||
|
||||
@@ -50,13 +68,14 @@ class Question {
|
||||
}
|
||||
|
||||
return new self(
|
||||
offeringId: Val::int( $row->offering_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 ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
@@ -70,6 +89,7 @@ class Question {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'offering_id' => $this->offeringId,
|
||||
'scope' => $this->scope,
|
||||
'label' => $this->label,
|
||||
'field_type' => $this->fieldType,
|
||||
'options' => $this->options,
|
||||
|
||||
@@ -23,8 +23,13 @@ class QuestionController {
|
||||
$userId = get_current_user_id();
|
||||
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only offering selector.
|
||||
$offeringId = absint( Val::int( $_GET['offering_id'] ?? 0 ) );
|
||||
// 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;
|
||||
|
||||
@@ -33,7 +38,13 @@ class QuestionController {
|
||||
}
|
||||
|
||||
$questions = null;
|
||||
if ( null !== $selectedOffering ) {
|
||||
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 );
|
||||
}
|
||||
@@ -44,20 +55,24 @@ class QuestionController {
|
||||
include USC_PLUGIN_DIR . 'templates/admin/questions.php';
|
||||
}
|
||||
|
||||
private function handleFormAction( Offering $offering ): void {
|
||||
/**
|
||||
* 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( (int) $offering->id );
|
||||
$this->addQuestion( $offering );
|
||||
}
|
||||
|
||||
if ( 'delete' === $action ) {
|
||||
$questionId = absint( Val::int( $_POST['question_id'] ?? 0 ) );
|
||||
if ( $questionId > 0 ) {
|
||||
$question = $this->questions->findById( $questionId );
|
||||
if ( $question && $question->offeringId === (int) $offering->id ) {
|
||||
if ( $question && $this->belongsToContext( $question, $offering ) ) {
|
||||
$this->questions->delete( $questionId );
|
||||
}
|
||||
}
|
||||
@@ -65,7 +80,7 @@ class QuestionController {
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
private function addQuestion( int $offeringId ): void {
|
||||
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 ) ) );
|
||||
@@ -76,17 +91,30 @@ class QuestionController {
|
||||
|
||||
$this->questions->insert(
|
||||
new Question(
|
||||
offeringId: $offeringId,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ class QuestionEndpoint {
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -167,8 +168,14 @@ class QuestionEndpoint {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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 ) {
|
||||
|
||||
@@ -15,7 +15,7 @@ class QuestionRepository {
|
||||
$this->db->insert(
|
||||
$this->table,
|
||||
$this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ],
|
||||
[ '%d', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ]
|
||||
[ '%d', '%s', '%s', '%s', '%s', '%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', '%d', '%d', '%d' ],
|
||||
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d' ],
|
||||
[ '%d' ]
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ 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 ),
|
||||
@@ -69,6 +70,27 @@ class QuestionRepository {
|
||||
return array_map( Question::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Find questions for a scope (e.g. account-signup), ordered for display.
|
||||
*
|
||||
* @return list<Question>
|
||||
*/
|
||||
public function findByScope( string $scope, bool $activeOnly = false ): array {
|
||||
$sql = 'SELECT * FROM %i WHERE scope = %s';
|
||||
$params = [ $this->table, $scope ];
|
||||
|
||||
if ( $activeOnly ) {
|
||||
$sql .= ' AND is_active = %d';
|
||||
$params[] = 1;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY sort_order ASC, id ASC';
|
||||
|
||||
$rows = $this->db->get_results( $this->db->prepare( $sql, $params ) );
|
||||
|
||||
return array_map( Question::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
public function findById( int $id ): ?Question {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
|
||||
|
||||
+3
-1
@@ -75,7 +75,8 @@ class Schema {
|
||||
|
||||
"CREATE TABLE {$prefix}us_questions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
offering_id BIGINT UNSIGNED NOT NULL,
|
||||
offering_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'offering',
|
||||
label VARCHAR(255) NOT NULL,
|
||||
field_type VARCHAR(20) NOT NULL DEFAULT 'text',
|
||||
options TEXT,
|
||||
@@ -85,6 +86,7 @@ class Schema {
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY offering_id (offering_id),
|
||||
KEY scope (scope),
|
||||
KEY is_active (is_active)
|
||||
) {$charset};",
|
||||
|
||||
|
||||
@@ -69,5 +69,8 @@ class ShortcodeRegistrar {
|
||||
|
||||
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
||||
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
||||
|
||||
// Progressive enhancement for the two-step registration form (no dependencies).
|
||||
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user