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:
+134
-11
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
@@ -47,6 +48,7 @@ class RegistrationPage {
|
||||
private QuestionRepository $questions,
|
||||
private AnswerRepository $answers,
|
||||
private GroupAccessRepository $access,
|
||||
private GuardianService $guardians,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -117,8 +119,10 @@ class RegistrationPage {
|
||||
// gate, so it needs the plugin stylesheet that formats it.
|
||||
wp_enqueue_style( 'us-scheduler' );
|
||||
|
||||
// The two-step script only matters when there is a second step to reveal.
|
||||
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
|
||||
// The script drives both the second step and the parent/guardian section
|
||||
// (revealing it, and cloning the child block for "add another"), so it is
|
||||
// needed whenever the form itself is on screen.
|
||||
if ( $canRegister && '' === $successType ) {
|
||||
wp_enqueue_script( 'us-scheduler-register' );
|
||||
}
|
||||
|
||||
@@ -270,14 +274,28 @@ class RegistrationPage {
|
||||
}
|
||||
}
|
||||
|
||||
// Account-signup questions (step two) — validate before creating the user so
|
||||
// a missing required answer never leaves a half-registered account behind.
|
||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$answers = $this->submittedAnswers();
|
||||
|
||||
foreach ( $accountQuestions as $question ) {
|
||||
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
||||
// Registering as a parent/guardian turns the account-signup questions from
|
||||
// "about you" into "about each child" — they describe the student
|
||||
// (instrument, level, school), not the person holding the account.
|
||||
$isGuardian = $this->submittedIsGuardian();
|
||||
$children = $isGuardian ? $this->submittedChildren() : [];
|
||||
$answers = $isGuardian ? [] : $this->submittedAnswers();
|
||||
|
||||
// Everything is validated before a single user is created, so a bad child
|
||||
// block never leaves a half-registered family behind.
|
||||
if ( $isGuardian && [] === $children ) {
|
||||
return esc_html__( 'Please add at least one child, or uncheck the parent/guardian option.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
foreach ( $isGuardian ? array_column( $children, 'answers' ) : [ $answers ] as $set ) {
|
||||
foreach ( $accountQuestions as $question ) {
|
||||
if ( $question->isRequired && '' === trim( (string) ( $set[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return $isGuardian
|
||||
? esc_html__( 'Please answer all required registration questions for each child.', 'unsupervised-schedular' )
|
||||
: esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,8 +317,16 @@ class RegistrationPage {
|
||||
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$this->recordAcceptances( $policyForms, (int) $userId );
|
||||
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
|
||||
$this->recordAcceptances( $policyForms, (int) $userId, (int) $userId );
|
||||
|
||||
if ( $isGuardian ) {
|
||||
$failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId );
|
||||
if ( '' !== $failure ) {
|
||||
return $failure;
|
||||
}
|
||||
} else {
|
||||
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
|
||||
}
|
||||
|
||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
||||
@@ -440,6 +466,96 @@ class RegistrationPage {
|
||||
return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the "I'm registering as a parent or guardian" box was ticked.
|
||||
*/
|
||||
private function submittedIsGuardian(): bool {
|
||||
// The submit nonce is verified by the caller before this runs.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
return '1' === sanitize_text_field( Val::string( wp_unslash( $_POST['us_is_guardian'] ?? '' ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The child blocks submitted with a guardian signup, as
|
||||
* `children[<n>][name|dob|answers]`. Blocks with no name are dropped rather
|
||||
* than rejected — the form always renders one spare block for "add another",
|
||||
* and an untouched spare is not a mistake the guardian needs telling about.
|
||||
*
|
||||
* @return list<array{name: string, dob: string, answers: array<int, string>}>
|
||||
*/
|
||||
private function submittedChildren(): array {
|
||||
// The submit nonce is verified by the caller before this runs.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each field is unslashed and sanitized below.
|
||||
$raw = $_POST['children'] ?? [];
|
||||
if ( ! is_array( $raw ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ( $raw as $child ) {
|
||||
if ( ! is_array( $child ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = sanitize_text_field( Val::string( wp_unslash( $child['name'] ?? '' ) ) );
|
||||
if ( '' === trim( $name ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$answers = [];
|
||||
foreach ( (array) ( $child['answers'] ?? [] ) as $questionId => $value ) {
|
||||
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'dob' => sanitize_text_field( Val::string( wp_unslash( $child['dob'] ?? '' ) ) ),
|
||||
'answers' => $answers,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create each child of a guardian signup: the login-less account, its answers
|
||||
* to the per-child questions, and a signup-policy acceptance recorded against
|
||||
* the child but attributed to the guardian who agreed for them.
|
||||
*
|
||||
* Returns an empty string on success, or an error message after rolling the
|
||||
* whole family back — every child created so far *and* the guardian. A signup
|
||||
* that half-worked would leave the guardian with an account they cannot
|
||||
* re-register and children they never confirmed, so it is undone entirely and
|
||||
* they simply try again.
|
||||
*
|
||||
* @param list<array{name: string, dob: string, answers: array<int, string>}> $children
|
||||
* @param list<Question> $questions
|
||||
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
||||
*/
|
||||
private function createChildren( array $children, array $questions, array $policyForms, int $guardianId ): string {
|
||||
$created = [];
|
||||
|
||||
foreach ( $children as $child ) {
|
||||
$childId = $this->guardians->createChild( $guardianId, $child['name'], $child['dob'] );
|
||||
|
||||
if ( $childId instanceof \WP_Error ) {
|
||||
foreach ( $created as $id ) {
|
||||
$this->guardians->deleteUser( $id );
|
||||
}
|
||||
$this->guardians->deleteUser( $guardianId );
|
||||
|
||||
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$created[] = $childId;
|
||||
|
||||
$this->recordAnswers( $questions, $child['answers'], $childId );
|
||||
$this->recordAcceptances( $policyForms, $childId, $guardianId );
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The account-question answers submitted with the form, keyed by question id.
|
||||
*
|
||||
@@ -489,9 +605,15 @@ class RegistrationPage {
|
||||
/**
|
||||
* Record account-time acceptances for each signup policy version.
|
||||
*
|
||||
* `$userId` is who the policy binds — the guardian for their own acceptance,
|
||||
* or the child for one accepted on their behalf — and `$acceptedBy` is who
|
||||
* actually ticked the box. Recording both is what makes the row legally
|
||||
* meaningful: "guardian X agreed to version N for child Y, at this time, from
|
||||
* this IP".
|
||||
*
|
||||
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
||||
*/
|
||||
private function recordAcceptances( array $policyForms, int $userId ): void {
|
||||
private function recordAcceptances( array $policyForms, int $userId, int $acceptedBy ): void {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP is stored verbatim for audit.
|
||||
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
|
||||
|
||||
@@ -502,6 +624,7 @@ class RegistrationPage {
|
||||
studentId: $userId,
|
||||
registrationType: PolicyAcceptance::REG_ACCOUNT,
|
||||
registrationId: $userId,
|
||||
acceptedBy: $acceptedBy,
|
||||
ipAddress: '' !== $ip ? $ip : null,
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user