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
+358
View File
@@ -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 );
}
}