From 0d9aafbb5b8388a3ad6b8e8acdbf3715229bfd88 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Wed, 22 Jul 2026 11:37:14 -0300 Subject: [PATCH] Bump plugin version so the us_invites schema migration actually runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #83 added kind and expires_at to us_invites and the repository started writing them, but USC_VERSION stayed at 1.0.0-rc.2 — Plugin::boot() only re-runs Installer/dbDelta on a version mismatch, so upgraded sites never got the columns. Every invite insert then failed silently: nothing appeared under Pending Invites while the admin was still shown a registration link whose token hash was never stored. - Version / USC_VERSION -> 1.0.0-rc.3 (triggers dbDelta on next load). - InviteRepository::insert() returns 0 on failure instead of a stale insert_id, and the Invites page now shows an error notice instead of a dead link when creation fails (personal and group forms), including clearer validation messages. - CLAUDE.md: schema changes must bump the version. Closes #87 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 1 + src/Auth/InviteRepository.php | 8 ++- src/Auth/RegistrationController.php | 91 +++++++++++++++--------- templates/admin/invites.php | 7 ++ tests/Unit/Auth/InviteRepositoryTest.php | 10 +++ unsupervised-schedular.php | 4 +- 6 files changed, 83 insertions(+), 38 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3814baf..ad8ce3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,7 @@ All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp() - When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method ### Adding a Feature +0. **If the feature touches `Schema.php`, bump both the `Version:` header and `USC_VERSION` in `unsupervised-schedular.php`.** `Plugin::boot()` only re-runs `Installer`/`dbDelta` when the stored `us_schedular_version` differs, so a schema change without a version bump never reaches existing sites and inserts into new columns fail silently. 1. Write the feature doc in `docs/features/.md` (data model, API, classes, test paths). 2. Create a domain package under `src//` containing all classes for that feature. 3. Add template(s) under `templates/` if needed. diff --git a/src/Auth/InviteRepository.php b/src/Auth/InviteRepository.php index 1ce6116..b3570f9 100644 --- a/src/Auth/InviteRepository.php +++ b/src/Auth/InviteRepository.php @@ -11,8 +11,12 @@ class InviteRepository { $this->table = $db->prefix . 'us_invites'; } + /** + * Persist an invite. Returns the new row id, or 0 when the insert failed — + * callers must not hand out a registration link for an unstored token. + */ public function insert( Invite $invite ): int { - $this->db->insert( + $result = $this->db->insert( $this->table, [ 'email' => $invite->email, @@ -29,7 +33,7 @@ class InviteRepository { [ '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' ] ); - return $this->db->insert_id; + return false === $result ? 0 : $this->db->insert_id; } public function findByToken( string $token ): ?Invite { diff --git a/src/Auth/RegistrationController.php b/src/Auth/RegistrationController.php index 8d93ad2..a25f611 100644 --- a/src/Auth/RegistrationController.php +++ b/src/Auth/RegistrationController.php @@ -20,8 +20,9 @@ class RegistrationController { } $newInviteUrl = ''; + $inviteError = ''; if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_invite_action' ) ) { - $newInviteUrl = $this->handleFormAction(); + [ $newInviteUrl, $inviteError ] = $this->handleFormAction(); } $pendingInvites = $this->invites->findPending(); @@ -32,11 +33,14 @@ class RegistrationController { } /** - * Handle a posted admin action. Returns the registration link for a freshly - * created invite — the only time it can be shown, since just the token's hash - * is stored — or an empty string for every other action. + * Handle a posted admin action. Returns `[link, error]`: the registration + * link for a freshly created invite — the only time it can be shown, since + * just the token's hash is stored — or an error message when creation + * failed; both empty for every other action. + * + * @return array{string, string} */ - private function handleFormAction(): string { + private function handleFormAction(): array { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); @@ -49,42 +53,46 @@ class RegistrationController { $email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) ); if ( - is_email( $email ) - && false === email_exists( $email ) - && null === $this->invites->findPendingByEmail( $email ) + ! is_email( $email ) + || false !== email_exists( $email ) + || null !== $this->invites->findPendingByEmail( $email ) ) { - $rawToken = wp_generate_password( 32, false ); - - $this->invites->insert( - new Invite( - email: $email, - token: Invite::hashToken( $rawToken ), - invitedBy: get_current_user_id(), - ) - ); - - return $this->registrationLink( $rawToken ); + return [ '', esc_html__( 'Could not create the invite: enter a valid email address that has no account and no pending invite.', 'unsupervised-schedular' ) ]; } + + $rawToken = wp_generate_password( 32, false ); + + $id = $this->invites->insert( + new Invite( + email: $email, + token: Invite::hashToken( $rawToken ), + invitedBy: get_current_user_id(), + ) + ); + + return $this->linkOrError( $id, $rawToken ); } if ( 'group_invite' === $action ) { $expiresAt = $this->normalizeExpiry( sanitize_text_field( Val::string( wp_unslash( $_POST['expires_at'] ?? '' ) ) ) ); - if ( null !== $expiresAt ) { - $rawToken = wp_generate_password( 32, false ); - - $this->invites->insert( - new Invite( - email: '', - token: Invite::hashToken( $rawToken ), - invitedBy: get_current_user_id(), - kind: Invite::KIND_GROUP, - expiresAt: $expiresAt, - ) - ); - - return $this->registrationLink( $rawToken ); + if ( null === $expiresAt ) { + return [ '', esc_html__( 'Could not create the group link: choose an expiry date of today or later.', 'unsupervised-schedular' ) ]; } + + $rawToken = wp_generate_password( 32, false ); + + $id = $this->invites->insert( + new Invite( + email: '', + token: Invite::hashToken( $rawToken ), + invitedBy: get_current_user_id(), + kind: Invite::KIND_GROUP, + expiresAt: $expiresAt, + ) + ); + + return $this->linkOrError( $id, $rawToken ); } if ( 'revoke' === $action ) { @@ -95,7 +103,22 @@ class RegistrationController { } // phpcs:enable WordPress.Security.NonceVerification.Missing - return ''; + return [ '', '' ]; + } + + /** + * The registration link for a stored invite, or an error when the insert + * failed — a link must never be shown for a token that was not persisted, + * since it could only ever dead-end as "invalid or expired". + * + * @return array{string, string} + */ + private function linkOrError( int $insertedId, string $rawToken ): array { + if ( $insertedId <= 0 ) { + return [ '', esc_html__( 'Could not save the invite. Deactivate and reactivate the plugin to update the database, then try again.', 'unsupervised-schedular' ) ]; + } + + return [ $this->registrationLink( $rawToken ), '' ]; } /** diff --git a/templates/admin/invites.php b/templates/admin/invites.php index 26bfb1b..a47727c 100644 --- a/templates/admin/invites.php +++ b/templates/admin/invites.php @@ -10,12 +10,19 @@ if (! defined('ABSPATH')) { * @var int $registrationPageId * @var string $registrationPageUrl * @var string $newInviteUrl One-time registration link for a just-created invite. + * @var string $inviteError Error message when invite creation failed. */ ?>

