From 9c900d6553bb40efea67db7b641f778b532d7d48 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Fri, 5 Jun 2026 16:39:39 -0300 Subject: [PATCH] Add account registration with signup policy acceptance Implements #16: invite-only student self-registration through a front-end page, accepting signup-scoped policies at account creation. Policy domain: - us_policies.acceptance_scope (signup/booking/both); Policy::appliesTo(); PolicyRepository::findForScope(); scope threaded through PolicyService, the REST create, the admin controller, and the Policies form. - PolicyAcceptance::REG_ACCOUNT (registration_id = the new user's ID). Auth: - Invite value object + InviteRepository; us_invites table. - RegistrationController + Invites admin page (manage_students): invite an email, share the registration link, revoke. - RegistrationPage ([us_student_register] shortcode): validates the invite token, collects name/password, renders signup-scoped published policies with required acceptance, creates the us_student user, records account-type acceptances, marks the invite accepted, and logs the user in. - RoleManager: manage_students cap added to STUDIO_ADMIN_CAPS. Invite-only is implemented; the us_registration_mode self_approval path is a documented future seam. Docs: docs/features/account-registration.md; policies.md updated. Tests: tests/Unit/Auth/ (Invite, InviteRepository) plus Policy scope updates. composer test (104), cs, and PHPStan level 6 all pass. Refs #16 Co-Authored-By: Claude Opus 4.8 --- docs/features/account-registration.md | 63 ++++++++ docs/features/policies.md | 1 + src/AdminMenu.php | 17 +- src/Auth/Invite.php | 64 ++++++++ src/Auth/InviteRepository.php | 103 ++++++++++++ src/Auth/RegistrationController.php | 55 +++++++ src/Auth/RegistrationPage.php | 159 +++++++++++++++++++ src/Auth/RoleManager.php | 2 + src/Plugin.php | 8 +- src/Policy/Policy.php | 21 +++ src/Policy/PolicyAcceptance.php | 6 +- src/Policy/PolicyController.php | 7 +- src/Policy/PolicyEndpoint.php | 7 +- src/Policy/PolicyRepository.php | 21 ++- src/Policy/PolicyService.php | 4 +- src/Schema.php | 20 ++- src/ShortcodeRegistrar.php | 19 ++- templates/admin/invites.php | 64 ++++++++ templates/admin/policies.php | 11 ++ templates/frontend/register-page.php | 67 ++++++++ tests/Unit/Auth/InviteRepositoryTest.php | 138 ++++++++++++++++ tests/Unit/Auth/InviteTest.php | 71 +++++++++ tests/Unit/Policy/PolicyRepositoryTest.php | 28 +++- tests/Unit/Policy/PolicyServiceTest.php | 6 +- tests/Unit/Policy/PolicyValueObjectsTest.php | 16 +- 25 files changed, 957 insertions(+), 21 deletions(-) create mode 100644 docs/features/account-registration.md create mode 100644 src/Auth/Invite.php create mode 100644 src/Auth/InviteRepository.php create mode 100644 src/Auth/RegistrationController.php create mode 100644 src/Auth/RegistrationPage.php create mode 100644 templates/admin/invites.php create mode 100644 templates/frontend/register-page.php create mode 100644 tests/Unit/Auth/InviteRepositoryTest.php create mode 100644 tests/Unit/Auth/InviteTest.php diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md new file mode 100644 index 0000000..16297ce --- /dev/null +++ b/docs/features/account-registration.md @@ -0,0 +1,63 @@ +# Feature: Account Registration + +## Overview +People register for a student account through a front-end page, accepting any +signup-scoped policies at that time. Registration is **invite-only** by default: a +studio admin sends an invite, and the invitee completes signup via a tokenised +link. A settings seam (`us_registration_mode`) allows switching to open +self-registration with approval later. + +## Registration Modes +Stored in the `us_registration_mode` option (default `invite`): +- `invite` — only a valid, pending invite token grants access to the registration form. *(implemented)* +- `self_approval` — anyone may register; the account is created in a pending state until a studio admin approves it. *(reserved for a later iteration)* + +## Data Model — `{prefix}us_invites` + +| Column | Type | Notes | +|--------------------|------------------|--------------------------------------------------------| +| `id` | BIGINT UNSIGNED | Primary key | +| `email` | VARCHAR(191) | Invited email address | +| `token` | VARCHAR(64) | Opaque token embedded in the registration link | +| `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) | +| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` | +| `invited_by` | BIGINT UNSIGNED | WordPress user ID of the studio admin who invited | +| `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending | +| `created_at` | DATETIME | Insertion time | +| `accepted_at` | DATETIME | When accepted; NULL while pending | + +## Policy Acceptance Scope +Policies declare **when** they must be accepted via `us_policies.acceptance_scope`: +`signup`, `booking`, or `both` (see `policies.md`). The registration form requires +acceptance of every published policy scoped `signup` or `both`. Acceptances are +recorded in `us_policy_acceptances` with `registration_type = account` and +`registration_id = `. + +## Flow (invite mode) +1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created with a token and a registration link. +2. The invitee opens `[us_student_register]` with the token (`?us_invite=`). +3. The form pre-fills the email and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. +4. On submit, the token is re-validated; a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. + +## Admin Interface +**Invites** in wp-admin (`manage_students`, studio admin only): +- Invite an email (creates a pending invite + link) +- List pending invites; revoke an invite + +## Frontend Shortcode +- `[us_student_register]` — the registration page. Shows the form for a valid pending invite; otherwise shows an "by invitation only" message (in `invite` mode). + +## Capabilities +- `manage_students` — manage invites (studio admin; administrators inherit it via the `user_has_cap` filter). Added to `RoleManager::STUDIO_ADMIN_CAPS`. + +## Implementation +- Models: `Unsupervised\Schedular\Auth\Invite` +- Repository: `Unsupervised\Schedular\Auth\InviteRepository` +- Admin controller: `Unsupervised\Schedular\Auth\RegistrationController` +- Frontend: `Unsupervised\Schedular\Auth\RegistrationPage` +- Reuses `Policy\PolicyRepository`, `Policy\PolicyVersionRepository`, `Policy\AcceptanceRepository` +- Schema: `us_invites`; `us_policies.acceptance_scope` + +## Tests +- `tests/Unit/Auth/InviteTest.php` +- `tests/Unit/Auth/InviteRepositoryTest.php` diff --git a/docs/features/policies.md b/docs/features/policies.md index 8602e1d..80b0a0b 100644 --- a/docs/features/policies.md +++ b/docs/features/policies.md @@ -11,6 +11,7 @@ The studio admin drafts, versions, and publishes policies (e.g. cancellation, pa | `title` | VARCHAR(191) | Display name | | `slug` | VARCHAR(191) | Unique key, e.g. `cancellation` | | `current_version_id` | BIGINT UNSIGNED | Nullable FK → `us_policy_versions.id` (published) | +| `acceptance_scope` | VARCHAR(20) | `signup` / `booking` / `both` — when it must be accepted | | `created_at` | DATETIME | Insertion time | ## Data Model — `{prefix}us_policy_versions` diff --git a/src/AdminMenu.php b/src/AdminMenu.php index b057b69..a9b0ac8 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -5,6 +5,8 @@ namespace Unsupervised\Schedular; use Unsupervised\Schedular\Availability\AvailabilityController; use Unsupervised\Schedular\Availability\AvailabilityRepository; +use Unsupervised\Schedular\Auth\InviteRepository; +use Unsupervised\Schedular\Auth\RegistrationController; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\LessonController; @@ -24,13 +26,15 @@ class AdminMenu { private OfferingController $offeringController; private QuestionController $questionController; private PolicyController $policyController; + private RegistrationController $registrationController; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites ) { $this->availabilityController = new AvailabilityController( $availability, $offerings ); $this->lessonController = new LessonController( $bookings ); $this->offeringController = new OfferingController( $offerings ); $this->questionController = new QuestionController( $questions, $offerings ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); + $this->registrationController = new RegistrationController( $invites ); } public function register(): void { @@ -92,6 +96,17 @@ class AdminMenu { 34 ); + // Studio admin: invite students to register. + add_menu_page( + __( 'Invites', 'unsupervised-schedular' ), + __( 'Invites', 'unsupervised-schedular' ), + RoleManager::CAP_MANAGE_STUDENTS, + 'us-invites', + [ $this->registrationController, 'renderPage' ], + 'dashicons-email', + 35 + ); + // Instructor: view their upcoming lessons. add_menu_page( __( 'My Lessons', 'unsupervised-schedular' ), diff --git a/src/Auth/Invite.php b/src/Auth/Invite.php new file mode 100644 index 0000000..871080f --- /dev/null +++ b/src/Auth/Invite.php @@ -0,0 +1,64 @@ + + */ + public const VALID_STATUSES = [ self::STATUS_PENDING, self::STATUS_ACCEPTED, self::STATUS_REVOKED ]; + + public function __construct( + public readonly string $email, + public readonly string $token, + public readonly string $role = RoleManager::STUDENT, + public readonly string $status = self::STATUS_PENDING, + public readonly ?int $invitedBy = null, + public readonly ?int $acceptedUserId = null, + public readonly ?string $acceptedAt = null, + public readonly ?int $id = null, + ) {} + + public static function fromRow( object $row ): self { + return new self( + email: $row->email, + token: $row->token, + role: $row->role, + status: $row->status, + invitedBy: null !== $row->invited_by ? (int) $row->invited_by : null, + acceptedUserId: null !== $row->accepted_user_id ? (int) $row->accepted_user_id : null, + acceptedAt: $row->accepted_at, + id: (int) $row->id, + ); + } + + public function isPending(): bool { + return self::STATUS_PENDING === $this->status; + } + + /** + * Returns a plain array representation of the invite. + * + * @return array + */ + public function toArray(): array { + return [ + 'id' => $this->id, + 'email' => $this->email, + 'token' => $this->token, + 'role' => $this->role, + 'status' => $this->status, + 'invited_by' => $this->invitedBy, + 'accepted_user_id' => $this->acceptedUserId, + 'accepted_at' => $this->acceptedAt, + ]; + } +} diff --git a/src/Auth/InviteRepository.php b/src/Auth/InviteRepository.php new file mode 100644 index 0000000..cc08299 --- /dev/null +++ b/src/Auth/InviteRepository.php @@ -0,0 +1,103 @@ +table = $db->prefix . 'us_invites'; + } + + public function insert( Invite $invite ): int { + $this->db->insert( + $this->table, + [ + 'email' => $invite->email, + 'token' => $invite->token, + 'role' => $invite->role, + 'status' => $invite->status, + 'invited_by' => $invite->invitedBy, + 'accepted_user_id' => $invite->acceptedUserId, + 'created_at' => current_time( 'mysql' ), + 'accepted_at' => $invite->acceptedAt, + ], + [ '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s' ] + ); + + return $this->db->insert_id; + } + + public function findByToken( string $token ): ?Invite { + $row = $this->db->get_row( + $this->db->prepare( "SELECT * FROM {$this->table} WHERE token = %s", $token ) + ); + + return $row ? Invite::fromRow( $row ) : null; + } + + public function findById( int $id ): ?Invite { + $row = $this->db->get_row( + $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + ); + + return $row ? Invite::fromRow( $row ) : null; + } + + /** + * The most recent pending invite for an email, if any. + */ + public function findPendingByEmail( string $email ): ?Invite { + $row = $this->db->get_row( + $this->db->prepare( + "SELECT * FROM {$this->table} WHERE email = %s AND status = %s ORDER BY id DESC LIMIT 1", + $email, + Invite::STATUS_PENDING + ) + ); + + return $row ? Invite::fromRow( $row ) : null; + } + + /** + * All invites awaiting acceptance, newest first. + * + * @return list + */ + public function findPending(): array { + $rows = $this->db->get_results( + $this->db->prepare( + "SELECT * FROM {$this->table} WHERE status = %s ORDER BY created_at DESC", + Invite::STATUS_PENDING + ) + ); + + return array_map( Invite::fromRow( ... ), $rows ?? [] ); + } + + public function markAccepted( int $id, int $userId ): bool { + return false !== $this->db->update( + $this->table, + [ + 'status' => Invite::STATUS_ACCEPTED, + 'accepted_user_id' => $userId, + 'accepted_at' => current_time( 'mysql' ), + ], + [ 'id' => $id ], + [ '%s', '%d', '%s' ], + [ '%d' ] + ); + } + + public function revoke( int $id ): bool { + return false !== $this->db->update( + $this->table, + [ 'status' => Invite::STATUS_REVOKED ], + [ 'id' => $id ], + [ '%s' ], + [ '%d' ] + ); + } +} diff --git a/src/Auth/RegistrationController.php b/src/Auth/RegistrationController.php new file mode 100644 index 0000000..f70f5e6 --- /dev/null +++ b/src/Auth/RegistrationController.php @@ -0,0 +1,55 @@ +handleFormAction(); + } + + $pendingInvites = $this->invites->findPending(); + + include USC_PLUGIN_DIR . 'templates/admin/invites.php'; + } + + private function handleFormAction(): void { + // Nonce is verified by the caller (renderPage) before this method runs. + // phpcs:disable WordPress.Security.NonceVerification.Missing + $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ); + + if ( 'invite' === $action ) { + $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ); + + if ( + is_email( $email ) + && false === email_exists( $email ) + && null === $this->invites->findPendingByEmail( $email ) + ) { + $this->invites->insert( + new Invite( + email: $email, + token: wp_generate_password( 32, false ), + invitedBy: get_current_user_id(), + ) + ); + } + } + + if ( 'revoke' === $action ) { + $inviteId = absint( $_POST['invite_id'] ?? 0 ); + if ( $inviteId > 0 ) { + $this->invites->revoke( $inviteId ); + } + } + // phpcs:enable WordPress.Security.NonceVerification.Missing + } +} diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php new file mode 100644 index 0000000..0603231 --- /dev/null +++ b/src/Auth/RegistrationPage.php @@ -0,0 +1,159 @@ + $atts Shortcode attributes (unused — reserved for future options). + */ + public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + if ( is_user_logged_in() ) { + return '

' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '

'; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked below. + $token = sanitize_text_field( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ); + $invite = '' !== $token ? $this->invites->findByToken( $token ) : null; + + $error = ''; + $success = false; + + if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) { + $result = $this->handleSubmit( $invite ); + if ( true === $result ) { + $success = true; + } else { + $error = $result; + } + } + + $policyForms = $this->signupPolicies(); + $canRegister = null !== $invite && $invite->isPending(); + + ob_start(); + include USC_PLUGIN_DIR . 'templates/frontend/register-page.php'; + return (string) ob_get_clean(); + } + + /** + * Process the submitted registration. Returns true on success or an error + * message string on failure. + */ + private function handleSubmit( ?Invite $invite ): string|bool { + if ( null === $invite || ! $invite->isPending() ) { + return esc_html__( 'This invitation is invalid or has already been used.', 'unsupervised-schedular' ); + } + + // The submit nonce is verified by the caller (render) before this runs. + // phpcs:disable WordPress.Security.NonceVerification.Missing + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized. + $password = (string) wp_unslash( $_POST['password'] ?? '' ); + $displayName = sanitize_text_field( wp_unslash( $_POST['display_name'] ?? '' ) ); + + if ( strlen( $password ) < 8 ) { + return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' ); + } + + $policyForms = $this->signupPolicies(); + $accepted = array_map( 'absint', (array) ( $_POST['accept'] ?? [] ) ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + + foreach ( $policyForms as $form ) { + if ( ! in_array( (int) $form['version']->id, $accepted, true ) ) { + return esc_html__( 'You must accept all required policies to register.', 'unsupervised-schedular' ); + } + } + + if ( email_exists( $invite->email ) ) { + return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' ); + } + + $userId = wp_insert_user( + [ + 'user_login' => $invite->email, + 'user_email' => $invite->email, + 'user_pass' => $password, + 'display_name' => '' !== $displayName ? $displayName : $invite->email, + 'role' => $invite->role, + ] + ); + + if ( is_wp_error( $userId ) ) { + return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' ); + } + + $this->recordAcceptances( $policyForms, (int) $userId ); + $this->invites->markAccepted( (int) $invite->id, (int) $userId ); + + wp_set_current_user( (int) $userId ); + wp_set_auth_cookie( (int) $userId ); + + return true; + } + + /** + * Record account-time acceptances for each signup policy version. + * + * @param list $policyForms + */ + private function recordAcceptances( array $policyForms, int $userId ): void { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP is stored verbatim for audit. + $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ); + + foreach ( $policyForms as $form ) { + $this->acceptances->insert( + new PolicyAcceptance( + policyVersionId: (int) $form['version']->id, + studentId: $userId, + registrationType: PolicyAcceptance::REG_ACCOUNT, + registrationId: $userId, + ipAddress: '' !== $ip ? $ip : null, + ) + ); + } + } + + /** + * Signup-scoped policies that have a current published version. + * + * @return list + */ + private function signupPolicies(): array { + $out = []; + + foreach ( $this->policies->findForScope( Policy::SCOPE_SIGNUP ) as $policy ) { + if ( null === $policy->currentVersionId ) { + continue; + } + + $version = $this->versions->findById( $policy->currentVersionId ); + if ( null === $version || ! $version->isPublished() ) { + continue; + } + + $out[] = [ + 'policy' => $policy, + 'version' => $version, + ]; + } + + return $out; + } +} diff --git a/src/Auth/RoleManager.php b/src/Auth/RoleManager.php index e68b3c2..1db863c 100644 --- a/src/Auth/RoleManager.php +++ b/src/Auth/RoleManager.php @@ -14,6 +14,7 @@ class RoleManager { public const CAP_BOOK_LESSON = 'book_lesson'; public const CAP_MANAGE_INSTRUCTORS = 'manage_instructors'; + public const CAP_MANAGE_STUDENTS = 'manage_students'; public const CAP_MANAGE_OFFERINGS = 'manage_offerings'; public const CAP_MANAGE_QUESTIONS = 'manage_questions'; public const CAP_MANAGE_POLICIES = 'manage_policies'; @@ -31,6 +32,7 @@ class RoleManager { */ public const STUDIO_ADMIN_CAPS = [ self::CAP_MANAGE_INSTRUCTORS, + self::CAP_MANAGE_STUDENTS, self::CAP_MANAGE_OFFERINGS, self::CAP_MANAGE_QUESTIONS, self::CAP_MANAGE_POLICIES, diff --git a/src/Plugin.php b/src/Plugin.php index 7737506..5cfe69f 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -3,10 +3,12 @@ declare(strict_types=1); namespace Unsupervised\Schedular; +use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyService; use Unsupervised\Schedular\Policy\PolicyVersionRepository; @@ -25,10 +27,12 @@ class Plugin { $policies = new PolicyRepository( $wpdb ); $policyVersions = new PolicyVersionRepository( $wpdb ); $policyService = new PolicyService( $policies, $policyVersions ); + $acceptances = new AcceptanceRepository( $wpdb ); + $invites = new InviteRepository( $wpdb ); ( new RoleManager() )->register(); - ( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService ) )->register(); + ( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites ) )->register(); ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService ) )->register(); - ( new ShortcodeRegistrar() )->register(); + ( new ShortcodeRegistrar( $invites, $policies, $policyVersions, $acceptances ) )->register(); } } diff --git a/src/Policy/Policy.php b/src/Policy/Policy.php index fbb2344..fa0f645 100644 --- a/src/Policy/Policy.php +++ b/src/Policy/Policy.php @@ -5,10 +5,22 @@ namespace Unsupervised\Schedular\Policy; class Policy { + public const SCOPE_SIGNUP = 'signup'; + public const SCOPE_BOOKING = 'booking'; + public const SCOPE_BOTH = 'both'; + + /** + * All valid acceptance scopes — when a policy must be accepted. + * + * @var list + */ + public const VALID_SCOPES = [ self::SCOPE_SIGNUP, self::SCOPE_BOOKING, self::SCOPE_BOTH ]; + public function __construct( public readonly string $title, public readonly string $slug, public readonly ?int $currentVersionId = null, + public readonly string $acceptanceScope = self::SCOPE_BOOKING, public readonly ?int $id = null, ) {} @@ -17,10 +29,18 @@ class Policy { title: $row->title, slug: $row->slug, currentVersionId: null !== $row->current_version_id ? (int) $row->current_version_id : null, + acceptanceScope: $row->acceptance_scope, id: (int) $row->id, ); } + /** + * Whether this policy must be accepted in the given gate (`signup` or `booking`). + */ + public function appliesTo( string $scope ): bool { + return $this->acceptanceScope === $scope || self::SCOPE_BOTH === $this->acceptanceScope; + } + /** * Returns a plain array representation of the policy. * @@ -32,6 +52,7 @@ class Policy { 'title' => $this->title, 'slug' => $this->slug, 'current_version_id' => $this->currentVersionId, + 'acceptance_scope' => $this->acceptanceScope, ]; } } diff --git a/src/Policy/PolicyAcceptance.php b/src/Policy/PolicyAcceptance.php index ea534cf..6c1d9c3 100644 --- a/src/Policy/PolicyAcceptance.php +++ b/src/Policy/PolicyAcceptance.php @@ -5,15 +5,17 @@ namespace Unsupervised\Schedular\Policy; class PolicyAcceptance { + public const REG_ACCOUNT = 'account'; public const REG_LESSON = 'lesson'; public const REG_ENROLLMENT = 'enrollment'; /** - * Polymorphic registration targets an acceptance can attach to. + * Polymorphic registration targets an acceptance can attach to. For `account` + * the registration id is the WordPress user ID. * * @var list */ - public const VALID_REGISTRATION_TYPES = [ self::REG_LESSON, self::REG_ENROLLMENT ]; + public const VALID_REGISTRATION_TYPES = [ self::REG_ACCOUNT, self::REG_LESSON, self::REG_ENROLLMENT ]; public function __construct( public readonly int $policyVersionId, diff --git a/src/Policy/PolicyController.php b/src/Policy/PolicyController.php index 7403ff5..aead227 100644 --- a/src/Policy/PolicyController.php +++ b/src/Policy/PolicyController.php @@ -40,9 +40,14 @@ class PolicyController { $title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) ); $slugRaw = sanitize_text_field( wp_unslash( $_POST['slug'] ?? '' ) ); $slug = sanitize_title( '' !== $slugRaw ? $slugRaw : $title ); + $scope = sanitize_key( wp_unslash( $_POST['acceptance_scope'] ?? Policy::SCOPE_BOOKING ) ); + + if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) { + $scope = Policy::SCOPE_BOOKING; + } if ( '' !== $title && '' !== $slug && null === $this->policies->findBySlug( $slug ) ) { - $this->service->createPolicy( $title, $slug ); + $this->service->createPolicy( $title, $slug, $scope ); } return; diff --git a/src/Policy/PolicyEndpoint.php b/src/Policy/PolicyEndpoint.php index 7d6ebe8..11c15b4 100644 --- a/src/Policy/PolicyEndpoint.php +++ b/src/Policy/PolicyEndpoint.php @@ -113,7 +113,12 @@ class PolicyEndpoint { return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } - $id = $this->service->createPolicy( $title, $slug ); + $scope = (string) ( $request->get_param( 'acceptance_scope' ) ?? Policy::SCOPE_BOOKING ); + if ( ! in_array( $scope, Policy::VALID_SCOPES, true ) ) { + return $this->invalid( __( 'Invalid acceptance scope.', 'unsupervised-schedular' ) ); + } + + $id = $this->service->createPolicy( $title, $slug, $scope ); return new \WP_REST_Response( [ 'id' => $id ], 201 ); } diff --git a/src/Policy/PolicyRepository.php b/src/Policy/PolicyRepository.php index 7dec220..d2af123 100644 --- a/src/Policy/PolicyRepository.php +++ b/src/Policy/PolicyRepository.php @@ -18,14 +18,33 @@ class PolicyRepository { 'title' => $policy->title, 'slug' => $policy->slug, 'current_version_id' => $policy->currentVersionId, + 'acceptance_scope' => $policy->acceptanceScope, 'created_at' => current_time( 'mysql' ), ], - [ '%s', '%s', '%d', '%s' ] + [ '%s', '%s', '%d', '%s', '%s' ] ); return $this->db->insert_id; } + /** + * Policies that must be accepted in the given gate — those scoped to it or to + * `both`. Pass `Policy::SCOPE_SIGNUP` or `Policy::SCOPE_BOOKING`. + * + * @return list + */ + public function findForScope( string $scope ): array { + $rows = $this->db->get_results( + $this->db->prepare( + "SELECT * FROM {$this->table} WHERE acceptance_scope = %s OR acceptance_scope = %s ORDER BY title ASC", + $scope, + Policy::SCOPE_BOTH + ) + ); + + return array_map( Policy::fromRow( ... ), $rows ?? [] ); + } + public function updateCurrentVersion( int $policyId, int $versionId ): bool { return false !== $this->db->update( $this->table, diff --git a/src/Policy/PolicyService.php b/src/Policy/PolicyService.php index 23a1aa1..c6cbe7b 100644 --- a/src/Policy/PolicyService.php +++ b/src/Policy/PolicyService.php @@ -13,8 +13,8 @@ class PolicyService { private PolicyVersionRepository $versions, ) {} - public function createPolicy( string $title, string $slug ): int { - return $this->policies->insert( new Policy( $title, $slug ) ); + public function createPolicy( string $title, string $slug, string $scope = Policy::SCOPE_BOOKING ): int { + return $this->policies->insert( new Policy( $title, $slug, acceptanceScope: $scope ) ); } /** diff --git a/src/Schema.php b/src/Schema.php index 009c069..964b4f1 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -100,9 +100,11 @@ class Schema { title VARCHAR(191) NOT NULL, slug VARCHAR(191) NOT NULL, current_version_id BIGINT UNSIGNED DEFAULT NULL, + acceptance_scope VARCHAR(20) NOT NULL DEFAULT 'booking', created_at DATETIME NOT NULL, PRIMARY KEY (id), - UNIQUE KEY slug (slug) + UNIQUE KEY slug (slug), + KEY acceptance_scope (acceptance_scope) ) {$charset};", "CREATE TABLE {$prefix}us_policy_versions ( @@ -131,6 +133,22 @@ class Schema { KEY student_id (student_id), KEY registration (registration_type, registration_id) ) {$charset};", + + "CREATE TABLE {$prefix}us_invites ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + email VARCHAR(191) NOT NULL, + token VARCHAR(64) NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'us_student', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + invited_by BIGINT UNSIGNED DEFAULT NULL, + accepted_user_id BIGINT UNSIGNED DEFAULT NULL, + created_at DATETIME NOT NULL, + accepted_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY token (token), + KEY email (email), + KEY status (status) + ) {$charset};", ]; } } diff --git a/src/ShortcodeRegistrar.php b/src/ShortcodeRegistrar.php index 5ec3763..24a1762 100644 --- a/src/ShortcodeRegistrar.php +++ b/src/ShortcodeRegistrar.php @@ -3,22 +3,35 @@ declare(strict_types=1); namespace Unsupervised\Schedular; +use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\LoginPage; +use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Booking\BookingPage; +use Unsupervised\Schedular\Policy\AcceptanceRepository; +use Unsupervised\Schedular\Policy\PolicyRepository; +use Unsupervised\Schedular\Policy\PolicyVersionRepository; class ShortcodeRegistrar { private BookingPage $bookingPage; private LoginPage $loginPage; + private RegistrationPage $registrationPage; - public function __construct() { - $this->bookingPage = new BookingPage(); - $this->loginPage = new LoginPage(); + public function __construct( + InviteRepository $invites, + PolicyRepository $policies, + PolicyVersionRepository $policyVersions, + AcceptanceRepository $acceptances, + ) { + $this->bookingPage = new BookingPage(); + $this->loginPage = new LoginPage(); + $this->registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances ); } public function register(): void { add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] ); add_shortcode( 'us_student_login', [ $this->loginPage, 'render' ] ); + add_shortcode( 'us_student_register', [ $this->registrationPage, 'render' ] ); add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] ); } diff --git a/templates/admin/invites.php b/templates/admin/invites.php new file mode 100644 index 0000000..b5ce256 --- /dev/null +++ b/templates/admin/invites.php @@ -0,0 +1,64 @@ + $pendingInvites */ +?> +
+

+

+ +

+
+ + + + + + + +
+ +
+ +

+ + +

+ + + + + + + + + + + + token, home_url('/'))); ?> + + + + + + + +
email); ?> + + + +
+ + + + +
+
+ +
diff --git a/templates/admin/policies.php b/templates/admin/policies.php index 3497059..65efaa9 100644 --- a/templates/admin/policies.php +++ b/templates/admin/policies.php @@ -1,6 +1,7 @@ "> + + + + + + diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php new file mode 100644 index 0000000..8e18350 --- /dev/null +++ b/templates/frontend/register-page.php @@ -0,0 +1,67 @@ + $policyForms + */ +?> +
+ +

