Files
unsupervised-scheduler/src/Guardian/GuardianService.php
T
thatguygriffandClaude Opus 5 1d2f95d388
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Coding Standards (pull_request) Successful in 2m56s
CI / PHPStan (pull_request) Successful in 2m56s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m45s
CI / Build Plugin Zip (pull_request) Skipped
Require a name and birth year for every student
Both fields are marked in their labels the same way a required registration
question is, and enforced on the server whichever form they arrive from:
GuardianService::createChild() and updateChild() now refuse a blank name or
an unusable birth year, and the signup form checks the same rule up front,
before it creates a single user, so a bad block never leaves a
half-registered family behind. normaliseBirthYear() became public and static
so both paths share one definition of what a usable year is.

The signup form cannot lean on the browser here. Its child blocks are hidden
until the parent/guardian box is ticked, and a `required` field inside a
hidden container makes the whole form unsubmittable with no control the user
can reach to fix — the same trap the guardian's own question panel already
sidesteps by disabling rather than hiding. So register.js puts `required` on
and takes it off along with the block itself, and the server is what makes
the rule hold with JavaScript off. The profile screen has no such problem:
its forms are always visible, so the attribute is static there.

One behaviour change beyond the requirement: a child block with anything
typed into it is now reported back instead of dropped. Previously any block
without a name was silently discarded, which would now mean losing a birth
year the guardian had filled in. A wholly untouched spare block — the one
the form always renders for "add another" — is still ignored.

Verified the required-toggling in a headless browser: unticked submits,
ticked blocks an empty block, a cloned block inherits the requirement, and
re-unticking leaves nothing behind to block a non-guardian signup.

Closes #148

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 21:00:45 -03:00

442 lines
14 KiB
PHP

<?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 birth year (`YYYY`), collected at signup and editable after. */
public const META_BIRTH_YEAR = 'us_birth_year';
/**
* The full date of birth this feature used to collect. Nothing writes it any
* more: it is read once, to derive a birth year for a child who predates the
* change, and cleared the moment that child's record is next saved. Kept
* public so a site that wants to purge the old dates outright can find them.
*/
public const META_DOB = 'us_date_of_birth';
/**
* The earliest birth year the form will accept. Old enough for any student a
* studio will ever enrol, and late enough to reject a typo like `19` or `190`
* that would otherwise be stored as a plausible-looking year.
*/
private const MIN_BIRTH_YEAR = 1900;
/**
* 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, the birth
* year is missing or unusable, or WordPress refuses the insert.
*/
public function createChild( int $guardianId, string $name, string $birthYear = '', string $relationship = '' ): int|\WP_Error {
$name = trim( $name );
if ( '' === $name ) {
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
}
if ( 0 === self::normaliseBirthYear( $birthYear ) ) {
return new \WP_Error( 'missing_birth_year', self::birthYearError() );
}
$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->setBirthYear( $userId, $birthYear );
$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 student. Please contact the studio.', 'unsupervised-schedular' ) );
}
return $userId;
}
/**
* Rename a child and update their birth year. 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.
*
* Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}.
*/
public function updateChild( int $guardianId, int $studentId, string $name, string $birthYear = '' ): ?\WP_Error {
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
}
$name = trim( $name );
if ( '' === $name ) {
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
}
if ( 0 === self::normaliseBirthYear( $birthYear ) ) {
return new \WP_Error( 'missing_birth_year', self::birthYearError() );
}
$result = wp_update_user(
[
'ID' => $studentId,
'display_name' => $name,
'nickname' => $name,
]
);
if ( is_wp_error( $result ) ) {
return $result;
}
$this->setBirthYear( $studentId, $birthYear );
return null;
}
/**
* 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.
*
* Returns null on success.
*/
public function removeChild( int $guardianId, int $studentId ): ?\WP_Error {
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
}
if ( [] !== $this->bookings->findByStudent( $studentId ) || [] !== $this->enrollments->findByStudent( $studentId ) ) {
return new \WP_Error(
'has_history',
__( 'This student 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 null;
}
/**
* 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, birth_year: 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 ),
'birth_year' => $this->birthYear( $link->studentId ),
'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 birth year, or clear it when blank or out of range.
*
* Either way the legacy full date of birth goes with it. That is what makes
* the read fallback in {@see birthYear()} safe: without it, clearing the year
* on a child who predates this change would leave the old date behind for the
* fallback to resurrect on the very next read.
*/
private function setBirthYear( int $userId, string $birthYear ): void {
delete_user_meta( $userId, self::META_DOB );
$year = self::normaliseBirthYear( $birthYear );
if ( 0 === $year ) {
delete_user_meta( $userId, self::META_BIRTH_YEAR );
return;
}
update_user_meta( $userId, self::META_BIRTH_YEAR, (string) $year );
}
/**
* A submitted birth year as an integer, or 0 when it is blank, not a number,
* or outside {@see MIN_BIRTH_YEAR}..this year. A year in the future is a typo
* every time, so it is refused rather than stored.
*
* Public and static so the signup form can reject a bad year up front, before
* it creates any users, without a second copy of the rule to keep in step.
*/
public static function normaliseBirthYear( string $birthYear ): int {
$birthYear = trim( $birthYear );
if ( '' === $birthYear || 1 !== preg_match( '/^\d{4}$/', $birthYear ) ) {
return 0;
}
$year = (int) $birthYear;
if ( $year < self::MIN_BIRTH_YEAR || $year > (int) current_time( 'Y' ) ) {
return 0;
}
return $year;
}
/**
* The message shown when a birth year is missing or unusable. One phrasing,
* shared by the signup form and the profile screen, so a guardian is told the
* same thing whichever way they got there.
*/
public static function birthYearError(): string {
return sprintf(
/* translators: %d: the earliest birth year the form accepts. */
__( 'Please give each student a birth year, as four digits from %d onwards.', 'unsupervised-schedular' ),
self::MIN_BIRTH_YEAR
);
}
/**
* A child's birth year, or an empty string when none is recorded.
*
* Falls back to the year of the full date of birth this feature used to
* collect, so a child added before the change still shows one. The fallback
* is read-only and one-way: {@see setBirthYear()} drops the old date as soon
* as the record is saved again.
*/
private function birthYear( int $userId ): string {
$year = Val::string( get_user_meta( $userId, self::META_BIRTH_YEAR, true ) );
if ( '' !== $year ) {
return $year;
}
$legacy = Val::string( get_user_meta( $userId, self::META_DOB, true ) );
return 1 === preg_match( '/^(\d{4})-/', $legacy, $m ) ? $m[1] : '';
}
/**
* 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 );
}
}