\WP_REST_Server::READABLE, 'callback' => [ $this, 'index' ], 'permission_callback' => [ $this, 'canBook' ], '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, ); // Public listing: omit the private e-transfer destination email. return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray( includeEtransferEmail: false ), $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, price: $this->price( $request->get_param( 'price' ) ), 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' ) ), etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ), 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, price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price, 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, etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail, 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 ); } /** * 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. */ public function canBook(): bool { return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON ); } /** * 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 price( mixed $value ): float { return max( 0.0, (float) $value ); } private function nullableEmail( mixed $value ): ?string { $email = sanitize_email( (string) $value ); return '' !== $email ? $email : null; } 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 ); } }