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 */ 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 */ 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 */ 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 ); } }