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]>
160 lines
4.8 KiB
PHP
160 lines
4.8 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Registration;
|
|
|
|
class QuestionRepository {
|
|
|
|
private string $table;
|
|
|
|
public function __construct( private \wpdb $db ) {
|
|
$this->table = $db->prefix . 'us_questions';
|
|
}
|
|
|
|
public function insert( Question $question ): int {
|
|
$this->db->insert(
|
|
$this->table,
|
|
$this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ],
|
|
[ '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s' ]
|
|
);
|
|
|
|
return $this->db->insert_id;
|
|
}
|
|
|
|
public function update( int $id, Question $question ): bool {
|
|
return false !== $this->db->update(
|
|
$this->table,
|
|
$this->columns( $question ),
|
|
[ 'id' => $id ],
|
|
[ '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d' ],
|
|
[ '%d' ]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Column values shared by insert and update (excludes created_at).
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
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 ),
|
|
'audience' => $question->audience,
|
|
'is_required' => $question->isRequired ? 1 : 0,
|
|
'is_required_child' => $question->isRequiredChild ? 1 : 0,
|
|
'sort_order' => $question->sortOrder,
|
|
'is_active' => $question->isActive ? 1 : 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Find questions for an offering, ordered for display.
|
|
*
|
|
* @return list<Question>
|
|
*/
|
|
public function findByOffering( int $offeringId, bool $activeOnly = false ): array {
|
|
$sql = 'SELECT * FROM %i WHERE offering_id = %d';
|
|
$params = [ $this->table, $offeringId ];
|
|
|
|
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 ?? [] );
|
|
}
|
|
|
|
/**
|
|
* 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 )
|
|
);
|
|
|
|
return $row ? Question::fromRow( $row ) : null;
|
|
}
|
|
|
|
public function delete( int $id ): bool {
|
|
return (bool) $this->db->delete(
|
|
$this->table,
|
|
[ 'id' => $id ],
|
|
[ '%d' ]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Relax `offering_id` to allow NULL for account-scope questions (which are
|
|
* not tied to an offering).
|
|
*
|
|
* The account-questions feature (v1.1.0) made the column nullable in the
|
|
* schema, but dbDelta does not reliably change a column from NOT NULL to
|
|
* NULL, so sites created before then keep the old NOT NULL column and reject
|
|
* account-scope inserts with "Column 'offering_id' cannot be null". This
|
|
* MODIFY is idempotent — re-applying the nullable definition is a no-op.
|
|
*
|
|
* @return bool True when the statement ran (or was already applied), false
|
|
* if it could not be prepared or the query failed.
|
|
*/
|
|
public function ensureOfferingNullable(): bool {
|
|
$sql = $this->db->prepare(
|
|
'ALTER TABLE %i MODIFY offering_id BIGINT UNSIGNED NULL DEFAULT NULL',
|
|
$this->table
|
|
);
|
|
|
|
return null !== $sql && false !== $this->db->query( $sql );
|
|
}
|
|
|
|
/**
|
|
* Give every question authored before students had a required-ness of their
|
|
* own the one it used to have.
|
|
*
|
|
* `is_required_child` arrives with `DEFAULT 0`, so without this a question the
|
|
* studio had marked required would quietly stop being required of the students
|
|
* a guardian registers — the case it most likely existed for. Copying
|
|
* `is_required` across preserves exactly the old behaviour: required of
|
|
* everyone, or of nobody.
|
|
*
|
|
* Run once, guarded by an option in {@see \Unsupervised\Schedular\Plugin::boot()},
|
|
* so a question deliberately made optional for students afterwards is not
|
|
* quietly made required again.
|
|
*
|
|
* @return bool True when the statement ran, false if it could not be prepared
|
|
* or the query failed.
|
|
*/
|
|
public function backfillChildRequired(): bool {
|
|
$sql = $this->db->prepare(
|
|
'UPDATE %i SET is_required_child = 1 WHERE is_required = 1',
|
|
$this->table
|
|
);
|
|
|
|
return null !== $sql && false !== $this->db->query( $sql );
|
|
}
|
|
}
|