Bump plugin version so the us_invites schema migration actually runs
CI / Tests (PHP 8.2) (pull_request) Successful in 37s
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / PHPStan (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 37s
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / PHPStan (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
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 <[email protected]>
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,13 +53,16 @@ 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 )
|
||||||
) {
|
) {
|
||||||
|
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 );
|
$rawToken = wp_generate_password( 32, false );
|
||||||
|
|
||||||
$this->invites->insert(
|
$id = $this->invites->insert(
|
||||||
new Invite(
|
new Invite(
|
||||||
email: $email,
|
email: $email,
|
||||||
token: Invite::hashToken( $rawToken ),
|
token: Invite::hashToken( $rawToken ),
|
||||||
@@ -63,17 +70,19 @@ class RegistrationController {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->registrationLink( $rawToken );
|
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 ) {
|
||||||
|
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 );
|
$rawToken = wp_generate_password( 32, false );
|
||||||
|
|
||||||
$this->invites->insert(
|
$id = $this->invites->insert(
|
||||||
new Invite(
|
new Invite(
|
||||||
email: '',
|
email: '',
|
||||||
token: Invite::hashToken( $rawToken ),
|
token: Invite::hashToken( $rawToken ),
|
||||||
@@ -83,8 +92,7 @@ class RegistrationController {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->registrationLink( $rawToken );
|
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 ), '' ];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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__));
|
||||||
|
|||||||
Reference in New Issue
Block a user