\WP_REST_Server::READABLE, 'callback' => [ $this, 'index' ], 'permission_callback' => [ $this, 'canRead' ], '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 { $instructorId = Val::int( $request->get_param( 'instructor_id' ) ); $kind = Val::string( $request->get_param( 'kind' ) ); // The public catalogue is public offerings only; invite-only classes are // hidden from it and surfaced separately to the students granted access. $offerings = $this->repository->findAll( $instructorId, $kind, activeOnly: true, accessMode: Offering::ACCESS_PUBLIC ); foreach ( $this->grantedInviteOnly( $instructorId, $kind ) as $granted ) { $offerings[] = $granted; } // Public listing: omit the private e-transfer destination email, and // attach the assigned instructor's display name so the front end can show // students who teaches each class. return new \WP_REST_Response( array_map( [ $this, 'present' ], $offerings ), 200 ); } /** * A public-facing offering array with the assigned instructor's name added — * their real name or nickname, never the login (empty when the instructor * account no longer exists). See {@see UserName::format()}. * * @return array */ private function present( Offering $offering ): array { $out = $offering->toArray( includeEtransferEmail: false ); $user = get_userdata( $offering->instructorId ); $out['instructor_name'] = UserName::format( $user instanceof \WP_User ? $user : null ); return $out; } /** * The active invite-only offerings the caller has been granted access to, * matching the same instructor/kind filters as the public catalogue. * * @return list */ private function grantedInviteOnly( int $instructorId, string $kind ): array { $grantedIds = $this->access->findGrantedOfferingIds( get_current_user_id() ); if ( [] === $grantedIds ) { return []; } $out = []; foreach ( $grantedIds as $offeringId ) { $offering = $this->repository->findById( $offeringId ); if ( null === $offering || ! $offering->isActive || ! $offering->isInviteOnly() || ( $instructorId > 0 && $offering->instructorId !== $instructorId ) || ( '' !== $kind && $offering->kind !== $kind ) ) { continue; } $out[] = $offering; } return $out; } public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ); if ( '' === $title ) { return $this->invalid( __( 'A title is required.', 'unsupervised-schedular' ) ); } $kind = Val::string( $request->get_param( 'kind' ) ); if ( ! in_array( $kind, Offering::VALID_KINDS, true ) ) { return $this->invalid( __( 'Invalid offering kind.', 'unsupervised-schedular' ) ); } $billingMode = Val::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' ) ); } $scheduleNote = $this->nullableText( $request->get_param( 'schedule_note' ) ); $etransferEmail = $this->nullableEmail( $request->get_param( 'etransfer_email' ) ); $lengthError = $this->checkLengths( $title, $scheduleNote, $etransferEmail ); if ( $lengthError instanceof \WP_Error ) { return $lengthError; } $offering = new Offering( instructorId: get_current_user_id(), kind: $kind, title: $title, price: $this->price( $request->get_param( 'price' ) ), currency: sanitize_text_field( Val::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' ) ), enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ), scheduleNote: $scheduleNote, etransferEmail: $etransferEmail, cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ), accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ), 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( Val::int( $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' ) ? Val::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' ) ? Val::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' ) ); } $title = $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title; $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; $lengthError = $this->checkLengths( $title, $scheduleNote, $etransferEmail ); if ( $lengthError instanceof \WP_Error ) { return $lengthError; } $offering = new Offering( instructorId: $existing->instructorId, kind: $kind, title: $title, price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price, currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::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, enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline, scheduleNote: $scheduleNote, etransferEmail: $etransferEmail, cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours, accessMode: $request->has_param( 'access_mode' ) ? $this->accessMode( $request->get_param( 'access_mode' ), $existing->accessMode ) : $existing->accessMode, 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( Val::int( $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 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 canRead(): bool { return is_user_logged_in() && ( current_user_can( RoleManager::CAP_BOOK_LESSON ) || 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 ] ); } /** * Reject any fixed-size field whose value exceeds its column length, so an * over-long value is refused with a clear 400 rather than silently dropped * by the database. */ private function checkLengths( string $title, ?string $scheduleNote, ?string $etransferEmail ): ?\WP_Error { $fields = [ [ __( 'title', 'unsupervised-schedular' ), $title, Offering::MAX_TITLE_LENGTH ], [ __( 'schedule note', 'unsupervised-schedular' ), $scheduleNote, Offering::MAX_SCHEDULE_NOTE_LENGTH ], [ __( 'e-transfer email', 'unsupervised-schedular' ), $etransferEmail, Offering::MAX_ETRANSFER_EMAIL_LENGTH ], ]; foreach ( $fields as [ $name, $value, $max ] ) { if ( null !== $value && mb_strlen( $value ) > $max ) { return $this->invalid( sprintf( /* translators: 1: field name, 2: maximum character count. */ __( 'The %1$s must be %2$d characters or fewer.', 'unsupervised-schedular' ), $name, $max ) ); } } return null; } private function price( mixed $value ): float { return max( 0.0, Val::float( $value ) ); } private function nullableEmail( mixed $value ): ?string { $email = sanitize_email( Val::string( $value ) ); return '' !== $email ? $email : null; } private function accessMode( mixed $value, string $fallback ): string { $mode = Val::string( $value ); return in_array( $mode, Offering::VALID_ACCESS_MODES, true ) ? $mode : $fallback; } private function nullableInt( mixed $value ): ?int { return ( null === $value || '' === $value ) ? null : Val::int( $value ); } private function nullableText( mixed $value ): ?string { if ( null === $value || '' === $value ) { return null; } return sanitize_text_field( Val::string( $value ) ); } }