Files
unsupervised-scheduler/src/Guardian/FamilyPage.php
T
thatguygriffandClaude Opus 5 9ac62a6021 Collect a birth year instead of a full date of birth
Signup and the profile page now ask for a four-digit year between 1900 and
the current year. Anything else — a short year, a full date, a year in the
future — is discarded rather than stored, so a typo cannot leave a nonsense
age on the record.

The year lives in a new us_birth_year user meta rather than reusing
us_date_of_birth, which would have left one key holding two formats. The old
key is not migrated in bulk. Instead GuardianService handles it in two
halves: birthYear() falls back to the year of the old date when the new key
is absent, so a student added before this change still shows one, and
setBirthYear() deletes the old date on every save.

That deletion is what makes the fallback safe rather than merely tidy.
Without it, clearing the birth year on a student who predates the change
would leave the old date behind for the fallback to read straight back, and
the year could never be cleared at all.

Stored in user meta, so no Schema.php change and no USC_VERSION bump.

Closes #147

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 20:46:27 -03:00

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' );
$birthYear = $this->postString( 'child_birth_year' );
$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, $birthYear, $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_birth_year' ) );
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 );
}
}