+ +

+ + + + + +
+ + + +

+ + +

+

+ + +

+

+ + +

+ + +
+ + +
+

title); ?>

+
body); ?>
+ +
+ +
+ + +

+ +

+
+ +
diff --git a/tests/Unit/Auth/InviteRepositoryTest.php b/tests/Unit/Auth/InviteRepositoryTest.php new file mode 100644 index 0000000..5a33797 --- /dev/null +++ b/tests/Unit/Auth/InviteRepositoryTest.php @@ -0,0 +1,138 @@ +db = Mockery::mock(\wpdb::class); + $this->db->prefix = 'wp_'; + $this->repo = new InviteRepository($this->db); + } + + public function testInsertReturnsId(): void + { + Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00'); + + $this->db->shouldReceive('insert') + ->once() + ->with( + 'wp_us_invites', + Mockery::on(static function (array $d): bool { + return $d['email'] === 'a@b.test' + && $d['token'] === 'tok123' + && $d['status'] === Invite::STATUS_PENDING + && $d['invited_by'] === 2; + }), + ['%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s'] + ); + $this->db->insert_id = 5; + + self::assertSame(5, $this->repo->insert(new Invite('a@b.test', 'tok123', invitedBy: 2))); + } + + public function testFindByTokenReturnsInvite(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/token = %s/'), 'tok123') + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_row')->andReturn($this->row()); + + $invite = $this->repo->findByToken('tok123'); + + self::assertInstanceOf(Invite::class, $invite); + self::assertSame('a@b.test', $invite->email); + } + + public function testFindByTokenReturnsNullWhenMissing(): void + { + $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); + $this->db->shouldReceive('get_row')->andReturn(null); + + self::assertNull($this->repo->findByToken('nope')); + } + + public function testFindPendingByEmailFiltersStatus(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/email = %s AND status = %s/'), 'a@b.test', Invite::STATUS_PENDING) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_row')->andReturn($this->row()); + + self::assertInstanceOf(Invite::class, $this->repo->findPendingByEmail('a@b.test')); + } + + public function testFindPendingMapsRows(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/status = %s/'), Invite::STATUS_PENDING) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_results')->andReturn([$this->row()]); + + $pending = $this->repo->findPending(); + + self::assertCount(1, $pending); + self::assertInstanceOf(Invite::class, $pending[0]); + } + + public function testMarkAcceptedUpdatesRow(): void + { + Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 10:00:00'); + + $this->db->shouldReceive('update') + ->once() + ->with( + 'wp_us_invites', + Mockery::on(static fn (array $d): bool => $d['status'] === Invite::STATUS_ACCEPTED && $d['accepted_user_id'] === 9), + ['id' => 5], + ['%s', '%d', '%s'], + ['%d'] + ) + ->andReturn(1); + + self::assertTrue($this->repo->markAccepted(5, 9)); + } + + public function testRevokeUpdatesStatus(): void + { + $this->db->shouldReceive('update') + ->once() + ->with('wp_us_invites', ['status' => Invite::STATUS_REVOKED], ['id' => 5], ['%s'], ['%d']) + ->andReturn(1); + + self::assertTrue($this->repo->revoke(5)); + } + + private function row(): object + { + return (object) [ + 'id' => '5', + 'email' => 'a@b.test', + 'token' => 'tok123', + 'role' => 'us_student', + 'status' => Invite::STATUS_PENDING, + 'invited_by' => '2', + 'accepted_user_id' => null, + 'accepted_at' => null, + ]; + } +} diff --git a/tests/Unit/Auth/InviteTest.php b/tests/Unit/Auth/InviteTest.php new file mode 100644 index 0000000..75dff7b --- /dev/null +++ b/tests/Unit/Auth/InviteTest.php @@ -0,0 +1,71 @@ +email); + self::assertSame('tok123', $invite->token); + self::assertSame(RoleManager::STUDENT, $invite->role); + self::assertSame(Invite::STATUS_PENDING, $invite->status); + self::assertTrue($invite->isPending()); + self::assertNull($invite->invitedBy); + self::assertNull($invite->acceptedUserId); + self::assertNull($invite->id); + } + + public function testFromRowMapsCorrectly(): void + { + $invite = Invite::fromRow((object) [ + 'id' => '5', + 'email' => 'a@b.test', + 'token' => 'tok123', + 'role' => RoleManager::STUDENT, + 'status' => Invite::STATUS_ACCEPTED, + 'invited_by' => '2', + 'accepted_user_id' => '9', + 'accepted_at' => '2026-06-02 09:00:00', + ]); + + self::assertSame(5, $invite->id); + self::assertSame(2, $invite->invitedBy); + self::assertSame(9, $invite->acceptedUserId); + self::assertFalse($invite->isPending()); + } + + public function testFromRowHandlesNullableIds(): void + { + $invite = Invite::fromRow((object) [ + 'id' => '5', + 'email' => 'a@b.test', + 'token' => 'tok123', + 'role' => RoleManager::STUDENT, + 'status' => Invite::STATUS_PENDING, + 'invited_by' => null, + 'accepted_user_id' => null, + 'accepted_at' => null, + ]); + + self::assertNull($invite->invitedBy); + self::assertNull($invite->acceptedUserId); + self::assertTrue($invite->isPending()); + } + + public function testToArrayContainsExpectedKeys(): void + { + $arr = (new Invite('a@b.test', 'tok', id: 1))->toArray(); + + foreach (['id', 'email', 'token', 'role', 'status', 'invited_by', 'accepted_user_id', 'accepted_at'] as $key) { + self::assertArrayHasKey($key, $arr); + } + } +} diff --git a/tests/Unit/Policy/PolicyRepositoryTest.php b/tests/Unit/Policy/PolicyRepositoryTest.php index 868e374..0ca8f70 100644 --- a/tests/Unit/Policy/PolicyRepositoryTest.php +++ b/tests/Unit/Policy/PolicyRepositoryTest.php @@ -31,14 +31,35 @@ class PolicyRepositoryTest extends TestCase ->once() ->with( 'wp_us_policies', - Mockery::on(static fn (array $d): bool => $d['title'] === 'Cancellation' && $d['slug'] === 'cancellation' && $d['current_version_id'] === null), - ['%s', '%s', '%d', '%s'] + Mockery::on(static fn (array $d): bool => $d['title'] === 'Cancellation' && $d['slug'] === 'cancellation' && $d['current_version_id'] === null && $d['acceptance_scope'] === Policy::SCOPE_BOOKING), + ['%s', '%s', '%d', '%s', '%s'] ); $this->db->insert_id = 7; self::assertSame(7, $this->repo->insert(new Policy('Cancellation', 'cancellation'))); } + public function testFindForScopeMatchesScopeOrBoth(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::pattern('/acceptance_scope = %s OR acceptance_scope = %s/'), + Policy::SCOPE_SIGNUP, + Policy::SCOPE_BOTH + ) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_results')->andReturn([ + (object) ['id' => '1', 'title' => 'Terms', 'slug' => 'terms', 'current_version_id' => '5', 'acceptance_scope' => Policy::SCOPE_SIGNUP], + ]); + + $found = $this->repo->findForScope(Policy::SCOPE_SIGNUP); + + self::assertCount(1, $found); + self::assertSame(Policy::SCOPE_SIGNUP, $found[0]->acceptanceScope); + } + public function testUpdateCurrentVersion(): void { $this->db->shouldReceive('update') @@ -57,6 +78,7 @@ class PolicyRepositoryTest extends TestCase 'title' => 'Cancellation', 'slug' => 'cancellation', 'current_version_id' => null, + 'acceptance_scope' => Policy::SCOPE_BOOKING, ]); $policy = $this->repo->findBySlug('cancellation'); @@ -76,7 +98,7 @@ class PolicyRepositoryTest extends TestCase public function testFindAllMapsRows(): void { $this->db->shouldReceive('get_results')->andReturn([ - (object) ['id' => '1', 'title' => 'A', 'slug' => 'a', 'current_version_id' => null], + (object) ['id' => '1', 'title' => 'A', 'slug' => 'a', 'current_version_id' => null, 'acceptance_scope' => Policy::SCOPE_BOOKING], ]); $all = $this->repo->findAll(); diff --git a/tests/Unit/Policy/PolicyServiceTest.php b/tests/Unit/Policy/PolicyServiceTest.php index 9784c96..dc8deb0 100644 --- a/tests/Unit/Policy/PolicyServiceTest.php +++ b/tests/Unit/Policy/PolicyServiceTest.php @@ -56,7 +56,7 @@ class PolicyServiceTest extends TestCase { Functions\expect('current_time')->with('mysql')->andReturn('2026-06-01 12:00:00'); - $this->policies->shouldReceive('findById')->once()->with(4)->andReturn(new Policy('T', 't', 8, 4)); + $this->policies->shouldReceive('findById')->once()->with(4)->andReturn(new Policy('T', 't', 8, id: 4)); $this->versions->shouldReceive('findById')->once()->with(9)->andReturn(new PolicyVersion(4, 2, '

