\WP_REST_Server::READABLE, 'callback' => [ $this, 'myLessons' ], 'permission_callback' => [ $this, 'isLoggedIn' ], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'book' ], 'permission_callback' => [ $this, 'canBook' ], 'args' => [ 'slot_id' => [ 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ], 'offering_id' => [ 'type' => 'integer', 'default' => 0, ], 'recurrence' => [ 'type' => 'string', 'default' => 'single', ], 'answers' => [ 'type' => 'object', 'default' => [], ], 'accepted_policy_version_ids' => [ 'type' => 'array', 'default' => [], ], 'notes' => [ 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_textarea_field', ], ], ], ] ); register_rest_route( $route_namespace, '/bookings/(?P\d+)/status', [ [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [ $this, 'updateStatus' ], 'permission_callback' => [ $this, 'canManage' ], 'args' => [ 'status' => [ 'type' => 'string', 'required' => true, 'enum' => Lesson::VALID_STATUSES, ], ], ], ] ); } public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found $userId = get_current_user_id(); $lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ? $this->bookings->findUpcomingForInstructor( $userId ) : $this->bookings->findByStudent( $userId ); return new \WP_REST_Response( array_map( fn( Lesson $l ) => $l->toArray(), $lessons ), 200 ); } public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $slotId = (int) $request->get_param( 'slot_id' ); $slot = $this->availability->findById( $slotId ); if ( null === $slot ) { return new \WP_Error( 'not_found', __( 'Slot not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); } if ( $slot->isBooked ) { return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } $offeringId = absint( $request->get_param( 'offering_id' ) ); if ( 0 === $offeringId ) { $offeringId = (int) ( $slot->offeringId ?? 0 ); } $offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null; if ( $offeringId > 0 && null === $offering ) { return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); } $answers = $this->answers( $request ); $acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) ); $gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds ); if ( $gateError instanceof \WP_Error ) { return $gateError; } $studentId = get_current_user_id(); $notes = (string) $request->get_param( 'notes' ); $recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE; $template = new Lesson( slotId: $slotId, studentId: $studentId, instructorId: $slot->instructorId, offeringId: $offeringId > 0 ? $offeringId : null, recurrence: $recurrence, notes: '' !== $notes ? $notes : null, ); // Weekly reservation across the slot's recurring group; otherwise a single lesson. if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) { $slotIds = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) ); $ids = $this->bookings->insertSeries( $template, $slotIds ); foreach ( $slotIds as $reservedSlotId ) { $this->availability->markBooked( $reservedSlotId ); } $anchorId = $ids[0] ?? 0; } else { $anchorId = $this->bookings->insert( $template ); $this->availability->markBooked( $slotId ); $ids = [ $anchorId ]; } $this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); if ( null !== $offering && $offering->price > 0.0 ) { $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); } return new \WP_REST_Response( [ 'ids' => $ids, 'status' => Lesson::STATUS_PENDING, ], 201 ); } /** * Extract a question_id => value map from the request. * * @return array */ private function answers( \WP_REST_Request $request ): array { $out = []; foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) { $out[ (int) $questionId ] = sanitize_text_field( (string) $value ); } return $out; } private function clientIp(): ?string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit. $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ); return '' !== $ip ? $ip : null; } public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $id = absint( $request->get_param( 'id' ) ); $lesson = $this->bookings->findById( $id ); if ( null === $lesson ) { return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); } if ( get_current_user_id() !== $lesson->instructorId && ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'forbidden', __( 'You cannot update this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } $this->bookings->updateStatus( $id, (string) $request->get_param( 'status' ) ); return new \WP_REST_Response( [ 'id' => $id, 'status' => $request->get_param( 'status' ), ], 200 ); } public function isLoggedIn(): bool { return is_user_logged_in(); } public function canBook(): bool { return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON ); } public function canManage(): bool { return is_user_logged_in() && ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) || current_user_can( 'manage_options' ) ); } }