CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m57s
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Replaces the single "I'm registering as a parent or guardian" tick with "Just myself" / "On behalf of one or more students" / "Both". Radios, not checkboxes as the feedback put it: the three answers are mutually exclusive, and "both" only means anything as a third choice alongside the other two. The tick could only ever say whether there were children to add. It could not say whether the account holder was a student, so bookableStudents() always offered them their own name and any guardian could book themselves a lesson nobody meant to sell. "On behalf of" now records us_guardian_only and leaves them out of the picker. That flag is stored as the negative on purpose. Every account predating this choice is a bookable student, and absence has to keep meaning exactly that, or the picker would quietly stop offering people themselves on upgrade. setGuardianOnly() clears the key rather than writing 0, so "not set" stays the single spelling of "yes, a student". A guardian-only account with nobody linked to it is still offered itself — an empty picker is no way to book at all, and they can put the account right from the profile page. An unrecognised or absent value reads as "just myself": the choice that collects the least and grants the least. A missing radio must never be taken as "register these children". Bumps to 1.4.0. The account holder's own questions stay out of play whenever students are being added, "both" included — asking them there is #146. Verified the form in a headless browser across all three choices: which blocks show, which fields carry `required`, whether the account holder's question panel is disabled, which submit is offered, and that switching back to "just myself" leaves no hidden required field blocking submit. Closes #145 Co-Authored-By: Claude Opus 5 <[email protected]>
484 lines
16 KiB
PHP
484 lines
16 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';
|
|
|
|
/**
|
|
* Set on an account that registered **only** to book for other people, so it
|
|
* is not offered as a student in its own right.
|
|
*
|
|
* Stored as the negative on purpose. Every account that existed before this
|
|
* choice was offered is a bookable student, and absence of the flag has to
|
|
* keep meaning exactly that — otherwise the picker would quietly stop
|
|
* offering people themselves on upgrade.
|
|
*/
|
|
public const META_GUARDIAN_ONLY = 'us_guardian_only';
|
|
|
|
/**
|
|
* 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 — unless they
|
|
* said at signup that they are not a student, in which case offering them is
|
|
* an invitation to book a lesson nobody meant to buy.
|
|
*
|
|
* @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,
|
|
];
|
|
}
|
|
|
|
// A guardian-only account with nobody linked to it would otherwise get an
|
|
// empty list and no way to book at all. Offering them themselves is the
|
|
// lesser wrong: they can still correct the account from the profile page.
|
|
if ( self::isGuardianOnly( $userId ) && [] !== $out ) {
|
|
return $out;
|
|
}
|
|
|
|
$self = get_userdata( $userId );
|
|
|
|
$out[] = [
|
|
'id' => $userId,
|
|
'name' => UserName::format( $self instanceof \WP_User ? $self : null, $userId ),
|
|
'is_self' => true,
|
|
];
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Whether this account books only for other people. False for every account
|
|
* that predates the choice — see {@see META_GUARDIAN_ONLY}.
|
|
*/
|
|
public static function isGuardianOnly( int $userId ): bool {
|
|
return '1' === Val::string( get_user_meta( $userId, self::META_GUARDIAN_ONLY, true ) );
|
|
}
|
|
|
|
/**
|
|
* Record whether this account is a student in its own right. Clears the flag
|
|
* rather than storing a `0`, so "not set" stays the single meaning of "yes,
|
|
* they are a student".
|
|
*/
|
|
public function setGuardianOnly( int $userId, bool $guardianOnly ): void {
|
|
if ( $guardianOnly ) {
|
|
update_user_meta( $userId, self::META_GUARDIAN_ONLY, '1' );
|
|
return;
|
|
}
|
|
|
|
delete_user_meta( $userId, self::META_GUARDIAN_ONLY );
|
|
}
|
|
|
|
/**
|
|
* 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 );
|
|
}
|
|
}
|