Bump plugin version so the us_invites schema migration actually runs #88

Merged
thatguygriff merged 1 commits from fix/invite-schema-migration into main 2026-07-22 14:41:20 +00:00
6 changed files with 83 additions and 38 deletions
+1
View File
@@ -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 - When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method
### Adding a Feature ### 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/<feature-name>.md` (data model, API, classes, test paths). 1. Write the feature doc in `docs/features/<feature-name>.md` (data model, API, classes, test paths).
2. Create a domain package under `src/<Domain>/` containing all classes for that feature. 2. Create a domain package under `src/<Domain>/` containing all classes for that feature.
3. Add template(s) under `templates/` if needed. 3. Add template(s) under `templates/` if needed.
+6 -2
View File
@@ -11,8 +11,12 @@ class InviteRepository {
$this->table = $db->prefix . 'us_invites'; $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 { public function insert( Invite $invite ): int {
$this->db->insert( $result = $this->db->insert(
$this->table, $this->table,
[ [
'email' => $invite->email, 'email' => $invite->email,
@@ -29,7 +33,7 @@ class InviteRepository {
[ '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' ] [ '%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 { public function findByToken( string $token ): ?Invite {
+57 -34
View File
@@ -20,8 +20,9 @@ class RegistrationController {
} }
$newInviteUrl = ''; $newInviteUrl = '';
$inviteError = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_invite_action' ) ) { if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_invite_action' ) ) {
$newInviteUrl = $this->handleFormAction(); [ $newInviteUrl, $inviteError ] = $this->handleFormAction();
} }
$pendingInvites = $this->invites->findPending(); $pendingInvites = $this->invites->findPending();
@@ -32,11 +33,14 @@ class RegistrationController {
} }
/** /**
* Handle a posted admin action. Returns the registration link for a freshly * Handle a posted admin action. Returns `[link, error]`: the registration
* created invite — the only time it can be shown, since just the token's hash * link for a freshly created invite — the only time it can be shown, since
* is stored — or an empty string for every other action. * 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. // Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); $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'] ?? '' ) ) ); $email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
if ( if (
is_email( $email ) ! is_email( $email )
&& false === email_exists( $email ) || false !== email_exists( $email )
&& null === $this->invites->findPendingByEmail( $email ) || null !== $this->invites->findPendingByEmail( $email )
) { ) {
$rawToken = wp_generate_password( 32, false ); return [ '', esc_html__( 'Could not create the invite: enter a valid email address that has no account and no pending invite.', 'unsupervised-schedular' ) ];
$this->invites->insert(
new Invite(
email: $email,
token: Invite::hashToken( $rawToken ),
invitedBy: get_current_user_id(),
)
);
return $this->registrationLink( $rawToken );
} }
$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 ) { if ( 'group_invite' === $action ) {
$expiresAt = $this->normalizeExpiry( sanitize_text_field( Val::string( wp_unslash( $_POST['expires_at'] ?? '' ) ) ) ); $expiresAt = $this->normalizeExpiry( sanitize_text_field( Val::string( wp_unslash( $_POST['expires_at'] ?? '' ) ) ) );
if ( null !== $expiresAt ) { if ( null === $expiresAt ) {
$rawToken = wp_generate_password( 32, false ); return [ '', esc_html__( 'Could not create the group link: choose an expiry date of today or later.', 'unsupervised-schedular' ) ];
$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 );
} }
$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 ) { if ( 'revoke' === $action ) {
@@ -95,7 +103,22 @@ class RegistrationController {
} }
// phpcs:enable WordPress.Security.NonceVerification.Missing // 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 ), '' ];
} }
/** /**
+7
View File
@@ -10,12 +10,19 @@ if (! defined('ABSPATH')) {
* @var int $registrationPageId * @var int $registrationPageId
* @var string $registrationPageUrl * @var string $registrationPageUrl
* @var string $newInviteUrl One-time registration link for a just-created invite. * @var string $newInviteUrl One-time registration link for a just-created invite.
* @var string $inviteError Error message when invite creation failed.
*/ */
?> ?>
<div class="wrap"> <div class="wrap">
<h1><?php esc_html_e('Invites', 'unsupervised-schedular'); ?></h1> <h1><?php esc_html_e('Invites', 'unsupervised-schedular'); ?></h1>
<p class="description"><?php esc_html_e('Invite a student by email, then send them the registration link. They complete signup and accept any required policies through the [us_student_register] page.', 'unsupervised-schedular'); ?></p> <p class="description"><?php esc_html_e('Invite a student by email, then send them the registration link. They complete signup and accept any required policies through the [us_student_register] page.', 'unsupervised-schedular'); ?></p>
<?php if ($inviteError !== '') : ?>
<div class="notice notice-error inline">
<p><?php echo esc_html($inviteError); ?></p>
</div>
<?php endif; ?>
<?php if ($newInviteUrl !== '') : ?> <?php if ($newInviteUrl !== '') : ?>
<div class="notice notice-success inline"> <div class="notice notice-success inline">
<p><?php esc_html_e('Invite created. Copy the registration link now — for security it is not stored and cannot be shown again. To re-send a lost link, revoke the invite and create a new one.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('Invite created. Copy the registration link now — for security it is not stored and cannot be shown again. To re-send a lost link, revoke the invite and create a new one.', 'unsupervised-schedular'); ?></p>
+10
View File
@@ -46,6 +46,16 @@ class InviteRepositoryTest extends TestCase
self::assertSame(5, $this->repo->insert(new Invite('[email protected]', 'tok123', invitedBy: 2))); self::assertSame(5, $this->repo->insert(new Invite('[email protected]', '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('[email protected]', 'tok123')));
}
public function testInsertPersistsGroupKindAndExpiry(): void public function testInsertPersistsGroupKindAndExpiry(): void
{ {
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00'); Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00');
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler * Plugin Name: Unsupervised Scheduler
* Plugin URI: https://unsupervised.ca * Plugin URI: https://unsupervised.ca
* Description: Instructor/student lesson scheduling for WordPress. * 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 at least: 6.2
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: Unsupervised * Author: Unsupervised
@@ -20,7 +20,7 @@ if (! defined('ABSPATH')) {
exit; 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_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));