From 36331388d13b1f8b319af748bf63607acccb931a Mon Sep 17 00:00:00 2001 From: James Griffin Date: Fri, 5 Jun 2026 10:33:02 -0300 Subject: [PATCH] Add Offerings domain and studio-admin capabilities Implements the offerings catalog (#1): private-lesson types and group classes carrying pricing, billing mode (one_time/full_term), duration, capacity, and term details. Adds the src/Offering/ domain (value object, repository, REST endpoint, admin controller + template), the us_offerings table, and an Offerings admin page. Also lands the capability slice of #9: registers the us_studio_admin role and the new capability strings (manage_instructors, manage_offerings, manage_questions, manage_policies, manage_billing, view_all_payments, view_own_payments, export_payments) so offering management gates correctly. Tests: tests/Unit/Offering/ (value object + repository) and a studio-admin case in RoleManagerTest. composer test, cs, and PHPStan level 6 all pass. Refs #1 #9 Co-Authored-By: Claude Opus 4.8 --- src/AdminMenu.php | 17 +- src/Auth/RoleManager.php | 35 +++- src/Offering/Offering.php | 90 ++++++++ src/Offering/OfferingController.php | 88 ++++++++ src/Offering/OfferingEndpoint.php | 196 ++++++++++++++++++ src/Offering/OfferingRepository.php | 107 ++++++++++ src/Plugin.php | 6 +- src/RestRegistrar.php | 7 +- src/Schema.php | 23 ++ templates/admin/offerings.php | 107 ++++++++++ tests/Unit/Auth/RoleManagerTest.php | 24 +++ .../Unit/Offering/OfferingRepositoryTest.php | 182 ++++++++++++++++ tests/Unit/Offering/OfferingTest.php | 93 +++++++++ 13 files changed, 969 insertions(+), 6 deletions(-) create mode 100644 src/Offering/Offering.php create mode 100644 src/Offering/OfferingController.php create mode 100644 src/Offering/OfferingEndpoint.php create mode 100644 src/Offering/OfferingRepository.php create mode 100644 templates/admin/offerings.php create mode 100644 tests/Unit/Offering/OfferingRepositoryTest.php create mode 100644 tests/Unit/Offering/OfferingTest.php diff --git a/src/AdminMenu.php b/src/AdminMenu.php index bcdb486..956d531 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -8,15 +8,19 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\LessonController; +use Unsupervised\Schedular\Offering\OfferingController; +use Unsupervised\Schedular\Offering\OfferingRepository; class AdminMenu { private AvailabilityController $availabilityController; private LessonController $lessonController; + private OfferingController $offeringController; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings ) { $this->availabilityController = new AvailabilityController( $availability ); $this->lessonController = new LessonController( $bookings ); + $this->offeringController = new OfferingController( $offerings ); } public function register(): void { @@ -46,6 +50,17 @@ class AdminMenu { 31 ); + // Studio admin / instructor: manage offerings. + add_menu_page( + __( 'Offerings', 'unsupervised-schedular' ), + __( 'Offerings', 'unsupervised-schedular' ), + RoleManager::CAP_MANAGE_OFFERINGS, + 'us-offerings', + [ $this->offeringController, 'renderPage' ], + 'dashicons-tag', + 33 + ); + // Instructor: view their upcoming lessons. add_menu_page( __( 'My Lessons', 'unsupervised-schedular' ), diff --git a/src/Auth/RoleManager.php b/src/Auth/RoleManager.php index e86bf01..c107dce 100644 --- a/src/Auth/RoleManager.php +++ b/src/Auth/RoleManager.php @@ -5,18 +5,45 @@ namespace Unsupervised\Schedular\Auth; class RoleManager { - public const INSTRUCTOR = 'us_instructor'; - public const STUDENT = 'us_student'; + public const STUDIO_ADMIN = 'us_studio_admin'; + public const INSTRUCTOR = 'us_instructor'; + public const STUDENT = 'us_student'; public const CAP_MANAGE_AVAILABILITY = 'manage_availability'; public const CAP_VIEW_LESSONS = 'view_own_lessons'; public const CAP_BOOK_LESSON = 'book_lesson'; + public const CAP_MANAGE_INSTRUCTORS = 'manage_instructors'; + public const CAP_MANAGE_OFFERINGS = 'manage_offerings'; + public const CAP_MANAGE_QUESTIONS = 'manage_questions'; + public const CAP_MANAGE_POLICIES = 'manage_policies'; + public const CAP_MANAGE_BILLING = 'manage_billing'; + public const CAP_VIEW_ALL_PAYMENTS = 'view_all_payments'; + public const CAP_VIEW_OWN_PAYMENTS = 'view_own_payments'; + public const CAP_EXPORT_PAYMENTS = 'export_payments'; + public function register(): void { add_action( 'init', [ $this, 'createRoles' ] ); } public function createRoles(): void { + if ( get_role( self::STUDIO_ADMIN ) === null ) { + add_role( + self::STUDIO_ADMIN, + __( 'Studio Admin', 'unsupervised-schedular' ), + [ + 'read' => true, + self::CAP_MANAGE_INSTRUCTORS => true, + self::CAP_MANAGE_OFFERINGS => true, + self::CAP_MANAGE_QUESTIONS => true, + self::CAP_MANAGE_POLICIES => true, + self::CAP_MANAGE_BILLING => true, + self::CAP_VIEW_ALL_PAYMENTS => true, + self::CAP_EXPORT_PAYMENTS => true, + ] + ); + } + if ( get_role( self::INSTRUCTOR ) === null ) { add_role( self::INSTRUCTOR, @@ -24,7 +51,11 @@ class RoleManager { [ 'read' => true, self::CAP_MANAGE_AVAILABILITY => true, + self::CAP_MANAGE_OFFERINGS => true, + self::CAP_MANAGE_QUESTIONS => true, self::CAP_VIEW_LESSONS => true, + self::CAP_VIEW_OWN_PAYMENTS => true, + self::CAP_EXPORT_PAYMENTS => true, ] ); } diff --git a/src/Offering/Offering.php b/src/Offering/Offering.php new file mode 100644 index 0000000..c73db77 --- /dev/null +++ b/src/Offering/Offering.php @@ -0,0 +1,90 @@ + + */ + public const VALID_KINDS = [ self::KIND_PRIVATE_LESSON, self::KIND_GROUP_CLASS ]; + + public const BILLING_ONE_TIME = 'one_time'; + public const BILLING_FULL_TERM = 'full_term'; + + /** + * All valid billing modes. + * + * @var list + */ + public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ]; + + public function __construct( + public readonly int $instructorId, + public readonly string $kind, + public readonly string $title, + public readonly int $priceCents = 0, + public readonly string $currency = 'CAD', + public readonly string $billingMode = self::BILLING_ONE_TIME, + public readonly ?string $description = null, + public readonly ?int $durationMinutes = null, + public readonly bool $allowWeekly = false, + public readonly ?int $capacity = null, + public readonly ?string $termStart = null, + public readonly ?string $termEnd = null, + public readonly ?string $scheduleNote = null, + public readonly bool $isActive = true, + public readonly ?int $id = null, + ) {} + + public static function fromRow( object $row ): self { + return new self( + instructorId: (int) $row->instructor_id, + kind: $row->kind, + title: $row->title, + priceCents: (int) $row->price_cents, + currency: $row->currency, + billingMode: $row->billing_mode, + description: $row->description, + durationMinutes: null !== $row->duration_minutes ? (int) $row->duration_minutes : null, + allowWeekly: (bool) $row->allow_weekly, + capacity: null !== $row->capacity ? (int) $row->capacity : null, + termStart: $row->term_start, + termEnd: $row->term_end, + scheduleNote: $row->schedule_note, + isActive: (bool) $row->is_active, + id: (int) $row->id, + ); + } + + /** + * Returns a plain array representation of the offering. + * + * @return array + */ + public function toArray(): array { + return [ + 'id' => $this->id, + 'instructor_id' => $this->instructorId, + 'kind' => $this->kind, + 'title' => $this->title, + 'description' => $this->description, + 'duration_minutes' => $this->durationMinutes, + 'price_cents' => $this->priceCents, + 'currency' => $this->currency, + 'billing_mode' => $this->billingMode, + 'allow_weekly' => $this->allowWeekly, + 'capacity' => $this->capacity, + 'term_start' => $this->termStart, + 'term_end' => $this->termEnd, + 'schedule_note' => $this->scheduleNote, + 'is_active' => $this->isActive, + ]; + } +} diff --git a/src/Offering/OfferingController.php b/src/Offering/OfferingController.php new file mode 100644 index 0000000..c1e8646 --- /dev/null +++ b/src/Offering/OfferingController.php @@ -0,0 +1,88 @@ +handleFormAction( $instructorId, $manageAll ); + } + + $offerings = $manageAll + ? $this->repository->findAll() + : $this->repository->findAll( $instructorId ); + + include USC_PLUGIN_DIR . 'templates/admin/offerings.php'; + } + + private function handleFormAction( int $instructorId, bool $manageAll ): 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 ( 'add' === $action ) { + $this->addOffering( $instructorId ); + } + + if ( 'delete' === $action ) { + $offeringId = absint( $_POST['offering_id'] ?? 0 ); + if ( $offeringId > 0 ) { + $offering = $this->repository->findById( $offeringId ); + if ( $offering && ( $manageAll || $offering->instructorId === $instructorId ) ) { + $this->repository->delete( $offeringId ); + } + } + } + // phpcs:enable WordPress.Security.NonceVerification.Missing + } + + private function addOffering( int $instructorId ): void { + // phpcs:disable WordPress.Security.NonceVerification.Missing + $title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) ); + $kind = sanitize_key( wp_unslash( $_POST['kind'] ?? '' ) ); + + if ( '' === $title || ! in_array( $kind, Offering::VALID_KINDS, true ) ) { + return; + } + + $billingMode = sanitize_key( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ); + if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) { + $billingMode = Offering::BILLING_ONE_TIME; + } + + $duration = absint( $_POST['duration_minutes'] ?? 0 ); + $capacity = absint( $_POST['capacity'] ?? 0 ); + + $this->repository->insert( + new Offering( + instructorId: $instructorId, + kind: $kind, + title: $title, + priceCents: absint( $_POST['price_cents'] ?? 0 ), + billingMode: $billingMode, + durationMinutes: $duration > 0 ? $duration : null, + allowWeekly: isset( $_POST['allow_weekly'] ), + capacity: $capacity > 0 ? $capacity : null, + scheduleNote: $this->nullableText( sanitize_text_field( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ), + ) + ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + } + + private function nullableText( string $value ): ?string { + return '' === $value ? null : $value; + } +} diff --git a/src/Offering/OfferingEndpoint.php b/src/Offering/OfferingEndpoint.php new file mode 100644 index 0000000..457f354 --- /dev/null +++ b/src/Offering/OfferingEndpoint.php @@ -0,0 +1,196 @@ + \WP_REST_Server::READABLE, + 'callback' => [ $this, 'index' ], + 'permission_callback' => '__return_true', + 'args' => [ + 'instructor_id' => [ + 'type' => 'integer', + 'default' => 0, + ], + 'kind' => [ + 'type' => 'string', + 'default' => '', + ], + ], + ], + [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'create' ], + 'permission_callback' => [ $this, 'canManage' ], + ], + ] + ); + + register_rest_route( + $route_namespace, + '/offerings/(?P\d+)', + [ + [ + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => [ $this, 'update' ], + 'permission_callback' => [ $this, 'canManage' ], + ], + [ + 'methods' => \WP_REST_Server::DELETABLE, + 'callback' => [ $this, 'delete' ], + 'permission_callback' => [ $this, 'canManage' ], + ], + ] + ); + } + + public function index( \WP_REST_Request $request ): \WP_REST_Response { + $offerings = $this->repository->findAll( + (int) $request->get_param( 'instructor_id' ), + (string) $request->get_param( 'kind' ), + activeOnly: true, + ); + + return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray(), $offerings ), 200 ); + } + + public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $title = sanitize_text_field( (string) $request->get_param( 'title' ) ); + if ( '' === $title ) { + return $this->invalid( __( 'A title is required.', 'unsupervised-schedular' ) ); + } + + $kind = (string) $request->get_param( 'kind' ); + if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) { + return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) ); + } + + $billingMode = (string) ( $request->get_param( 'billing_mode' ) ?? Offering::BILLING_ONE_TIME ); + if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) { + return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) ); + } + + $offering = new Offering( + instructorId: get_current_user_id(), + kind: $kind, + title: $title, + priceCents: absint( $request->get_param( 'price_cents' ) ), + currency: sanitize_text_field( (string) ( $request->get_param( 'currency' ) ?? 'CAD' ) ), + billingMode: $billingMode, + description: $this->nullableText( $request->get_param( 'description' ) ), + durationMinutes: $this->nullableInt( $request->get_param( 'duration_minutes' ) ), + allowWeekly: (bool) $request->get_param( 'allow_weekly' ), + capacity: $this->nullableInt( $request->get_param( 'capacity' ) ), + termStart: $this->nullableText( $request->get_param( 'term_start' ) ), + termEnd: $this->nullableText( $request->get_param( 'term_end' ) ), + scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ), + isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ), + ); + + $id = $this->repository->insert( $offering ); + + return new \WP_REST_Response( [ 'id' => $id ], 201 ); + } + + public function update( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $id = absint( $request->get_param( 'id' ) ); + $existing = $this->repository->findById( $id ); + + if ( null === $existing ) { + return new \WP_Error( 'not_found', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); + } + + if ( ! $this->ownsOrManagesAll( $existing ) ) { + return new \WP_Error( 'forbidden', __( 'You cannot edit this offering.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); + } + + $kind = $request->has_param( 'kind' ) ? (string) $request->get_param( 'kind' ) : $existing->kind; + if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) { + return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) ); + } + + $billingMode = $request->has_param( 'billing_mode' ) ? (string) $request->get_param( 'billing_mode' ) : $existing->billingMode; + if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) { + return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) ); + } + + $offering = new Offering( + instructorId: $existing->instructorId, + kind: $kind, + title: $request->has_param( 'title' ) ? sanitize_text_field( (string) $request->get_param( 'title' ) ) : $existing->title, + priceCents: $request->has_param( 'price_cents' ) ? absint( $request->get_param( 'price_cents' ) ) : $existing->priceCents, + currency: $request->has_param( 'currency' ) ? sanitize_text_field( (string) $request->get_param( 'currency' ) ) : $existing->currency, + billingMode: $billingMode, + description: $request->has_param( 'description' ) ? $this->nullableText( $request->get_param( 'description' ) ) : $existing->description, + durationMinutes: $request->has_param( 'duration_minutes' ) ? $this->nullableInt( $request->get_param( 'duration_minutes' ) ) : $existing->durationMinutes, + allowWeekly: $request->has_param( 'allow_weekly' ) ? (bool) $request->get_param( 'allow_weekly' ) : $existing->allowWeekly, + capacity: $request->has_param( 'capacity' ) ? $this->nullableInt( $request->get_param( 'capacity' ) ) : $existing->capacity, + termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart, + termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd, + scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote, + isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive, + id: $id, + ); + + $this->repository->update( $id, $offering ); + + return new \WP_REST_Response( $offering->toArray(), 200 ); + } + + public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $id = absint( $request->get_param( 'id' ) ); + $existing = $this->repository->findById( $id ); + + if ( null === $existing ) { + return new \WP_Error( 'not_found', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); + } + + if ( ! $this->ownsOrManagesAll( $existing ) ) { + return new \WP_Error( 'forbidden', __( 'You cannot delete this offering.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); + } + + $this->repository->delete( $id ); + + return new \WP_REST_Response( null, 204 ); + } + + public function canManage(): bool { + return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ); + } + + /** + * An offering may be changed by its owning instructor or by a studio admin + * (identified by the studio-only manage_instructors capability). + */ + private function ownsOrManagesAll( Offering $offering ): bool { + return get_current_user_id() === $offering->instructorId + || current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS ); + } + + private function invalid( string $message ): \WP_Error { + return new \WP_Error( 'invalid_offering', $message, [ 'status' => 400 ] ); + } + + private function nullableInt( mixed $value ): ?int { + return ( null === $value || '' === $value ) ? null : (int) $value; + } + + private function nullableText( mixed $value ): ?string { + if ( null === $value || '' === $value ) { + return null; + } + + return sanitize_text_field( (string) $value ); + } +} diff --git a/src/Offering/OfferingRepository.php b/src/Offering/OfferingRepository.php new file mode 100644 index 0000000..5142f4f --- /dev/null +++ b/src/Offering/OfferingRepository.php @@ -0,0 +1,107 @@ +table = $db->prefix . 'us_offerings'; + } + + public function insert( Offering $offering ): int { + $this->db->insert( + $this->table, + $this->columns( $offering ) + [ 'created_at' => current_time( 'mysql' ) ], + [ '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d', '%s' ] + ); + + return $this->db->insert_id; + } + + public function update( int $id, Offering $offering ): bool { + return false !== $this->db->update( + $this->table, + $this->columns( $offering ), + [ 'id' => $id ], + [ '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d' ], + [ '%d' ] + ); + } + + /** + * Column values shared by insert and update (excludes created_at). + * + * @return array + */ + private function columns( Offering $offering ): array { + return [ + 'instructor_id' => $offering->instructorId, + 'kind' => $offering->kind, + 'title' => $offering->title, + 'description' => $offering->description, + 'duration_minutes' => $offering->durationMinutes, + 'price_cents' => $offering->priceCents, + 'currency' => $offering->currency, + 'billing_mode' => $offering->billingMode, + 'allow_weekly' => $offering->allowWeekly ? 1 : 0, + 'capacity' => $offering->capacity, + 'term_start' => $offering->termStart, + 'term_end' => $offering->termEnd, + 'schedule_note' => $offering->scheduleNote, + 'is_active' => $offering->isActive ? 1 : 0, + ]; + } + + /** + * Find offerings, optionally filtered by instructor, kind, and active state. + * + * @return list + */ + public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null ): array { + $where = [ '1 = 1' ]; + $params = []; + + if ( $instructorId > 0 ) { + $where[] = 'instructor_id = %d'; + $params[] = $instructorId; + } + + if ( '' !== $kind ) { + $where[] = 'kind = %s'; + $params[] = $kind; + } + + if ( null !== $activeOnly ) { + $where[] = 'is_active = %d'; + $params[] = $activeOnly ? 1 : 0; + } + + $whereClause = implode( ' AND ', $where ); + $sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY title ASC"; + + $rows = $params + ? $this->db->get_results( $this->db->prepare( $sql, $params ) ) + : $this->db->get_results( $sql ); + + return array_map( Offering::fromRow( ... ), $rows ?? [] ); + } + + public function findById( int $id ): ?Offering { + $row = $this->db->get_row( + $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + ); + + return $row ? Offering::fromRow( $row ) : null; + } + + public function delete( int $id ): bool { + return (bool) $this->db->delete( + $this->table, + [ 'id' => $id ], + [ '%d' ] + ); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index b3a739f..22ee832 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -6,6 +6,7 @@ namespace Unsupervised\Schedular; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Booking\BookingRepository; +use Unsupervised\Schedular\Offering\OfferingRepository; class Plugin { @@ -15,10 +16,11 @@ class Plugin { global $wpdb; $availability = new AvailabilityRepository( $wpdb ); $bookings = new BookingRepository( $wpdb ); + $offerings = new OfferingRepository( $wpdb ); ( new RoleManager() )->register(); - ( new AdminMenu( $availability, $bookings ) )->register(); - ( new RestRegistrar( $availability, $bookings ) )->register(); + ( new AdminMenu( $availability, $bookings, $offerings ) )->register(); + ( new RestRegistrar( $availability, $bookings, $offerings ) )->register(); ( new ShortcodeRegistrar() )->register(); } } diff --git a/src/RestRegistrar.php b/src/RestRegistrar.php index 713c094..59ccf34 100644 --- a/src/RestRegistrar.php +++ b/src/RestRegistrar.php @@ -7,6 +7,8 @@ use Unsupervised\Schedular\Availability\AvailabilityEndpoint; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Booking\BookingEndpoint; use Unsupervised\Schedular\Booking\BookingRepository; +use Unsupervised\Schedular\Offering\OfferingEndpoint; +use Unsupervised\Schedular\Offering\OfferingRepository; class RestRegistrar { @@ -14,10 +16,12 @@ class RestRegistrar { private AvailabilityEndpoint $availabilityEndpoint; private BookingEndpoint $bookingEndpoint; + private OfferingEndpoint $offeringEndpoint; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings ) { $this->availabilityEndpoint = new AvailabilityEndpoint( $availability ); $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings ); + $this->offeringEndpoint = new OfferingEndpoint( $offerings ); } public function register(): void { @@ -27,5 +31,6 @@ class RestRegistrar { public function registerRoutes(): void { $this->availabilityEndpoint->registerRoutes( self::NAMESPACE ); $this->bookingEndpoint->registerRoutes( self::NAMESPACE ); + $this->offeringEndpoint->registerRoutes( self::NAMESPACE ); } } diff --git a/src/Schema.php b/src/Schema.php index 9449fa5..0ea26d1 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -37,6 +37,29 @@ class Schema { KEY student_id (student_id), KEY instructor_id (instructor_id) ) {$charset};", + + "CREATE TABLE {$prefix}us_offerings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + instructor_id BIGINT UNSIGNED NOT NULL, + kind VARCHAR(20) NOT NULL, + title VARCHAR(191) NOT NULL, + description TEXT, + duration_minutes SMALLINT UNSIGNED DEFAULT NULL, + price_cents INT UNSIGNED NOT NULL DEFAULT 0, + currency VARCHAR(3) NOT NULL DEFAULT 'CAD', + billing_mode VARCHAR(20) NOT NULL DEFAULT 'one_time', + allow_weekly TINYINT(1) NOT NULL DEFAULT 0, + capacity SMALLINT UNSIGNED DEFAULT NULL, + term_start DATE DEFAULT NULL, + term_end DATE DEFAULT NULL, + schedule_note VARCHAR(191) DEFAULT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY instructor_id (instructor_id), + KEY kind (kind), + KEY is_active (is_active) + ) {$charset};", ]; } } diff --git a/templates/admin/offerings.php b/templates/admin/offerings.php new file mode 100644 index 0000000..1b12fd4 --- /dev/null +++ b/templates/admin/offerings.php @@ -0,0 +1,107 @@ + $offerings */ +?> +
+

+ +

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

+ + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
title); ?>kind); ?>durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '—'; ?>priceCents / 100, 2) . ' ' . $offering->currency); ?>billingMode); ?>isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?> +
+ + + + +
+
+ +
diff --git a/tests/Unit/Auth/RoleManagerTest.php b/tests/Unit/Auth/RoleManagerTest.php index 3b5363d..6ef790a 100644 --- a/tests/Unit/Auth/RoleManagerTest.php +++ b/tests/Unit/Auth/RoleManagerTest.php @@ -47,6 +47,30 @@ class RoleManagerTest extends TestCase (new RoleManager())->createRoles(); } + public function testCreateRolesAddsStudioAdminRoleWithCorrectCaps(): void + { + Functions\when('get_role')->alias(static function (string $role): ?object { + return $role === RoleManager::STUDIO_ADMIN ? null : new \stdClass(); + }); + + Functions\expect('add_role') + ->once() + ->with( + RoleManager::STUDIO_ADMIN, + \Mockery::any(), + \Mockery::on(static function (array $caps): bool { + return ($caps['read'] ?? false) === true + && ($caps[RoleManager::CAP_MANAGE_INSTRUCTORS] ?? false) === true + && ($caps[RoleManager::CAP_MANAGE_OFFERINGS] ?? false) === true + && ($caps[RoleManager::CAP_MANAGE_POLICIES] ?? false) === true + && ($caps[RoleManager::CAP_MANAGE_BILLING] ?? false) === true + && ($caps[RoleManager::CAP_VIEW_ALL_PAYMENTS] ?? false) === true; + }) + ); + + (new RoleManager())->createRoles(); + } + public function testCreateRolesAddsStudentRoleWithCorrectCaps(): void { Functions\when('get_role')->alias(static function (string $role): ?object { diff --git a/tests/Unit/Offering/OfferingRepositoryTest.php b/tests/Unit/Offering/OfferingRepositoryTest.php new file mode 100644 index 0000000..86f88dc --- /dev/null +++ b/tests/Unit/Offering/OfferingRepositoryTest.php @@ -0,0 +1,182 @@ +db = Mockery::mock(\wpdb::class); + $this->db->prefix = 'wp_'; + $this->repo = new OfferingRepository($this->db); + } + + public function testInsertCallsWpdbInsertAndReturnsId(): void + { + Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00'); + + $this->db->shouldReceive('insert') + ->once() + ->with( + 'wp_us_offerings', + Mockery::on(static function (array $data): bool { + return $data['instructor_id'] === 5 + && $data['kind'] === Offering::KIND_PRIVATE_LESSON + && $data['title'] === '30-min Piano' + && $data['price_cents'] === 3500 + && $data['allow_weekly'] === 0 + && $data['is_active'] === 1 + && $data['created_at'] === '2026-04-01 12:00:00'; + }), + Mockery::type('array') + ); + + $this->db->insert_id = 42; + + $offering = new Offering( + instructorId: 5, + kind: Offering::KIND_PRIVATE_LESSON, + title: '30-min Piano', + priceCents: 3500, + durationMinutes: 30, + ); + + self::assertSame(42, $this->repo->insert($offering)); + } + + public function testUpdateReturnsTrueOnSuccess(): void + { + $this->db->shouldReceive('update') + ->once() + ->with( + 'wp_us_offerings', + Mockery::on(static fn (array $data): bool => $data['title'] === 'Renamed' && $data['allow_weekly'] === 1), + ['id' => 7], + Mockery::type('array'), + ['%d'] + ) + ->andReturn(1); + + $offering = new Offering( + instructorId: 5, + kind: Offering::KIND_PRIVATE_LESSON, + title: 'Renamed', + allowWeekly: true, + id: 7, + ); + + self::assertTrue($this->repo->update(7, $offering)); + } + + public function testUpdateReturnsFalseWhenWpdbReturnsFalse(): void + { + $this->db->shouldReceive('update')->once()->andReturn(false); + + $offering = new Offering(5, Offering::KIND_GROUP_CLASS, 'Choir', id: 9); + + self::assertFalse($this->repo->update(9, $offering)); + } + + public function testFindByIdReturnsNullWhenNotFound(): void + { + $this->db->shouldReceive('prepare')->once()->andReturn('SELECT ...'); + $this->db->shouldReceive('get_row')->once()->andReturn(null); + + self::assertNull($this->repo->findById(99)); + } + + public function testFindByIdReturnsOfferingWhenFound(): void + { + $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); + $this->db->shouldReceive('get_row')->andReturn($this->sampleRow()); + + $offering = $this->repo->findById(10); + + self::assertInstanceOf(Offering::class, $offering); + self::assertSame(10, $offering->id); + self::assertSame(3, $offering->instructorId); + } + + public function testFindAllWithNoFiltersUsesNoParams(): void + { + $this->db->shouldReceive('get_results') + ->once() + ->with(Mockery::pattern('/WHERE 1 = 1/')) + ->andReturn([$this->sampleRow()]); + + $offerings = $this->repo->findAll(); + + self::assertCount(1, $offerings); + self::assertInstanceOf(Offering::class, $offerings[0]); + } + + public function testFindAllActiveOnlyPreparesQuery(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/is_active = %d/'), Mockery::on(static fn (array $p): bool => $p === [1])) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_results')->andReturn([]); + + self::assertSame([], $this->repo->findAll(activeOnly: true)); + } + + public function testFindAllWithInstructorAndKindFilters(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::pattern('/instructor_id = %d AND kind = %s/'), + Mockery::on(static fn (array $p): bool => $p === [3, Offering::KIND_GROUP_CLASS]) + ) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_results')->andReturn([]); + + $this->repo->findAll(3, Offering::KIND_GROUP_CLASS); + } + + public function testDeleteCallsWpdbDelete(): void + { + $this->db->shouldReceive('delete') + ->once() + ->with('wp_us_offerings', ['id' => 4], ['%d']) + ->andReturn(1); + + self::assertTrue($this->repo->delete(4)); + } + + private function sampleRow(): object + { + return (object) [ + 'id' => '10', + 'instructor_id' => '3', + 'kind' => Offering::KIND_PRIVATE_LESSON, + 'title' => '30-min Piano', + 'description' => null, + 'duration_minutes' => '30', + 'price_cents' => '3500', + 'currency' => 'CAD', + 'billing_mode' => Offering::BILLING_ONE_TIME, + 'allow_weekly' => '0', + 'capacity' => null, + 'term_start' => null, + 'term_end' => null, + 'schedule_note' => null, + 'is_active' => '1', + ]; + } +} diff --git a/tests/Unit/Offering/OfferingTest.php b/tests/Unit/Offering/OfferingTest.php new file mode 100644 index 0000000..fc27e42 --- /dev/null +++ b/tests/Unit/Offering/OfferingTest.php @@ -0,0 +1,93 @@ +instructorId); + self::assertSame(Offering::KIND_PRIVATE_LESSON, $offering->kind); + self::assertSame('30-min Piano', $offering->title); + self::assertSame(3500, $offering->priceCents); + self::assertSame(30, $offering->durationMinutes); + self::assertSame(42, $offering->id); + } + + public function testDefaults(): void + { + $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir'); + + self::assertSame(0, $offering->priceCents); + self::assertSame('CAD', $offering->currency); + self::assertSame(Offering::BILLING_ONE_TIME, $offering->billingMode); + self::assertNull($offering->durationMinutes); + self::assertFalse($offering->allowWeekly); + self::assertNull($offering->capacity); + self::assertTrue($offering->isActive); + self::assertNull($offering->id); + } + + public function testFromRowMapsCorrectlyAndCastsNullables(): void + { + $row = (object) [ + 'id' => '7', + 'instructor_id' => '3', + 'kind' => Offering::KIND_GROUP_CLASS, + 'title' => 'Year Choir', + 'description' => 'Weekly choir', + 'duration_minutes' => null, + 'price_cents' => '12000', + 'currency' => 'CAD', + 'billing_mode' => Offering::BILLING_FULL_TERM, + 'allow_weekly' => '0', + 'capacity' => '20', + 'term_start' => '2026-09-01', + 'term_end' => '2027-06-30', + 'schedule_note' => 'Tuesdays 4:00pm', + 'is_active' => '1', + ]; + + $offering = Offering::fromRow($row); + + self::assertSame(7, $offering->id); + self::assertSame(3, $offering->instructorId); + self::assertNull($offering->durationMinutes); + self::assertSame(12000, $offering->priceCents); + self::assertSame(20, $offering->capacity); + self::assertSame(Offering::BILLING_FULL_TERM, $offering->billingMode); + self::assertTrue($offering->isActive); + } + + public function testToArrayContainsExpectedKeys(): void + { + $offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', id: 10); + $arr = $offering->toArray(); + + foreach (['id', 'instructor_id', 'kind', 'title', 'price_cents', 'billing_mode', 'is_active'] as $key) { + self::assertArrayHasKey($key, $arr); + } + } + + public function testValidKindAndBillingConstants(): void + { + self::assertContains(Offering::KIND_PRIVATE_LESSON, Offering::VALID_KINDS); + self::assertContains(Offering::KIND_GROUP_CLASS, Offering::VALID_KINDS); + self::assertContains(Offering::BILLING_ONE_TIME, Offering::VALID_BILLING_MODES); + self::assertContains(Offering::BILLING_FULL_TERM, Offering::VALID_BILLING_MODES); + } +} -- 2.52.0