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,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
|
||||
/**
|
||||
* Keeps child accounts unusable as logins. A child holds the `us_student` role
|
||||
* so every `student_id` lookup in the schema keeps working, but nobody is ever
|
||||
* given its credentials — this closes the door the role would otherwise leave
|
||||
* open:
|
||||
*
|
||||
* - authentication is refused outright, and
|
||||
* - the booking capability is withheld, so nothing that reaches a capability
|
||||
* check on a child's own session (there should be none) can book as them.
|
||||
*
|
||||
* Both key off the `us_child` meta, so ordinary students are untouched.
|
||||
*/
|
||||
class ChildLoginGate {
|
||||
|
||||
public function register(): void {
|
||||
add_filter( 'wp_authenticate_user', [ $this, 'blockChildLogin' ], 10, 1 );
|
||||
add_filter( 'user_has_cap', [ $this, 'withholdBooking' ], 10, 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse authentication for a child account. Runs after password
|
||||
* verification, so it holds even if a password were somehow set on one.
|
||||
*
|
||||
* @param \WP_User|\WP_Error $user Authenticating user, or an earlier error.
|
||||
* @return \WP_User|\WP_Error
|
||||
*/
|
||||
public function blockChildLogin( $user ) {
|
||||
if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) {
|
||||
return new \WP_Error(
|
||||
'us_child_account',
|
||||
esc_html__( 'This is a child account and cannot be signed in to. Please sign in with the parent or guardian account.', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the booking capability from a child account, so the only route to a
|
||||
* lesson in their name is their guardian's authorised booking.
|
||||
*
|
||||
* @param array<string, bool> $allcaps All capabilities currently held.
|
||||
* @param array<int, string> $caps Required capabilities (unused).
|
||||
* @param array<int, mixed> $args Callback args (unused).
|
||||
* @param mixed $user The user being checked (a WP_User in practice).
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
public function withholdBooking( array $allcaps, array $caps, array $args, mixed $user ): array {
|
||||
if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) {
|
||||
unset( $allcaps[ RoleManager::CAP_BOOK_LESSON ] );
|
||||
}
|
||||
|
||||
return $allcaps;
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* One parent/guardian ↔ child link. The child is a real (login-less) WordPress
|
||||
* user, so `studentId` is a `wp_users` ID exactly like every other student id in
|
||||
* the schema — this row only records who books and pays on their behalf.
|
||||
*/
|
||||
class GuardianLink {
|
||||
|
||||
public function __construct(
|
||||
public readonly int $guardianId,
|
||||
public readonly int $studentId,
|
||||
public readonly string $relationship = '',
|
||||
public readonly ?string $createdAt = null,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
public static function fromRow( \stdClass $row ): self {
|
||||
return new self(
|
||||
guardianId: Val::int( $row->guardian_id ),
|
||||
studentId: Val::int( $row->student_id ),
|
||||
relationship: Val::string( $row->relationship ?? '' ),
|
||||
createdAt: Val::stringOrNull( $row->created_at ?? null ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the link.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'guardian_id' => $this->guardianId,
|
||||
'student_id' => $this->studentId,
|
||||
'relationship' => $this->relationship,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
class GuardianRepository {
|
||||
|
||||
private string $table;
|
||||
|
||||
public function __construct( private \wpdb $db ) {
|
||||
$this->table = $db->prefix . 'us_guardians';
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a child to a guardian. Returns 0 without inserting when the child
|
||||
* already has a guardian: v1 is one guardian per child, and the check lives
|
||||
* here so every caller (signup, the family screen, admin) gets it.
|
||||
*/
|
||||
public function insert( GuardianLink $link ): int {
|
||||
if ( null !== $this->findByStudent( $link->studentId ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->db->insert(
|
||||
$this->table,
|
||||
[
|
||||
'guardian_id' => $link->guardianId,
|
||||
'student_id' => $link->studentId,
|
||||
'relationship' => $link->relationship,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The link naming this child's guardian, or null when they book for
|
||||
* themselves.
|
||||
*/
|
||||
public function findByStudent( int $studentId ): ?GuardianLink {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare(
|
||||
'SELECT * FROM %i WHERE student_id = %d LIMIT 1',
|
||||
$this->table,
|
||||
$studentId
|
||||
)
|
||||
);
|
||||
|
||||
return $row ? GuardianLink::fromRow( $row ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every child linked to a guardian, oldest link first — the order they are
|
||||
* offered in the booking selector, so it stays stable as children are added.
|
||||
*
|
||||
* @return list<GuardianLink>
|
||||
*/
|
||||
public function findByGuardian( int $guardianId ): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT * FROM %i WHERE guardian_id = %d ORDER BY created_at ASC, id ASC',
|
||||
$this->table,
|
||||
$guardianId
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( GuardianLink::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this exact guardian↔child pair is linked — the authorisation check
|
||||
* behind every "act for this student" boundary.
|
||||
*/
|
||||
public function isGuardianOf( int $guardianId, int $studentId ): bool {
|
||||
$found = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT id FROM %i WHERE guardian_id = %d AND student_id = %d LIMIT 1',
|
||||
$this->table,
|
||||
$guardianId,
|
||||
$studentId
|
||||
)
|
||||
);
|
||||
|
||||
return null !== $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the link between a guardian and one of their children. Deleting the
|
||||
* child user itself is the caller's decision ({@see GuardianService::removeChild()});
|
||||
* this only unlinks.
|
||||
*/
|
||||
public function delete( int $guardianId, int $studentId ): bool {
|
||||
$deleted = $this->db->delete(
|
||||
$this->table,
|
||||
[
|
||||
'guardian_id' => $guardianId,
|
||||
'student_id' => $studentId,
|
||||
],
|
||||
[ '%d', '%d' ]
|
||||
);
|
||||
|
||||
return (int) $deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many children a guardian has — enough to decide whether the booking
|
||||
* page needs a "who is this for?" selector at all.
|
||||
*/
|
||||
public function countChildren( int $guardianId ): int {
|
||||
$count = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT COUNT(*) FROM %i WHERE guardian_id = %d',
|
||||
$this->table,
|
||||
$guardianId
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Everything a guardian does on a child's behalf: creating the child's
|
||||
* login-less account, deciding who may act for whom, and resolving the payer and
|
||||
* contact behind a student id.
|
||||
*/
|
||||
class GuardianService {
|
||||
|
||||
/**
|
||||
* Marks a `wp_users` row as a child account: created by a guardian, holding
|
||||
* the student role so every `student_id` lookup keeps working, but with no
|
||||
* usable login. {@see ChildLoginGate} enforces the "no login" half.
|
||||
*/
|
||||
public const META_CHILD = 'us_child';
|
||||
|
||||
/** A child's date of birth (`Y-m-d`), collected at signup and editable after. */
|
||||
public const META_DOB = 'us_date_of_birth';
|
||||
|
||||
/**
|
||||
* Domain used for a child's placeholder login address. `.invalid` is reserved
|
||||
* by RFC 2606 and can never resolve, so a child's address is guaranteed
|
||||
* undeliverable — nothing about a child's account can ever be emailed to
|
||||
* somewhere real by mistake.
|
||||
*/
|
||||
private const CHILD_EMAIL_DOMAIN = 'child.invalid';
|
||||
|
||||
public function __construct(
|
||||
private GuardianRepository $guardians,
|
||||
private BookingRepository $bookings,
|
||||
private EnrollmentRepository $enrollments,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a login-less child account and link it to its guardian. The password
|
||||
* is random and discarded — it is never stored anywhere readable, emailed, or
|
||||
* shown — so the account cannot be signed into even if the gate were removed.
|
||||
*
|
||||
* Returns the new user ID, or a `WP_Error` when the name is blank or WordPress
|
||||
* refuses the insert.
|
||||
*/
|
||||
public function createChild( int $guardianId, string $name, string $dateOfBirth = '', string $relationship = '' ): int|\WP_Error {
|
||||
$name = trim( $name );
|
||||
if ( '' === $name ) {
|
||||
return new \WP_Error( 'missing_name', __( 'Please give each child a name.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$email = $this->childEmail();
|
||||
$userId = wp_insert_user(
|
||||
[
|
||||
'user_login' => $email,
|
||||
'user_email' => $email,
|
||||
'user_pass' => wp_generate_password( 24, true, true ),
|
||||
'display_name' => $name,
|
||||
'nickname' => $name,
|
||||
'role' => RoleManager::STUDENT,
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $userId ) ) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
$userId = (int) $userId;
|
||||
|
||||
update_user_meta( $userId, self::META_CHILD, '1' );
|
||||
$this->setDateOfBirth( $userId, $dateOfBirth );
|
||||
|
||||
$linkId = $this->guardians->insert(
|
||||
new GuardianLink(
|
||||
guardianId: $guardianId,
|
||||
studentId: $userId,
|
||||
relationship: trim( $relationship ),
|
||||
)
|
||||
);
|
||||
|
||||
// The child was just created, so it cannot already be linked — a failure
|
||||
// here means the insert itself failed, and leaving an unreachable orphan
|
||||
// user behind would be worse than reporting it.
|
||||
if ( $linkId <= 0 ) {
|
||||
$this->deleteUser( $userId );
|
||||
|
||||
return new \WP_Error( 'link_failed', __( 'Could not add this child. Please contact the studio.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a child and update their date of birth. Refuses a student the caller
|
||||
* is not the guardian of, so the family screen cannot be turned into an
|
||||
* arbitrary user editor by posting someone else's id.
|
||||
*/
|
||||
public function updateChild( int $guardianId, int $studentId, string $name, string $dateOfBirth = '' ): true|\WP_Error {
|
||||
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
|
||||
return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$name = trim( $name );
|
||||
if ( '' === $name ) {
|
||||
return new \WP_Error( 'missing_name', __( 'Please give each child a name.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$result = wp_update_user(
|
||||
[
|
||||
'ID' => $studentId,
|
||||
'display_name' => $name,
|
||||
'nickname' => $name,
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->setDateOfBirth( $studentId, $dateOfBirth );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a child and delete their account. Refused once the child has any
|
||||
* lesson or enrolment history: their id is referenced by lessons, payments and
|
||||
* credits, and deleting the user would orphan all of it. A studio admin
|
||||
* handles those cases by hand.
|
||||
*/
|
||||
public function removeChild( int $guardianId, int $studentId ): true|\WP_Error {
|
||||
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
|
||||
return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( [] !== $this->bookings->findByStudent( $studentId ) || [] !== $this->enrollments->findByStudent( $studentId ) ) {
|
||||
return new \WP_Error(
|
||||
'has_history',
|
||||
__( 'This child has lessons or enrolments on record and cannot be removed here. Please contact the studio.', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
$this->guardians->delete( $guardianId, $studentId );
|
||||
$this->deleteUser( $studentId );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `$actorId` may book, cancel and pay as `$studentId` — true for
|
||||
* themselves, and for a guardian acting as one of their own children. This is
|
||||
* the authorisation boundary the REST endpoints and form handlers check before
|
||||
* honouring a submitted student id.
|
||||
*/
|
||||
public function canActFor( int $actorId, int $studentId ): bool {
|
||||
if ( $actorId <= 0 || $studentId <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $actorId === $studentId || $this->guardians->isGuardianOf( $actorId, $studentId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Who owes a student's charges: their guardian when they have one, otherwise
|
||||
* themselves. Payments, credits and the billing-method override all resolve
|
||||
* through this, so a family shares one balance and one billing setting.
|
||||
*/
|
||||
public function payerFor( int $studentId ): int {
|
||||
$link = $this->guardians->findByStudent( $studentId );
|
||||
|
||||
return null !== $link ? $link->guardianId : $studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The student ids whose lessons `$userId` may see: their own plus every child
|
||||
* they are guardian for.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function householdIds( int $userId ): array {
|
||||
$ids = [ $userId ];
|
||||
|
||||
foreach ( $this->guardians->findByGuardian( $userId ) as $link ) {
|
||||
$ids[] = $link->studentId;
|
||||
}
|
||||
|
||||
return array_values( array_unique( $ids ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The people a user may book or enrol for: **children first**, then
|
||||
* themselves. The order is the point — a guardian's normal case is booking for
|
||||
* a child, so the first option (and hence the default selection) is a child,
|
||||
* never the parent. Booking for a child by mistake is a correctable
|
||||
* inconvenience; silently billing a parent's account for a lesson meant for
|
||||
* their kid is the error worth designing out.
|
||||
*
|
||||
* The guardian is still offered, last, so a parent taking lessons alongside
|
||||
* their children can book for themselves from the same account.
|
||||
*
|
||||
* @return list<array{id: int, name: string, is_self: bool}>
|
||||
*/
|
||||
public function bookableStudents( int $userId ): array {
|
||||
$out = [];
|
||||
|
||||
foreach ( $this->children( $userId ) as $child ) {
|
||||
$out[] = [
|
||||
'id' => $child['id'],
|
||||
'name' => $child['name'],
|
||||
'is_self' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$self = get_userdata( $userId );
|
||||
|
||||
$out[] = [
|
||||
'id' => $userId,
|
||||
'name' => UserName::format( $self instanceof \WP_User ? $self : null, $userId ),
|
||||
'is_self' => true,
|
||||
];
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A guardian's children, in link order, with the details the family and admin
|
||||
* screens display.
|
||||
*
|
||||
* @return list<array{id: int, name: string, date_of_birth: string, relationship: string}>
|
||||
*/
|
||||
public function children( int $guardianId ): array {
|
||||
$out = [];
|
||||
|
||||
foreach ( $this->guardians->findByGuardian( $guardianId ) as $link ) {
|
||||
$user = get_userdata( $link->studentId );
|
||||
|
||||
$out[] = [
|
||||
'id' => $link->studentId,
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ),
|
||||
'date_of_birth' => Val::string( get_user_meta( $link->studentId, self::META_DOB, true ) ),
|
||||
'relationship' => $link->relationship,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The guardian behind a child, or null when the student books for themselves.
|
||||
*
|
||||
* @return array{id: int, name: string, email: string}|null
|
||||
*/
|
||||
public function guardianOf( int $studentId ): ?array {
|
||||
$link = $this->guardians->findByStudent( $studentId );
|
||||
if ( null === $link ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = get_userdata( $link->guardianId );
|
||||
|
||||
return [
|
||||
'id' => $link->guardianId,
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->guardianId ),
|
||||
'email' => $user instanceof \WP_User ? $user->user_email : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Who to contact about a student: their guardian when they have one, otherwise
|
||||
* the student. What an instructor looking at a child's lesson actually needs —
|
||||
* a child's own address is an undeliverable placeholder.
|
||||
*
|
||||
* @return array{id: int, name: string, email: string}
|
||||
*/
|
||||
public function contactFor( int $studentId ): array {
|
||||
$guardian = $this->guardianOf( $studentId );
|
||||
if ( null !== $guardian ) {
|
||||
return $guardian;
|
||||
}
|
||||
|
||||
$user = get_userdata( $studentId );
|
||||
|
||||
return [
|
||||
'id' => $studentId,
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $studentId ),
|
||||
'email' => $user instanceof \WP_User ? $user->user_email : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A student's display name, or an empty string when the user is gone. Used
|
||||
* wherever a charge or lesson has to say whose it is.
|
||||
*/
|
||||
public function studentName( int $studentId ): string {
|
||||
$user = get_userdata( $studentId );
|
||||
|
||||
return UserName::format( $user instanceof \WP_User ? $user : null );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a user is a child account (created by a guardian, cannot sign in).
|
||||
*/
|
||||
public static function isChild( int $userId ): bool {
|
||||
return '1' === Val::string( get_user_meta( $userId, self::META_CHILD, true ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a child's user account. Split out so the front-end paths pull in the
|
||||
* admin user functions `wp_delete_user()` lives in — it is not loaded on the
|
||||
* front end, where the family screen runs.
|
||||
*/
|
||||
public function deleteUser( int $userId ): void {
|
||||
if ( ! function_exists( 'wp_delete_user' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/user.php';
|
||||
}
|
||||
|
||||
wp_delete_user( $userId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a child's date of birth, or clear it when blank or unparseable. Kept
|
||||
* as `Y-m-d` so it sorts and displays consistently wherever it is read.
|
||||
*/
|
||||
private function setDateOfBirth( int $userId, string $dateOfBirth ): void {
|
||||
$dateOfBirth = trim( $dateOfBirth );
|
||||
|
||||
if ( '' === $dateOfBirth ) {
|
||||
delete_user_meta( $userId, self::META_DOB );
|
||||
return;
|
||||
}
|
||||
|
||||
$parsed = \DateTimeImmutable::createFromFormat( 'Y-m-d', $dateOfBirth );
|
||||
if ( false === $parsed ) {
|
||||
delete_user_meta( $userId, self::META_DOB );
|
||||
return;
|
||||
}
|
||||
|
||||
update_user_meta( $userId, self::META_DOB, $parsed->format( 'Y-m-d' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* An unused placeholder address for a child's account. WordPress requires a
|
||||
* unique email per user, so the random suffix is retried against
|
||||
* `email_exists()` rather than assumed unique.
|
||||
*/
|
||||
private function childEmail(): string {
|
||||
do {
|
||||
$email = 'us-child-' . wp_generate_password( 12, false, false ) . '@' . self::CHILD_EMAIL_DOMAIN;
|
||||
} while ( false !== email_exists( $email ) );
|
||||
|
||||
return strtolower( $email );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user