Let parents register once and book for their children

A parent registers once and manages lessons for one or more children, who
need no login of their own. A child is a real wp_users row with the student
role but no usable login — so student_id keeps meaning "a WordPress user"
on every table, and booking, credits, policies and enrolments work unchanged.
A us_guardians link table maps guardian to child.

The signup form gains a parent/guardian tick that reveals a block per child,
with the account-signup questions asked per child rather than per guardian
— they describe the student, not the account holder. Signup policies are
recorded once per child with the guardian as the acceptor, which is the
record that actually means something. A family that half-creates is rolled
back entirely rather than leaving a guardian who cannot re-register.

The booking and enrolment forms gain a "Who is this for?" picker listing
children first, so the default selection is never the parent — booking for
the wrong child is correctable, quietly billing a parent for their kid's
lesson is not. POST /bookings and POST /enrollments take an optional
student_id honoured only for that child's guardian; anything else is a 403.
That check is the authorisation boundary of the feature.

Payments and credits gain a payer: the charge names the child it was for and
the guardian who owes it, so per-child reporting is unchanged while notices,
receipts and the payment step reach the parent. Credit is held by the payer,
so one child's cancellation can settle a sibling's charge, and the daily
billing scan sends a guardian one notice covering every child.

Closes #132

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-07-29 16:07:52 -03:00
co-authored by Claude Opus 5
parent c25260a367
commit b772e1811e
71 changed files with 4192 additions and 191 deletions
+284
View File
@@ -0,0 +1,284 @@
<?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 ) );
$result = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) );
return $result instanceof \WP_Error ? $result : 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 ) );
$result = $this->guardians->removeChild( $guardianId, $childId );
return $result instanceof \WP_Error ? $result : 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 );
}
}