The assessment looked for three things: whether students can reach each other's bookings, whether payment settings can be dodged, and whether the plugin opens a way into the rest of the install. The student-isolation and payment paths held up. These are what did not. - The front-end login form told WordPress not to work out whether the site was secure, so on HTTPS every student's session cookie was issued without the Secure flag. wp_signon() only derives it from is_ssl() when the second argument is left at its default; an explicit false reads like "no preference" and is not. - The update check took whatever download URL the release API returned and handed it to core, which unpacks it over the installed plugin. The package must now be https on git.unsupervised.ca exactly, compared on the parsed host so a lookalike name cannot pass. - Uninstalling dropped 2 of 14 tables and left the Stripe secret and webhook signing key in wp_options. Removal is now a choice made in advance on Access -> Plugin removal: records are kept unless the owner opts in (with a typed confirmation), while credentials and the borrowed core registration settings go every time. - Open registration switches on the site-wide users_can_register and makes Student the default role, arming any other signup form on the site to mint students who could book and be billed immediately. The pending state is now decided once, on user_register, rather than by whichever form created the account. - Cancel and withdraw answered "not yours" differently from "does not exist", which let a signed-in student enumerate the studio's bookings. Both now give the same 404. Co-Authored-By: Claude Opus 5 <[email protected]>
575 lines
19 KiB
PHP
575 lines
19 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Guardian;
|
|
|
|
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
|
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' );
|
|
|
|
// A child is a student created by someone who is not staff, so the
|
|
// registration gate holds it on `user_register` like any other unattributed
|
|
// signup. There is nothing here to approve: the account is never signed in
|
|
// to, and the guardian in front of us is the approval. Leaving the hold on
|
|
// would put every child a family adds into the studio's review queue.
|
|
RegistrationStatus::approve( $userId );
|
|
|
|
$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 );
|
|
}
|
|
}
|