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:
@@ -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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user