+ +
+

+
+ +

diff --git a/tests/Unit/Auth/InviteRepositoryTest.php b/tests/Unit/Auth/InviteRepositoryTest.php index a232208..70f3be2 100644 --- a/tests/Unit/Auth/InviteRepositoryTest.php +++ b/tests/Unit/Auth/InviteRepositoryTest.php @@ -46,6 +46,16 @@ class InviteRepositoryTest extends TestCase self::assertSame(5, $this->repo->insert(new Invite('a@b.test', 'tok123', invitedBy: 2))); } + public function testInsertReturnsZeroWhenDbInsertFails(): void + { + Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00'); + + $this->db->shouldReceive('insert')->once()->andReturn(false); + $this->db->insert_id = 99; // Stale id from an earlier insert must not leak out. + + self::assertSame(0, $this->repo->insert(new Invite('a@b.test', 'tok123'))); + } + public function testInsertPersistsGroupKindAndExpiry(): void { Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00'); diff --git a/unsupervised-schedular.php b/unsupervised-schedular.php index a14febd..b8fd861 100644 --- a/unsupervised-schedular.php +++ b/unsupervised-schedular.php @@ -3,7 +3,7 @@ * Plugin Name: Unsupervised Scheduler * Plugin URI: https://unsupervised.ca * Description: Instructor/student lesson scheduling for WordPress. - * Version: 1.0.0-rc.2 + * Version: 1.0.0-rc.3 * Requires at least: 6.2 * Requires PHP: 8.1 * Author: Unsupervised @@ -20,7 +20,7 @@ if (! defined('ABSPATH')) { exit; } -define('USC_VERSION', '1.0.0-rc.2'); +define('USC_VERSION', '1.0.0-rc.3'); define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); -- 2.54.0