CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 40s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / PHPStan (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
Sweep the translatable strings across the frontend templates, the admin screens, the editor previews and the block inserter entry. Nothing else moves: the database columns, request parameters, form field names, CSS classes, the us_family shortcode and the us-scheduler/family block name are contracts with existing installs and with post content people have already saved, so renaming them would break sites for no user-visible gain. Two strings are reworded rather than swapped, because the direct substitution reads wrong: - The students list said "Child of Jane" and now says "Managed by Jane". "Student of Jane" would read as a teacher's pupil, which is exactly the wrong idea in a music studio. - A managed account is now "a managed student account" rather than "a student account", which would not distinguish it from the account holder. The guardian feature doc gains a short section on the split, so the next person to work on it does not read the mismatch as drift and "fix" it. Closes #144 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 profile', '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 student.', '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 => __( 'Student added.', 'unsupervised-schedular' ),
|
|
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
|
|
self::RESULT_REMOVED => __( 'Student 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 );
|
|
}
|
|
}
|