CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 55s
CI / PHPStan (pull_request) Successful in 2m57s
CI / Coding Standards (pull_request) Successful in 3m3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
The Profile block is headed "Your profile", but the one person on it you could not change was yourself: your name, your birth year, and whether you take lessons yourself were fixed at whatever signup recorded, and correcting any of them meant asking a studio admin. A "Your details" section now opens the page, saved through the same nonce-checked template_redirect post/redirect/get path the child rows use: - Your name, written to display_name and nickname together, for the reason updateChild() does — UserName reads the nickname first, and leaving it behind would put the account's email address back on every screen that names a person. - "I take lessons myself", the positive of us_guardian_only. This makes good on the claim already in bookableStudents() and the feature doc that a guardian-only account can put itself right from the profile page. - Your birth year, held to the same normaliseBirthYear() rule as every other student. The email is shown but not editable: it is the account's user_login as well as its address, so changing it stays a studio-side job. The birth-year field deliberately carries no `required` attribute. It is asked of a student only, and this page loads no JavaScript, so a browser-enforced `required` would leave a guardian who books solely for other people unable to submit the form at all; handleSelf() enforces it against the checkbox instead. Unticking the box does not clear a stored birth year — it says who books, not "forget what is on file". Closes #165 Co-Authored-By: Claude Opus 5 <[email protected]>
566 lines
19 KiB
PHP
566 lines
19 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;
|
|
}
|
|
|
|
/**
|
|
* Update the account holder's own details from the profile screen: their
|
|
* name, whether they are a student in their own right, and — when they are —
|
|
* their birth year.
|
|
*
|
|
* `$isStudent` is the positive of what {@see META_GUARDIAN_ONLY} stores, so
|
|
* the form can ask the question the way a person would answer it and this is
|
|
* the single place the sense is flipped.
|
|
*
|
|
* Returns null on success, mirroring {@see updateChild()}.
|
|
*/
|
|
public function updateSelf( int $userId, string $name, string $birthYear, bool $isStudent ): ?\WP_Error {
|
|
$name = trim( $name );
|
|
if ( '' === $name ) {
|
|
return new \WP_Error( 'missing_name', __( 'Please give your name.', 'unsupervised-schedular' ) );
|
|
}
|
|
|
|
if ( $isStudent && 0 === self::normaliseBirthYear( $birthYear ) ) {
|
|
return new \WP_Error( 'missing_birth_year', self::ownBirthYearError() );
|
|
}
|
|
|
|
$result = wp_update_user(
|
|
[
|
|
'ID' => $userId,
|
|
'display_name' => $name,
|
|
'nickname' => $name,
|
|
]
|
|
);
|
|
|
|
if ( is_wp_error( $result ) ) {
|
|
return $result;
|
|
}
|
|
|
|
$this->setGuardianOnly( $userId, ! $isStudent );
|
|
|
|
// Only written when they are a student. Saying "I only book for other
|
|
// people" is a statement about who books, not an instruction to forget a
|
|
// year already on file — and someone who ticks the box back on the next
|
|
// visit should find their own details as they left them.
|
|
if ( $isStudent ) {
|
|
$this->setBirthYear( $userId, $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 account holder's own details, as the profile screen's form needs them.
|
|
* The counterpart to {@see children()} for the person reading the page.
|
|
*
|
|
* `is_student` is the positive of {@see META_GUARDIAN_ONLY} — see
|
|
* {@see updateSelf()}, which reads it back the same way round.
|
|
*
|
|
* @return array{name: string, email: string, birth_year: string, is_student: bool}
|
|
*/
|
|
public function accountHolder( int $userId ): array {
|
|
$user = get_userdata( $userId );
|
|
|
|
return [
|
|
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $userId ),
|
|
'email' => $user instanceof \WP_User ? $user->user_email : '',
|
|
'birth_year' => $this->birthYear( $userId ),
|
|
'is_student' => ! self::isGuardianOnly( $userId ),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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 student's birth year, or clear it when blank or out of range. Used
|
|
* for a child added by their guardian and for an account holder who is a
|
|
* student in their own right — the same fact about the same kind of person,
|
|
* so the same meta key holds both.
|
|
*
|
|
* 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.
|
|
*/
|
|
public 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
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The same message for the account holder's own birth year. Separate wording
|
|
* because "each student" is nobody when the student in question is the person
|
|
* reading it.
|
|
*/
|
|
public static function ownBirthYearError(): string {
|
|
return sprintf(
|
|
/* translators: %d: the earliest birth year the form accepts. */
|
|
__( 'Please give your 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 );
|
|
}
|
|
}
|