CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
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]>
661 lines
26 KiB
PHP
661 lines
26 KiB
PHP
<?php
|
|
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;
|
|
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
|
use Unsupervised\Schedular\Policy\PolicyRepository;
|
|
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
|
use Unsupervised\Schedular\Registration\Answer;
|
|
use Unsupervised\Schedular\Registration\AnswerRepository;
|
|
use Unsupervised\Schedular\Registration\Question;
|
|
use Unsupervised\Schedular\Registration\QuestionRepository;
|
|
use Unsupervised\Schedular\Val;
|
|
|
|
class RegistrationPage {
|
|
|
|
/** Success signal: an invited student was created and logged in. */
|
|
private const RESULT_INVITE = 'invite';
|
|
|
|
/** Success signal: a self-signup was created and must confirm their email. */
|
|
private const RESULT_CONFIRM = 'confirm';
|
|
|
|
/**
|
|
* Success signal: a group-link signup was created and must confirm their
|
|
* email — confirming approves the account immediately (no admin review).
|
|
*/
|
|
private const RESULT_CONFIRM_GROUP = 'confirm_group';
|
|
|
|
/**
|
|
* Validation error from the most recent submission processed on
|
|
* `template_redirect`, carried over to {@see render()} so it can be shown
|
|
* inline with the form. Empty when the last submit succeeded or none ran.
|
|
*/
|
|
private string $submitError = '';
|
|
|
|
public function __construct(
|
|
private InviteRepository $invites,
|
|
private PolicyRepository $policies,
|
|
private PolicyVersionRepository $versions,
|
|
private AcceptanceRepository $acceptances,
|
|
private StudioSettings $settings,
|
|
private RegistrationMailer $mailer,
|
|
private QuestionRepository $questions,
|
|
private AnswerRepository $answers,
|
|
private GroupAccessRepository $access,
|
|
private GuardianService $guardians,
|
|
) {}
|
|
|
|
/**
|
|
* Renders the student registration shortcode output.
|
|
*
|
|
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`,
|
|
* `inviteOnlyMessage`) or shortcode
|
|
* attributes (`login_page_id`,
|
|
* `invite_only_message`).
|
|
*/
|
|
public function render( array $atts ): string {
|
|
// A just-completed invite signup is redirected back here already logged
|
|
// in (see maybeHandleSubmit); its success flag distinguishes that from a
|
|
// visitor who simply happens to be signed in already.
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
|
|
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
|
|
|
|
if ( is_user_logged_in() ) {
|
|
// Both logged-in outcomes are dead ends without somewhere to go next,
|
|
// so both offer the same "continue" link to the configured page.
|
|
wp_enqueue_style( 'us-scheduler' );
|
|
$link = $this->continueLink( $atts );
|
|
|
|
if ( self::RESULT_INVITE === $registered ) {
|
|
// An invited student is done the moment they land here logged in.
|
|
return '<div class="us-register-form"><p class="us-success">'
|
|
. esc_html__( 'Your account has been created and you are now logged in.', 'unsupervised-schedular' )
|
|
. '</p>' . $link . '</div>';
|
|
}
|
|
|
|
return '<div class="us-register-form"><p>'
|
|
. esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' )
|
|
. '</p>' . $link . '</div>';
|
|
}
|
|
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked in maybeHandleSubmit.
|
|
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) );
|
|
// Only the token's hash is stored, so hash the submitted token for lookup.
|
|
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
|
|
$open = $this->settings->openRegistrationEnabled();
|
|
|
|
// Only a redeemable invite fixes the form's email to the invited address.
|
|
// A stale token (expired / accepted / revoked) with open registration on
|
|
// must fall back to the normal editable email field, not show — and then
|
|
// fail to submit — the stale invite's address.
|
|
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
|
|
|
|
// The submission itself is processed in maybeHandleSubmit on
|
|
// template_redirect (before any output), so the invite auto-login cookie
|
|
// is actually sent. Its success signal returns here as ?us_registered;
|
|
// only a validation error is carried on the instance to show inline.
|
|
$successType = in_array( $registered, [ self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ? $registered : '';
|
|
$error = $this->submitError;
|
|
|
|
// Result of an email-confirmation link (set by EmailConfirmationHandler's redirect).
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, not a state change.
|
|
$confirmResult = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
|
|
|
|
// Where the post-confirmation prompt sends students to sign in.
|
|
$loginUrl = $this->loginUrl( $this->successPageId( $atts ) );
|
|
|
|
$policyForms = $this->signupPolicies();
|
|
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
|
$canRegister = $open || $inviteValid;
|
|
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
|
|
|
|
// The signup form carries the same policy-acceptance markup as the booking
|
|
// gate, so it needs the plugin stylesheet that formats it.
|
|
wp_enqueue_style( 'us-scheduler' );
|
|
|
|
// 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' );
|
|
}
|
|
|
|
ob_start();
|
|
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
|
|
return (string) ob_get_clean();
|
|
}
|
|
|
|
/**
|
|
* Process a submitted registration on `template_redirect`, before any page
|
|
* output. Running here (rather than inside {@see render()}, which fires
|
|
* during `the_content` after headers are sent) is what lets the invite
|
|
* branch's `wp_set_auth_cookie()` actually persist — otherwise the student
|
|
* appears logged in for a single render and is logged out on the next view.
|
|
*
|
|
* On success the request is redirected (post/redirect/get) with a
|
|
* `?us_registered` flag so a refresh cannot resubmit; a validation error is
|
|
* stashed for {@see render()} to show inline with the form.
|
|
*/
|
|
public function maybeHandleSubmit(): void {
|
|
if ( ! isset( $_POST['us_register'] ) || is_user_logged_in() ) {
|
|
return;
|
|
}
|
|
|
|
if ( ! check_admin_referer( 'us_student_register' ) ) {
|
|
return;
|
|
}
|
|
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer above.
|
|
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) );
|
|
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
|
|
$open = $this->settings->openRegistrationEnabled();
|
|
|
|
$result = $this->handleSubmit( $invite, $open );
|
|
|
|
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) {
|
|
$this->redirect( add_query_arg( 'us_registered', $result, $this->currentUrl() ) );
|
|
return;
|
|
}
|
|
|
|
$this->submitError = $result;
|
|
}
|
|
|
|
/**
|
|
* The current page's clean permalink, used as the post/redirect/get target
|
|
* so the invite token and any stale flags 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;
|
|
}
|
|
|
|
/**
|
|
* The message shown when registration is closed and no valid invite is
|
|
* present. Studios can override the default via the block
|
|
* (`inviteOnlyMessage`) or shortcode (`invite_only_message`) attribute.
|
|
*
|
|
* @param array<int|string, mixed> $atts
|
|
*/
|
|
private function inviteOnlyMessage( array $atts ): string {
|
|
$custom = trim( Val::string( $atts['inviteOnlyMessage'] ?? $atts['invite_only_message'] ?? '' ) );
|
|
|
|
if ( '' !== $custom ) {
|
|
return $custom;
|
|
}
|
|
|
|
return esc_html__( 'Registration is by invitation only. Please use the link from your invitation email, or contact the studio.', 'unsupervised-schedular' );
|
|
}
|
|
|
|
/**
|
|
* Redirect to the configured registration page when an invite token lands
|
|
* elsewhere (e.g. a link generated before the page was selected). Hooked on
|
|
* `template_redirect`.
|
|
*/
|
|
public function maybeRedirectToRegistrationPage(): void {
|
|
if ( is_admin() ) {
|
|
return;
|
|
}
|
|
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only token used only to build the redirect target.
|
|
$token = sanitize_text_field( Val::string( wp_unslash( $_GET['us_invite'] ?? '' ) ) );
|
|
if ( '' === $token ) {
|
|
return;
|
|
}
|
|
|
|
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
|
|
if ( $pageId <= 0 || is_page( $pageId ) ) {
|
|
return;
|
|
}
|
|
|
|
wp_safe_redirect( add_query_arg( 'us_invite', rawurlencode( $token ), (string) get_permalink( $pageId ) ) );
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Process the submitted registration. Returns a success signal
|
|
* ({@see RESULT_INVITE} or {@see RESULT_CONFIRM}) or an error message string
|
|
* on failure.
|
|
*
|
|
* The invite branch is tried first, so an invited student always completes
|
|
* signup regardless of whether open registration is enabled.
|
|
*/
|
|
private function handleSubmit( ?Invite $invite, bool $open ): string {
|
|
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
|
|
|
|
if ( ! $inviteValid && ! $open ) {
|
|
return esc_html__( 'This invitation is invalid, expired, or has already been used.', 'unsupervised-schedular' );
|
|
}
|
|
|
|
// The submit nonce is verified by the caller (render) before this runs.
|
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized.
|
|
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) );
|
|
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
|
|
|
|
if ( strlen( $password ) < 8 ) {
|
|
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
|
|
}
|
|
|
|
// The email is fixed by a personal invite; group-link signups and
|
|
// self-signups supply their own.
|
|
if ( $inviteValid && ! $invite->isGroup() ) {
|
|
$email = $invite->email;
|
|
} else {
|
|
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
|
|
if ( ! is_email( $email ) ) {
|
|
return esc_html__( 'Please enter a valid email address.', 'unsupervised-schedular' );
|
|
}
|
|
}
|
|
|
|
$policyForms = $this->signupPolicies();
|
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion.
|
|
$accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );
|
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
|
|
|
foreach ( $policyForms as $form ) {
|
|
if ( ! in_array( (int) $form['version']->id, $accepted, true ) ) {
|
|
return esc_html__( 'You must accept all required policies to register.', 'unsupervised-schedular' );
|
|
}
|
|
}
|
|
|
|
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
|
|
|
// 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 student, 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 student.', 'unsupervised-schedular' )
|
|
: esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( email_exists( $email ) ) {
|
|
return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
|
|
}
|
|
|
|
$userId = wp_insert_user(
|
|
[
|
|
'user_login' => $email,
|
|
'user_email' => $email,
|
|
'user_pass' => $password,
|
|
'display_name' => '' !== $displayName ? $displayName : $email,
|
|
'role' => $inviteValid ? $invite->role : RoleManager::STUDENT,
|
|
]
|
|
);
|
|
|
|
if ( is_wp_error( $userId ) ) {
|
|
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
|
|
}
|
|
|
|
$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 );
|
|
|
|
// A personal invite may carry a group-class grant (invited by email);
|
|
// point any grants for this address at the new account so the class
|
|
// becomes enrollable for them.
|
|
$this->access->linkStudentByEmail( $email, (int) $userId );
|
|
|
|
wp_set_current_user( (int) $userId );
|
|
wp_set_auth_cookie( (int) $userId );
|
|
|
|
return self::RESULT_INVITE;
|
|
}
|
|
|
|
// Group-link signups and self-signups both stay pending until they
|
|
// confirm their email; the group link is multi-use so it is never marked
|
|
// accepted. A group signup auto-approves on confirmation — no admin
|
|
// review — while a self-signup then waits for studio approval.
|
|
$autoApprove = $inviteValid && $invite->isGroup();
|
|
|
|
$rawToken = RegistrationStatus::markPending( (int) $userId, $autoApprove );
|
|
$user = get_user_by( 'id', (int) $userId );
|
|
if ( $user instanceof \WP_User ) {
|
|
$this->mailer->sendConfirmation( $user, $this->confirmUrl( $rawToken ) );
|
|
}
|
|
|
|
return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM;
|
|
}
|
|
|
|
/**
|
|
* The page id chosen for the post-registration destination, from either the
|
|
* block (`loginPageId`) or shortcode (`login_page_id`) attribute.
|
|
*
|
|
* @param array<int|string, mixed> $atts
|
|
*/
|
|
private function successPageId( array $atts ): int {
|
|
return Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
|
|
}
|
|
|
|
/**
|
|
* URL the post-confirmation sign-in link points to: the chosen login page
|
|
* when one is configured (and still exists), otherwise the WordPress login
|
|
* screen.
|
|
*/
|
|
private function loginUrl( int $loginPageId ): string {
|
|
return $this->continueUrl( $loginPageId ) ?? wp_login_url();
|
|
}
|
|
|
|
/**
|
|
* The "continue" paragraph shown to a logged-in visitor, or an empty string
|
|
* when no destination page is configured. The link names the chosen page, so
|
|
* the visitor knows where it goes before clicking; an untitled page falls
|
|
* back to generic wording rather than reading "Continue to ".
|
|
*
|
|
* The sign-in-page fallback {@see loginUrl()} applies is deliberately not
|
|
* used here: pointing someone who is already signed in at the login screen is
|
|
* the same dead end with extra steps, so no link is better than that one.
|
|
*
|
|
* @param array<int|string, mixed> $atts
|
|
*/
|
|
private function continueLink( array $atts ): string {
|
|
$pageId = $this->successPageId( $atts );
|
|
$continue = $this->continueUrl( $pageId );
|
|
|
|
if ( null === $continue ) {
|
|
return '';
|
|
}
|
|
|
|
$title = trim( Val::string( get_the_title( $pageId ) ) );
|
|
$label = '' === $title
|
|
? esc_html__( 'Continue to your account', 'unsupervised-schedular' )
|
|
: esc_html(
|
|
sprintf(
|
|
/* translators: %s: title of the page the student continues to. */
|
|
__( 'Continue to %s', 'unsupervised-schedular' ),
|
|
$title
|
|
)
|
|
);
|
|
|
|
return '<p><a href="' . esc_url( $continue ) . '">' . $label . '</a></p>';
|
|
}
|
|
|
|
/**
|
|
* The chosen post-registration page's URL, or null when none is configured
|
|
* (or it has since been deleted). Unlike {@see loginUrl()} this has no
|
|
* WordPress-login-screen fallback, so callers that need a page the student
|
|
* was actually sent to — the invited-student link and the block's
|
|
* auto-redirect — can tell "not configured" from "configured".
|
|
*/
|
|
public function continueUrl( int $pageId ): ?string {
|
|
if ( $pageId <= 0 ) {
|
|
return null;
|
|
}
|
|
|
|
$url = get_permalink( $pageId );
|
|
|
|
return is_string( $url ) ? $url : null;
|
|
}
|
|
|
|
/**
|
|
* Whether this request is a *finished* registration — the states the
|
|
* block's auto-redirect may act on:
|
|
*
|
|
* - an invited student who just signed up and is now logged in, and
|
|
* - a self-signup returning from the emailed confirmation link, whether
|
|
* their account is ready (`ready`) or awaiting studio approval (`1`).
|
|
*
|
|
* Deliberately excluded: the intermediate "check your email" step (the
|
|
* student would never see the instruction) and every failure — a validation
|
|
* error or an expired confirmation link (`expired`) — so the message always
|
|
* gets shown. The `us_confirmed` values are set by
|
|
* {@see EmailConfirmationHandler::maybeConfirm()}.
|
|
*/
|
|
public function isRegistrationComplete(): bool {
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
|
|
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
|
|
|
|
if ( self::RESULT_INVITE === $registered ) {
|
|
return is_user_logged_in();
|
|
}
|
|
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag set by EmailConfirmationHandler's redirect.
|
|
$confirmed = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
|
|
|
|
return in_array( $confirmed, [ '1', 'ready' ], true );
|
|
}
|
|
|
|
/**
|
|
* Build the email-confirmation URL for a raw token: the configured
|
|
* registration page (falling back to the home page) with `?us_confirm=`.
|
|
*/
|
|
private function confirmUrl( string $rawToken ): string {
|
|
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
|
|
$base = $pageId > 0 ? (string) get_permalink( $pageId ) : home_url( '/' );
|
|
|
|
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|birth_year|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, birth_year: 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,
|
|
'birth_year' => sanitize_text_field( Val::string( wp_unslash( $child['birth_year'] ?? '' ) ) ),
|
|
'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, birth_year: 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['birth_year'] );
|
|
|
|
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.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function submittedAnswers(): array {
|
|
// The submit nonce is verified by the caller (render) before this runs.
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- 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;
|
|
}
|
|
|
|
/**
|
|
* Persist the submitted answers for each active account-signup question.
|
|
*
|
|
* @param list<Question> $questions
|
|
* @param array<int, string> $answers question_id => submitted value
|
|
*/
|
|
private function recordAnswers( array $questions, array $answers, int $userId ): 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: $userId,
|
|
studentId: $userId,
|
|
answerValue: $value,
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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, 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'] ?? '' ) ) );
|
|
|
|
foreach ( $policyForms as $form ) {
|
|
$this->acceptances->insert(
|
|
new PolicyAcceptance(
|
|
policyVersionId: (int) $form['version']->id,
|
|
studentId: $userId,
|
|
registrationType: PolicyAcceptance::REG_ACCOUNT,
|
|
registrationId: $userId,
|
|
acceptedBy: $acceptedBy,
|
|
ipAddress: '' !== $ip ? $ip : null,
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Signup-scoped policies that have a current published version.
|
|
*
|
|
* @return list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}>
|
|
*/
|
|
private function signupPolicies(): array {
|
|
$out = [];
|
|
|
|
foreach ( $this->policies->findForScope( Policy::SCOPE_SIGNUP ) as $policy ) {
|
|
if ( null === $policy->currentVersionId ) {
|
|
continue;
|
|
}
|
|
|
|
$version = $this->versions->findById( $policy->currentVersionId );
|
|
if ( null === $version || ! $version->isPublished() ) {
|
|
continue;
|
|
}
|
|
|
|
$out[] = [
|
|
'policy' => $policy,
|
|
'version' => $version,
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
}
|