CI / Tests (PHP 8.2) (pull_request) Successful in 51s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
`true` as a return type is PHP 8.2, but the plugin advertises 8.1, so the family screen's two service calls fataled on the 8.1 test job while every other job passed. They now return `?\WP_Error` — null on success — which matches RegistrationGate::validate() and works on 8.1. PHPStan was analysing against whatever PHP happened to be running (8.3 in CI, newer locally), so `composer lint` was green on syntax the plugin promises not to use. It is now pinned to the supported 8.1-8.3 range, which reproduces this failure at lint time instead of three jobs later. Co-Authored-By: Claude Opus 5 <[email protected]>
285 lines
9.5 KiB
PHP
285 lines
9.5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Guardian;
|
|
|
|
use Unsupervised\Schedular\Registration\Answer;
|
|
use Unsupervised\Schedular\Registration\AnswerRepository;
|
|
use Unsupervised\Schedular\Registration\Question;
|
|
use Unsupervised\Schedular\Registration\QuestionRepository;
|
|
use Unsupervised\Schedular\Val;
|
|
|
|
/**
|
|
* The guardian's "my family" screen (`[us_family]`): list, add, edit and remove
|
|
* the children they book for.
|
|
*
|
|
* Submissions are processed on `template_redirect` — before any output — and
|
|
* post/redirect/get back to the page, so a refresh cannot resubmit and add the
|
|
* same child twice.
|
|
*/
|
|
class FamilyPage {
|
|
|
|
/** Query flag carrying a completed action back to {@see render()}. */
|
|
private const RESULT_ADDED = 'added';
|
|
private const RESULT_UPDATED = 'updated';
|
|
private const RESULT_REMOVED = 'removed';
|
|
|
|
/**
|
|
* Error from the most recent submission processed on `template_redirect`,
|
|
* carried over to {@see render()} so it can be shown inline with the form.
|
|
*/
|
|
private string $submitError = '';
|
|
|
|
public function __construct(
|
|
private GuardianService $guardians,
|
|
private QuestionRepository $questions,
|
|
private AnswerRepository $answers,
|
|
) {}
|
|
|
|
/**
|
|
* Renders the family shortcode/block output.
|
|
*
|
|
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
|
|
* shortcode attributes (`login_page_id`).
|
|
*/
|
|
public function render( array $atts ): string {
|
|
if ( ! is_user_logged_in() ) {
|
|
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
|
|
|
|
return sprintf(
|
|
'<p>%s <a href="%s">%s</a>.</p>',
|
|
esc_html__( 'Please', 'unsupervised-schedular' ),
|
|
esc_url( $this->loginUrl( $loginPageId ) ),
|
|
esc_html__( 'log in to manage your family', 'unsupervised-schedular' )
|
|
);
|
|
}
|
|
|
|
wp_enqueue_style( 'us-scheduler' );
|
|
|
|
$userId = get_current_user_id();
|
|
|
|
$children = $this->guardians->children( $userId );
|
|
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
|
$error = $this->submitError;
|
|
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
|
|
$result = sanitize_key( Val::string( wp_unslash( $_GET['us_family'] ?? '' ) ) );
|
|
$notice = $this->noticeFor( $result );
|
|
|
|
// Which child the "edit" link opened, if any — the row is swapped for an
|
|
// editable form rather than every row carrying one.
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing; the edit submit is nonce-checked.
|
|
$editingId = absint( Val::int( $_GET['us_edit_child'] ?? 0 ) );
|
|
|
|
ob_start();
|
|
include USC_PLUGIN_DIR . 'templates/frontend/family-page.php';
|
|
return (string) ob_get_clean();
|
|
}
|
|
|
|
/**
|
|
* Process an add/edit/remove submission on `template_redirect`, before any
|
|
* page output, then post/redirect/get back to the page. An error is stashed
|
|
* for {@see render()} to show inline with the form.
|
|
*/
|
|
public function maybeHandleSubmit(): void {
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- routing only; the action is nonce-checked immediately below.
|
|
$action = sanitize_key( Val::string( wp_unslash( $_POST['us_family_action'] ?? '' ) ) );
|
|
|
|
if ( '' === $action || ! is_user_logged_in() ) {
|
|
return;
|
|
}
|
|
|
|
if ( ! check_admin_referer( 'us_family' ) ) {
|
|
return;
|
|
}
|
|
|
|
$userId = get_current_user_id();
|
|
|
|
$result = match ( $action ) {
|
|
'add' => $this->handleAdd( $userId ),
|
|
'edit' => $this->handleEdit( $userId ),
|
|
'remove' => $this->handleRemove( $userId ),
|
|
default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ),
|
|
};
|
|
|
|
if ( $result instanceof \WP_Error ) {
|
|
$this->submitError = $result->get_error_message();
|
|
return;
|
|
}
|
|
|
|
$this->redirect( add_query_arg( 'us_family', $result, $this->currentUrl() ) );
|
|
}
|
|
|
|
/**
|
|
* Add a child, then record their answers to the account-signup questions —
|
|
* asked per child, since they describe the student rather than the account.
|
|
*
|
|
* Required answers are validated *before* the child is created, so a missing
|
|
* one never leaves a nameless half-added child behind.
|
|
*/
|
|
private function handleAdd( int $guardianId ): string|\WP_Error {
|
|
$name = $this->postString( 'child_name' );
|
|
$dateOfBirth = $this->postString( 'child_dob' );
|
|
$relationship = $this->postString( 'child_relationship' );
|
|
|
|
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
|
$answers = $this->submittedAnswers();
|
|
|
|
$missing = $this->firstMissingAnswer( $questions, $answers );
|
|
if ( null !== $missing ) {
|
|
return $missing;
|
|
}
|
|
|
|
$childId = $this->guardians->createChild( $guardianId, $name, $dateOfBirth, $relationship );
|
|
if ( $childId instanceof \WP_Error ) {
|
|
return $childId;
|
|
}
|
|
|
|
$this->recordAnswers( $questions, $answers, $childId );
|
|
|
|
return self::RESULT_ADDED;
|
|
}
|
|
|
|
private function handleEdit( int $guardianId ): string|\WP_Error {
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
|
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
|
|
|
$error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) );
|
|
|
|
return $error ?? self::RESULT_UPDATED;
|
|
}
|
|
|
|
private function handleRemove( int $guardianId ): string|\WP_Error {
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
|
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
|
|
|
$error = $this->guardians->removeChild( $guardianId, $childId );
|
|
|
|
return $error ?? self::RESULT_REMOVED;
|
|
}
|
|
|
|
/**
|
|
* The first required question left unanswered, as the error to show — or null
|
|
* when every required question has a value.
|
|
*
|
|
* @param list<Question> $questions
|
|
* @param array<int, string> $answers question_id => submitted value
|
|
*/
|
|
private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error {
|
|
foreach ( $questions as $question ) {
|
|
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
|
return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this child.', 'unsupervised-schedular' ) );
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Persist a child's answers to the account-signup questions. The answer is
|
|
* recorded against the child, not the guardian, so a studio admin reading a
|
|
* child's screen sees the information that describes them.
|
|
*
|
|
* @param list<Question> $questions
|
|
* @param array<int, string> $answers question_id => submitted value
|
|
*/
|
|
private function recordAnswers( array $questions, array $answers, int $childId ): 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: $childId,
|
|
studentId: $childId,
|
|
answerValue: $value,
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The account-question answers submitted with the form, keyed by question id.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function submittedAnswers(): array {
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- nonce checked by the caller; 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;
|
|
}
|
|
|
|
/**
|
|
* A sanitized text field from the submission. The caller has already verified
|
|
* the nonce.
|
|
*/
|
|
private function postString( string $key ): string {
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
|
return sanitize_text_field( Val::string( wp_unslash( $_POST[ $key ] ?? '' ) ) );
|
|
}
|
|
|
|
/**
|
|
* The confirmation to show for a completed action, or an empty string when
|
|
* the flag is absent or unrecognised.
|
|
*/
|
|
private function noticeFor( string $result ): string {
|
|
return match ( $result ) {
|
|
self::RESULT_ADDED => __( 'Child added.', 'unsupervised-schedular' ),
|
|
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
|
|
self::RESULT_REMOVED => __( 'Child removed.', 'unsupervised-schedular' ),
|
|
default => '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The current page's clean permalink, used as the post/redirect/get target so
|
|
* the edit flag and any stale notice are dropped from the URL.
|
|
*/
|
|
private function currentUrl(): string {
|
|
$url = get_permalink();
|
|
|
|
return is_string( $url ) ? $url : home_url( '/' );
|
|
}
|
|
|
|
/**
|
|
* Issues the post-submit redirect and stops the request. Split out so tests
|
|
* can observe the target without the process exiting.
|
|
*/
|
|
protected function redirect( string $url ): void {
|
|
wp_safe_redirect( $url );
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* URL the logged-out prompt sends visitors to: the chosen login page when one
|
|
* is configured (and still exists), otherwise the WordPress login screen with
|
|
* a redirect back to the current page.
|
|
*/
|
|
public function loginUrl( int $loginPageId ): string {
|
|
if ( $loginPageId > 0 ) {
|
|
$url = get_permalink( $loginPageId );
|
|
|
|
if ( is_string( $url ) ) {
|
|
return $url;
|
|
}
|
|
}
|
|
|
|
$permalink = get_permalink();
|
|
|
|
return wp_login_url( false === $permalink ? '' : $permalink );
|
|
}
|
|
}
|