x

', PolicyVersion::STATUS_DRAFT, null, 9)); $this->versions->shouldReceive('updateStatus')->once()->with(8, PolicyVersion::STATUS_ARCHIVED); @@ -70,7 +70,7 @@ class PolicyServiceTest extends TestCase { Functions\expect('current_time')->andReturn('2026-06-01 12:00:00'); - $this->policies->shouldReceive('findById')->once()->with(4)->andReturn(new Policy('T', 't', null, 4)); + $this->policies->shouldReceive('findById')->once()->with(4)->andReturn(new Policy('T', 't', null, id: 4)); $this->versions->shouldReceive('findById')->once()->with(9)->andReturn(new PolicyVersion(4, 1, null, PolicyVersion::STATUS_DRAFT, null, 9)); // No archive call expected (no prior current version). @@ -82,7 +82,7 @@ class PolicyServiceTest extends TestCase public function testPublishRejectsVersionFromAnotherPolicy(): void { - $this->policies->shouldReceive('findById')->once()->with(4)->andReturn(new Policy('T', 't', null, 4)); + $this->policies->shouldReceive('findById')->once()->with(4)->andReturn(new Policy('T', 't', null, id: 4)); $this->versions->shouldReceive('findById')->once()->with(9)->andReturn(new PolicyVersion(99, 1, null, PolicyVersion::STATUS_DRAFT, null, 9)); // No status/current-version writes when the version belongs elsewhere. diff --git a/tests/Unit/Policy/PolicyValueObjectsTest.php b/tests/Unit/Policy/PolicyValueObjectsTest.php index 51b8c76..00e400a 100644 --- a/tests/Unit/Policy/PolicyValueObjectsTest.php +++ b/tests/Unit/Policy/PolicyValueObjectsTest.php @@ -17,12 +17,14 @@ class PolicyValueObjectsTest extends TestCase 'title' => 'Cancellation', 'slug' => 'cancellation', 'current_version_id' => '9', + 'acceptance_scope' => Policy::SCOPE_BOTH, ]); self::assertSame(4, $policy->id); self::assertSame('cancellation', $policy->slug); self::assertSame(9, $policy->currentVersionId); - self::assertArrayHasKey('current_version_id', $policy->toArray()); + self::assertSame(Policy::SCOPE_BOTH, $policy->acceptanceScope); + self::assertArrayHasKey('acceptance_scope', $policy->toArray()); } public function testPolicyHandlesNullCurrentVersion(): void @@ -32,11 +34,23 @@ class PolicyValueObjectsTest extends TestCase 'title' => 'Cancellation', 'slug' => 'cancellation', 'current_version_id' => null, + 'acceptance_scope' => Policy::SCOPE_BOOKING, ]); self::assertNull($policy->currentVersionId); } + public function testPolicyAppliesToScopeAndBoth(): void + { + $signup = new Policy('Terms', 'terms', acceptanceScope: Policy::SCOPE_SIGNUP); + self::assertTrue($signup->appliesTo(Policy::SCOPE_SIGNUP)); + self::assertFalse($signup->appliesTo(Policy::SCOPE_BOOKING)); + + $both = new Policy('Both', 'both', acceptanceScope: Policy::SCOPE_BOTH); + self::assertTrue($both->appliesTo(Policy::SCOPE_SIGNUP)); + self::assertTrue($both->appliesTo(Policy::SCOPE_BOOKING)); + } + public function testPolicyVersionFromRowAndStatusHelper(): void { $version = PolicyVersion::fromRow((object) [ -- 2.52.0