\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->findUpcomingForStudent( $userId ); return new \WP_REST_Response( array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ), 200 ); } /** * A lesson's array form plus its slot's start/end times, so front-end lists * can show when the session happens without a second request. * * @return array */ private function lessonWithTimes( Lesson $lesson ): array { $slot = $this->availability->findById( $lesson->slotId ); return $lesson->toArray() + [ 'start_dt' => $slot?->startDt, 'end_dt' => $slot?->endDt, ]; } public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $slotId = Val::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 ] ); } // Resolve the offering for this booking. A client-supplied offering must // never override the slot's price or payment routing: when the slot is tied // to a specific offering that offering is authoritative, and any offering // used must belong to the slot's instructor. This prevents substituting a // cheaper/free offering to dodge payment, or another instructor's offering // to misroute it. $requestedOfferingId = absint( Val::int( $request->get_param( 'offering_id' ) ) ); $slotOfferingId = (int) ( $slot->offeringId ?? 0 ); if ( $slotOfferingId > 0 ) { if ( $requestedOfferingId > 0 && $requestedOfferingId !== $slotOfferingId ) { return new \WP_Error( 'offering_mismatch', __( 'This slot is tied to a different offering.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); } $offeringId = $slotOfferingId; } else { $offeringId = $requestedOfferingId; } $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 ] ); } if ( null !== $offering && $offering->instructorId !== $slot->instructorId ) { return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); } $answers = $this->answers( $request ); $acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (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 = Val::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 ) { // Claim each occurrence atomically (capped so one booking cannot lock an // instructor's entire schedule), then create a lesson only for the slots // this request actually won — never for one already taken by someone else. $candidates = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) ); $candidates = array_slice( $candidates, 0, self::MAX_WEEKLY_OCCURRENCES ); $claimed = array_values( array_filter( $candidates, fn( int $candidateId ): bool => $this->availability->claim( $candidateId ) ) ); if ( [] === $claimed ) { return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } $ids = $this->bookings->insertSeries( $template, $claimed ); $anchorId = $ids[0] ?? 0; } else { // Claim before inserting: if another request already took the slot, the // guarded update reports no rows and we reject rather than double-book. if ( ! $this->availability->claim( $slotId ) ) { return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } $anchorId = $this->bookings->insert( $template ); $ids = [ $anchorId ]; } $this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); $payment = null; $status = Lesson::STATUS_PENDING; if ( null !== $offering && $offering->price > 0.0 ) { $payment = $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); if ( null !== $payment && $payment->isPaid() ) { $status = Lesson::STATUS_CONFIRMED; } } else { // Nothing owed: there is no payment step that would confirm these // lessons later, so they are confirmed at booking time. foreach ( $ids as $lessonId ) { $this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED ); } $status = Lesson::STATUS_CONFIRMED; } // `payment: null` tells the front end to skip the payment step entirely. return new \WP_REST_Response( [ 'ids' => $ids, 'status' => $status, 'payment' => $payment?->toSummaryArray(), ], 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( Val::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( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) ); return '' !== $ip ? $ip : null; } public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $id = absint( Val::int( $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, Val::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' ) ); } }