$this->create_session( $request ), 'public.session.get' => RestResponder::ok( $this->sessions->get( (string) $request['token'] ) ), 'public.session.step' => RestResponder::ok( $this->sessions->submit_step( (string) $request['token'], (string) $request['name'], is_array( $request->get_json_params() ) ? $request->get_json_params() : [] ) ), 'public.session.back' => RestResponder::ok( $this->sessions->back( (string) $request['token'] ) ), 'public.session.submit' => $this->submit_session( $request ), 'public.availability' => $this->availability( $request ), 'public.services.list' => $this->list_services( $request ), 'public.service_categories.list' => $this->list_categories( $request ), 'public.agents.list' => $this->list_agents( $request ), 'public.locations.list' => $this->list_locations( $request ), 'public.coupon.validate' => $this->validate_coupon( $request ), 'public.payment.intent' => $this->payment_intent( $request ), 'public.payment.webhook' => $this->payment_webhook( $request ), 'manage.request_link' => RestResponder::ok( $this->customers->request_link( (string) $request['email'] ) ), 'manage.exchange' => $this->exchange( $request ), 'manage.bookings.list' => $this->manage_list( $request ), 'manage.bookings.cancel' => $this->manage_cancel( $request ), 'manage.bookings.reschedule' => $this->manage_reschedule( $request ), default => RestResponder::error( 'bookline_unknown_route', __( 'Unknown route.', 'buildler' ), 404 ), }; } catch ( DomainException $e ) { return RestResponder::from_domain( $e ); } } /** * Create a booking session. * * @param WP_REST_Request $request Request. */ private function create_session( WP_REST_Request $request ): WP_REST_Response { $ip = isset( $_SERVER['REMOTE_ADDR'] ) && is_string( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : null; $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) && is_string( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null; $tz = $request->get_param( 'timezone' ); $locale = $request->get_param( 'locale' ); return RestResponder::ok( $this->sessions->create( (int) $request['form_id'], is_string( $tz ) ? $tz : null, is_string( $locale ) ? $locale : null, $ip, $ua ), 201 ); } /** * Submit a booking session for conversion. * * @param WP_REST_Request $request Request. */ private function submit_session( WP_REST_Request $request ): WP_REST_Response { return RestResponder::ok( $this->sessions->submit( (string) $request['token'] ) ); } /** * Availability stub until the engine is wired with live agent payloads in this layer. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ private function availability( WP_REST_Request $request ): WP_REST_Response|WP_Error { $from = (string) $request['date_from']; $to = (string) $request['date_to']; $tz = (string) $request['timezone']; try { $start = new DateTimeImmutable( $from . ' 00:00:00', new DateTimeZone( $tz ) ); $end = new DateTimeImmutable( $to . ' 23:59:59', new DateTimeZone( $tz ) ); } catch ( \Exception $e ) { return RestResponder::error( 'bookline_invalid_dates', __( 'Invalid date range or timezone.', 'buildler' ), 400 ); } $days = (int) $start->diff( $end )->format( '%a' ); if ( $days > 62 ) { return RestResponder::error( 'bookline_range_too_large', __( 'Availability range must not exceed 62 days.', 'buildler' ), 400 ); } // Temporary weekday slots (09:00–16:00 hourly) until GetAvailabilityHandler is wired here. $service_id = absint( $request->get_param( 'service_id' ) ); $attendees = max( 1, absint( $request->get_param( 'attendees' ) ?? 1 ) ); $capacity = 1; if ( $service_id > 0 ) { $service = $this->services->find_by_id( $service_id ); if ( null !== $service ) { $capacity = max( 1, $service->capacity_max() ); } } $fits = $attendees <= $capacity; $agent_id = absint( $request->get_param( 'agent_id' ) ); $busy = IntervalSet::empty(); try { $tz_id = new TimeZoneId( $tz ); $start_utc = $start->setTimezone( new DateTimeZone( 'UTC' ) ); $end_utc = $end->setTimezone( new DateTimeZone( 'UTC' ) ); $query = AvailabilityQuery::for_busy_scan( $start_utc, $end_utc, $tz_id, $agent_id > 0 ? $agent_id : null ); if ( $this->busy instanceof BusyProviderRegistry ) { $busy = $this->busy->union( $query ); } } catch ( DomainException $e ) { unset( $e ); } $cursor = $start; $out = []; while ( $cursor <= $end ) { $slots = []; $dow = (int) $cursor->format( 'N' ); // 1=Mon … 7=Sun if ( $dow <= 5 ) { for ( $hour = 9; $hour <= 16; $hour++ ) { $slot_start = $cursor->setTime( $hour, 0, 0 ); $slot_end = $slot_start->modify( '+60 minutes' ); $slot_utc = new Interval( $slot_start->setTimezone( new DateTimeZone( 'UTC' ) ), $slot_end->setTimezone( new DateTimeZone( 'UTC' ) ) ); $slots[] = [ 'start' => [ 'utc' => $slot_start->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d\TH:i:s\Z' ), 'local' => $slot_start->format( 'Y-m-d\TH:i:s' ), ], 'end' => [ 'utc' => $slot_end->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d\TH:i:s\Z' ), 'local' => $slot_end->format( 'Y-m-d\TH:i:s' ), ], 'available' => $fits && ! $busy->overlaps( $slot_utc ), 'capacity_remaining' => $capacity, 'start_local' => $slot_start->format( 'Y-m-d\TH:i:s' ), ]; } } $out[] = [ 'date' => $cursor->format( 'Y-m-d' ), 'slots' => $slots, ]; $cursor = $cursor->modify( '+1 day' ); } return RestResponder::ok( [ 'timezone' => $tz, 'days' => $out, ] ); } /** * Private helper. * * @param WP_REST_Request $request Request. */ private function list_services( WP_REST_Request $request ): WP_REST_Response { $page = max( 1, (int) $request->get_param( 'page' ) ); $per_page = min( 100, max( 1, (int) ( null !== $request->get_param( 'per_page' ) ? $request->get_param( 'per_page' ) : 20 ) ) ); $form = $this->form_scope->active_form( (int) $request->get_param( 'form_id' ) ); $items = $this->services->find_active( $per_page, ( $page - 1 ) * $per_page ); $data = []; foreach ( $items as $service ) { if ( ! $this->form_scope->service_allowed( $form, $service ) ) { continue; } $data[] = [ 'id' => $service->id(), 'uuid' => $service->uuid()->to_string(), 'name' => $service->name(), 'slug' => $service->slug(), 'short_description' => $service->short_description(), 'colour' => $service->colour(), 'capacity_min' => $service->capacity_min(), 'capacity_max' => $service->capacity_max(), ]; } $response = RestResponder::ok( [ 'items' => $data ] ); $response->header( 'X-WP-Total', (string) count( $data ) ); $response->header( 'X-WP-TotalPages', '1' ); return $response; } /** * Private helper. * * @param WP_REST_Request $request Request. */ private function list_categories( WP_REST_Request $request ): WP_REST_Response { $page = max( 1, (int) $request->get_param( 'page' ) ); $per_page = min( 100, max( 1, (int) ( null !== $request->get_param( 'per_page' ) ? $request->get_param( 'per_page' ) : 20 ) ) ); $items = $this->categories->find_active( $per_page, ( $page - 1 ) * $per_page ); $data = []; foreach ( $items as $cat ) { $data[] = [ 'id' => $cat->id(), 'uuid' => $cat->uuid()->to_string(), 'name' => $cat->name(), 'slug' => $cat->slug(), ]; } return RestResponder::ok( [ 'items' => $data ] ); } /** * Private helper. * * @param WP_REST_Request $request Request. */ private function list_agents( WP_REST_Request $request ): WP_REST_Response { $page = max( 1, (int) $request->get_param( 'page' ) ); $per_page = min( 100, max( 1, (int) ( null !== $request->get_param( 'per_page' ) ? $request->get_param( 'per_page' ) : 20 ) ) ); $form = $this->form_scope->active_form( (int) $request->get_param( 'form_id' ) ); $items = $this->agents->find_active( $per_page, ( $page - 1 ) * $per_page ); $data = []; foreach ( $items as $agent ) { if ( ! $this->form_scope->agent_allowed( $form, $agent ) ) { continue; } $data[] = [ 'id' => $agent->id(), 'uuid' => $agent->uuid()->to_string(), 'display_name' => $agent->display_name(), 'bio' => $agent->bio(), 'colour' => $agent->colour(), // Never expose email, phone, or wp_user_id on public routes. ]; } return RestResponder::ok( [ 'items' => $data ] ); } /** * Private helper. * * @param WP_REST_Request $request Request. */ private function list_locations( WP_REST_Request $request ): WP_REST_Response { $page = max( 1, (int) $request->get_param( 'page' ) ); $per_page = min( 100, max( 1, (int) ( null !== $request->get_param( 'per_page' ) ? $request->get_param( 'per_page' ) : 20 ) ) ); $form = $this->form_scope->active_form( (int) $request->get_param( 'form_id' ) ); $items = $this->locations->find_active( $per_page, ( $page - 1 ) * $per_page ); $data = []; foreach ( $items as $location ) { if ( ! $this->form_scope->location_allowed( $form, $location ) ) { continue; } $data[] = [ 'id' => $location->id(), 'uuid' => $location->uuid()->to_string(), 'name' => $location->name(), 'slug' => $location->slug(), 'address_line1' => $location->address_line1(), 'city' => $location->city(), 'postcode' => $location->postcode(), 'country' => $location->country(), 'timezone' => $location->timezone()->to_string(), // Never expose email on public routes. ]; } return RestResponder::ok( [ 'items' => $data ] ); } /** * Generic coupon failure — never reveals whether a code exists. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ private function validate_coupon( WP_REST_Request $request ): WP_REST_Response|WP_Error { $token = (string) $request['token']; $code = (string) $request['code']; try { $this->intents->resume( $token ); } catch ( DomainException $e ) { return RestResponder::from_domain( $e ); } $generic = __( 'That coupon cannot be applied to this booking.', 'buildler' ); $named = $this->named ?? new OpenNamedFeatureGate(); if ( ! $named->allows( NamedFeature::Coupons ) ) { return RestResponder::error( 'bookline_coupon_invalid', $generic, 422 ); } $coupon = $this->coupons->find_by_code( $code ); if ( null === $coupon ) { return RestResponder::error( 'bookline_coupon_invalid', $generic, 422 ); } $updated = $this->intents->mutate( $token, null, $code, null, null, null ); return RestResponder::ok( [ 'totals' => $updated->intent()->totals(), 'code' => $code, ] ); } /** * Create a gateway payment intent for the session. * * @param WP_REST_Request $request Request. */ private function payment_intent( WP_REST_Request $request ): WP_REST_Response { return RestResponder::ok( $this->payment_intent->handle( (string) $request['token'], (string) $request['gateway'], (string) $request['payment_type'] ) ); } /** * Verify and process a gateway webhook (idempotent). * * @param WP_REST_Request $request Request. */ private function payment_webhook( WP_REST_Request $request ): WP_REST_Response|WP_Error { $gateway_id = (string) $request['gateway']; if ( ! $this->gateways->has( $gateway_id ) ) { return RestResponder::error( 'bookline_gateway_unknown', __( 'Unknown payment gateway.', 'buildler' ), 404 ); } $payload = $request->get_body(); if ( ! is_string( $payload ) || '' === $payload ) { return RestResponder::error( 'bookline_webhook_empty', __( 'Webhook payload is empty.', 'buildler' ), 400 ); } $verification = $this->gateways->get( $gateway_id )->verify_webhook( $payload, self::webhook_headers( $request ) ); if ( ! $verification->verified() || null === $verification->event() ) { return RestResponder::error( 'bookline_webhook_unverified', __( 'Webhook signature verification failed.', 'buildler' ), 401 ); } $this->payment_webhook->handle( $gateway_id, $verification->event() ); return RestResponder::ok( [ 'received' => true ] ); } /** * Normalise request headers for gateway verification. * * @param WP_REST_Request $request Request. * @return array */ private static function webhook_headers( WP_REST_Request $request ): array { $headers = []; if ( method_exists( $request, 'get_headers' ) ) { $raw = $request->get_headers(); if ( ! is_array( $raw ) ) { $raw = []; } foreach ( $raw as $name => $values ) { $key = strtolower( (string) $name ); if ( is_array( $values ) ) { $headers[ $key ] = (string) ( $values[0] ?? '' ); } else { $headers[ $key ] = (string) $values; } } } $sig = $request->get_header( 'stripe-signature' ); if ( is_string( $sig ) && '' !== $sig ) { $headers['stripe-signature'] = $sig; } return $headers; } /** * Private helper. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ private function exchange( WP_REST_Request $request ): WP_REST_Response|WP_Error { $exchanged = $this->customers->exchange( (string) $request['token'] ); if ( null === $exchanged ) { return RestResponder::error( 'bookline_magic_token_invalid', __( 'That link is invalid or has expired.', 'buildler' ), 400 ); } return RestResponder::ok( $exchanged ); } /** * Private helper. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ private function manage_list( WP_REST_Request $request ): WP_REST_Response|WP_Error { $email = $this->session_email( $request ); if ( null === $email ) { return RestResponder::error( 'bookline_customer_session', __( 'A valid customer session is required.', 'buildler' ), 401 ); } $matches = $this->customer_repo->find_by_email( new EmailAddress( $email ) ); $items = []; $now = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ); foreach ( $matches as $customer ) { if ( null === $customer->id() ) { continue; } foreach ( $this->bookings->find_upcoming_for_customer( (int) $customer->id(), $now ) as $booking ) { $items[] = [ 'uuid' => $booking->uuid()->to_string(), 'status' => $booking->status()->value, 'start_utc' => $booking->start_utc()->format( 'c' ), 'end_utc' => $booking->end_utc()->format( 'c' ), ]; } } return RestResponder::ok( [ 'items' => $items ] ); } /** * Private helper. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ private function manage_cancel( WP_REST_Request $request ): WP_REST_Response|WP_Error { $email = $this->session_email( $request ); if ( null === $email ) { return RestResponder::error( 'bookline_customer_session', __( 'A valid customer session is required.', 'buildler' ), 401 ); } $booking = $this->bookings->find_by_uuid( new Uuid( (string) $request['uuid'] ) ); if ( null === $booking || null === $booking->id() ) { return RestResponder::error( 'bookline_not_found', __( 'Booking not found.', 'buildler' ), 404 ); } if ( ! $this->booking_belongs_to_email( $booking->customer_id(), $email ) ) { return RestResponder::error( 'bookline_forbidden', __( 'You do not have permission to access this resource.', 'buildler' ), 403 ); } $this->cancel->handle( (int) $booking->id(), CancelledBy::Customer ); return RestResponder::ok( [ 'cancelled' => true ] ); } /** * Private helper. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ private function manage_reschedule( WP_REST_Request $request ): WP_REST_Response|WP_Error { $email = $this->session_email( $request ); if ( null === $email ) { return RestResponder::error( 'bookline_customer_session', __( 'A valid customer session is required.', 'buildler' ), 401 ); } $booking = $this->bookings->find_by_uuid( new Uuid( (string) $request['uuid'] ) ); if ( null === $booking || null === $booking->id() ) { return RestResponder::error( 'bookline_not_found', __( 'Booking not found.', 'buildler' ), 404 ); } if ( ! $this->booking_belongs_to_email( $booking->customer_id(), $email ) ) { return RestResponder::error( 'bookline_forbidden', __( 'You do not have permission to access this resource.', 'buildler' ), 403 ); } $start = new DateTimeImmutable( (string) $request['start_utc'], new DateTimeZone( 'UTC' ) ); $duration = $booking->end_utc()->getTimestamp() - $booking->start_utc()->getTimestamp(); $end = $start->modify( '+' . max( 1, $duration ) . ' seconds' ); $buf_before = $booking->start_utc()->getTimestamp() - $booking->buffer_start_utc()->getTimestamp(); $buf_after = $booking->buffer_end_utc()->getTimestamp() - $booking->end_utc()->getTimestamp(); $buffer_start = $start->modify( '-' . max( 0, $buf_before ) . ' seconds' ); $buffer_end = $end->modify( '+' . max( 0, $buf_after ) . ' seconds' ); $this->reschedule->handle( (int) $booking->id(), $start, $end, $buffer_start, $buffer_end, $start ); return RestResponder::ok( [ 'rescheduled' => true ] ); } /** * Private helper. * * @param WP_REST_Request $request Request. */ private function session_email( WP_REST_Request $request ): ?string { $header = $request->get_header( 'x_bookline_customer_session' ); if ( ! is_string( $header ) || '' === $header ) { $header = $request->get_header( 'X-Bookline-Customer-Session' ); } if ( ! is_string( $header ) ) { return null; } return $this->customers->email_for_session( $header ); } /** * Private helper. * * @param int|null $customer_id Customer id. * @param string $email Session email. */ private function booking_belongs_to_email( ?int $customer_id, string $email ): bool { if ( null === $customer_id ) { return false; } $customer = $this->customer_repo->find_by_id( $customer_id ); if ( null === $customer ) { return false; } return strtolower( $customer->email()->to_string() ) === strtolower( $email ); } }