From 0f30f28e928417cb46c48a8254a62699926bb43f Mon Sep 17 00:00:00 2001 From: James Griffin Date: Tue, 28 Jul 2026 12:54:58 -0300 Subject: [PATCH] Let offering managers read the offerings catalogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block editor's group-class picker fetches GET /offerings, whose permission callback only accepted book_lesson — a capability held by students alone. Administrators and instructors editing a page were rejected with a 403 and the picker silently rendered an empty list. Read access now accepts book_lesson or manage_offerings. The listing is unchanged: active offerings only, public ones plus the invite-only classes the caller has been granted, without the e-transfer email. Closes #121 Co-Authored-By: Claude Opus 5 --- docs/features/offerings.md | 2 +- src/Offering/OfferingEndpoint.php | 16 +++++---- tests/Unit/Offering/OfferingEndpointTest.php | 37 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/docs/features/offerings.md b/docs/features/offerings.md index c3be69a..c649323 100644 --- a/docs/features/offerings.md +++ b/docs/features/offerings.md @@ -107,7 +107,7 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin. ## REST API | Method | Endpoint | Permission | |----------|---------------------------------------------|----------------------------------| -| `GET` | `/wp-json/us-scheduler/v1/offerings` | Public (active offerings only) | +| `GET` | `/wp-json/us-scheduler/v1/offerings` | `book_lesson` or `manage_offerings` (active offerings only) | | `POST` | `/wp-json/us-scheduler/v1/offerings` | `manage_offerings` | | `PATCH` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner | | `DELETE` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner | diff --git a/src/Offering/OfferingEndpoint.php b/src/Offering/OfferingEndpoint.php index b887b25..d0faac5 100644 --- a/src/Offering/OfferingEndpoint.php +++ b/src/Offering/OfferingEndpoint.php @@ -28,7 +28,7 @@ class OfferingEndpoint { [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'index' ], - 'permission_callback' => [ $this, 'canBook' ], + 'permission_callback' => [ $this, 'canRead' ], 'args' => [ 'instructor_id' => [ 'type' => 'integer', @@ -262,12 +262,16 @@ class OfferingEndpoint { } /** - * Reading the offerings catalogue is only needed by the logged-in student - * booking flow, so it requires the same capability as booking — there is no - * anonymous consumer. + * Reading the offerings catalogue has no anonymous consumer, so it stays + * behind a login. Students reach it through the booking flow, and studio + * admins and instructors reach it from the block editor's group-class + * pickers — an administrator holds `manage_offerings` but not + * `book_lesson`, so both capabilities open the listing. */ - public function canBook(): bool { - return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON ); + public function canRead(): bool { + return is_user_logged_in() + && ( current_user_can( RoleManager::CAP_BOOK_LESSON ) + || current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) ); } /** diff --git a/tests/Unit/Offering/OfferingEndpointTest.php b/tests/Unit/Offering/OfferingEndpointTest.php index 5620cca..7229ea8 100644 --- a/tests/Unit/Offering/OfferingEndpointTest.php +++ b/tests/Unit/Offering/OfferingEndpointTest.php @@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Offering; use Brain\Monkey\Functions; use Mockery; +use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingEndpoint; @@ -119,6 +120,42 @@ class OfferingEndpointTest extends TestCase self::assertArrayNotHasKey('etransfer_email', $data[0]); } + public function testCanReadAllowsStudentsWhoMayBook(): void + { + Functions\when('is_user_logged_in')->justReturn(true); + Functions\when('current_user_can')->alias( + static fn (string $cap): bool => RoleManager::CAP_BOOK_LESSON === $cap + ); + + self::assertTrue($this->endpoint->canRead()); + } + + public function testCanReadAllowsOfferingManagersWhoCannotBook(): void + { + Functions\when('is_user_logged_in')->justReturn(true); + Functions\when('current_user_can')->alias( + static fn (string $cap): bool => RoleManager::CAP_MANAGE_OFFERINGS === $cap + ); + + self::assertTrue($this->endpoint->canRead()); + } + + public function testCanReadRejectsLoggedInUserWithNeitherCapability(): void + { + Functions\when('is_user_logged_in')->justReturn(true); + Functions\when('current_user_can')->justReturn(false); + + self::assertFalse($this->endpoint->canRead()); + } + + public function testCanReadRejectsLoggedOutVisitors(): void + { + Functions\when('is_user_logged_in')->justReturn(false); + Functions\when('current_user_can')->justReturn(true); + + self::assertFalse($this->endpoint->canRead()); + } + public function testCreateRejectsTitleLongerThanColumnLimit(): void { Functions\when('sanitize_text_field')->returnArg();