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]>
359 lines
11 KiB
PHP
359 lines
11 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 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 );
|
|
}
|
|
}
|