is_full_admin() && $this->is_team_member(); } /** * Guard an admin-only tool. * * Returns an error array on failure, null on success. * * @return array|null */ protected function guard_admin(): ?array { if ( ! $this->plugin_active() ) { return [ 'error' => 'Booktics plugin is not active.' ]; } if ( ! $this->is_full_admin() ) { return [ 'error' => 'You do not have permission for this action. It requires Booktics administrator access.' ]; } return null; } /** * Guard a tool usable by admins and team members. * * Returns an error array on failure, null on success. Callers that expose * booking data must still narrow it with {@see enforced_team_member_id()}. * * @return array|null */ protected function guard_staff(): ?array { if ( ! $this->plugin_active() ) { return [ 'error' => 'Booktics plugin is not active.' ]; } if ( ! $this->is_full_admin() && ! $this->is_team_member() ) { return [ 'error' => 'You do not have permission to access Booktics bookings.' ]; } return null; } /** * The team-member ID a listing must be locked to for the current user. * * Team members (non-admins) are forced to their own WP user ID — which is * also their Booktics team-member ID. Admins are unrestricted (null), so a * caller-supplied filter, if any, is honoured. * * @return int|null */ protected function enforced_team_member_id(): ?int { return $this->is_staff_only() ? get_current_user_id() : null; } /** * Restrict a list of appointment rows to the current user's own bookings. * * Defence-in-depth for team members that does not rely on the Booktics * controller honouring the `team_member_id` query param — notably, that * param is OR-combined (not AND) when a `search` term matches, which would * otherwise leak other staff members' bookings. A no-op for admins. * * @param array $rows Normalised appointment rows (each with `team_member_id`). * @return array */ protected function filter_to_own_appointments( array $rows ): array { if ( ! $this->is_staff_only() ) { return $rows; } $user_id = get_current_user_id(); return array_values( array_filter( $rows, static function ( $row ) use ( $user_id ) { return is_array( $row ) && (int) ( $row['team_member_id'] ?? 0 ) === $user_id; } ) ); } /** * Assert the current user may act on a specific appointment. * * Loads the appointment, confirms it exists, and — for team members — * confirms it is assigned to them. Admins may act on any appointment. * * @param int $appointment_id Appointment post ID. * @return array|null Error array on failure, null when access is allowed. */ protected function assert_appointment_access( int $appointment_id ): ?array { if ( $appointment_id <= 0 ) { return [ 'error' => 'A valid appointment ID is required.' ]; } // Post_Model::find() returns a hollow, unloaded instance for a valid post // ID that belongs to another post type (its own `id` stays empty), so // truthiness alone is not "found" — verify the loaded record's ID matches. $appointment = \Booktics\Models\Appointment_Model::find( $appointment_id ); if ( ! $appointment || (int) $appointment->id !== $appointment_id ) { return [ 'error' => "Appointment #{$appointment_id} was not found." ]; } if ( $this->is_staff_only() && get_current_user_id() !== (int) $appointment->team_member_id ) { Log::warning( 'Booktics staff attempted to access another team member\'s appointment', [ 'user_id' => get_current_user_id(), 'appointment_id' => $appointment_id, ] ); return [ 'error' => 'You can only manage appointments assigned to you.' ]; } return null; } /** * Build a one-line, human-readable description of an appointment for approval * cards. * * @param int|string $appointment_id Appointment post ID. * @return string */ protected function describe_appointment( $appointment_id ): string { $appointment_id = (int) $appointment_id; if ( $appointment_id <= 0 ) { return ''; } // Guard against a hollow instance from a wrong-type post ID. $appointment = \Booktics\Models\Appointment_Model::find( $appointment_id ); if ( ! $appointment || (int) $appointment->id !== $appointment_id ) { return ''; } $data = $this->normalize( $appointment ); if ( null === $data ) { return ''; } $service = is_array( $data['service'] ?? null ) ? $data['service'] : []; $customer = is_array( $data['customer'] ?? null ) ? $data['customer'] : []; $service_name = (string) ( $service['title'] ?? '' ); $customer_name = (string) ( $customer['name'] ?? ( $data['customer_name'] ?? '' ) ); $when = trim( (string) ( $data['date'] ?? '' ) . ' ' . (string) ( $data['start_time'] ?? '' ) ); $parts = []; if ( '' !== $service_name ) { $parts[] = $service_name; } if ( '' !== $customer_name ) { $parts[] = 'for ' . $customer_name; } if ( '' !== $when ) { $parts[] = 'on ' . $when; } return implode( ' ', $parts ); } /** * Assert the current user may act on a specific service. * * Loads the service, confirms it exists, and — for team members — confirms * it is assigned to them. Admins may act on any service. Mirrors * {@see assert_appointment_access()} so single-record scoping is consistent * across entity types. * * @param int $service_id Service post ID. * @return array|null Error array on failure, null when access is allowed. */ protected function assert_service_access( int $service_id ): ?array { if ( $service_id <= 0 ) { return [ 'error' => 'A valid service ID is required.' ]; } // See assert_appointment_access(): guard against a hollow instance from a // valid post ID of another type by confirming the loaded record's ID. $service = \Booktics\Models\Service_Model::find( $service_id ); if ( ! $service || (int) $service->id !== $service_id ) { return [ 'error' => "Service #{$service_id} was not found." ]; } if ( $this->is_staff_only() ) { $assigned = array_map( 'intval', (array) $service->team_member ); if ( ! in_array( get_current_user_id(), $assigned, true ) ) { return [ 'error' => 'You can only view services assigned to you.' ]; } } return null; } /** * Validate a Y-m-d date string. * * @param string $date Date string. * @return bool */ protected function is_valid_date( string $date ): bool { $parsed = \DateTime::createFromFormat( 'Y-m-d', $date ); return $parsed && $parsed->format( 'Y-m-d' ) === $date; } /** * Validate an appointment date plus its start/end time window. * * @param string $date Date (Y-m-d). * @param string $start_time Start time (HH:MM, 24-hour). * @param string $end_time End time (HH:MM, 24-hour). * @return array|null Error array on failure, null when valid. */ protected function validate_appointment_datetime( string $date, string $start_time, string $end_time ): ?array { if ( ! $this->is_valid_date( $date ) ) { return [ 'error' => 'date must be in Y-m-d format.' ]; } $time_pattern = '/^(?:[01]\d|2[0-3]):[0-5]\d$/'; if ( ! preg_match( $time_pattern, $start_time ) ) { return [ 'error' => 'start_time must be a valid 24-hour time in HH:MM format.' ]; } if ( ! preg_match( $time_pattern, $end_time ) ) { return [ 'error' => 'end_time must be a valid 24-hour time in HH:MM format.' ]; } if ( $start_time >= $end_time ) { return [ 'error' => 'end_time must be later than start_time.' ]; } return null; } /** * Build the canonical Booktics service duration row. * * @param int $minutes Duration in minutes. * @param float $price Charge amount. * @return array */ protected function default_duration_row( int $minutes = 30, float $price = 0.0 ): array { return [ 'id' => 'default', 'name' => 'default', 'duration_time' => max( 1, $minutes ), 'duration_unit' => 'min', 'charge_amount' => max( 0.0, $price ), 'deposit_amount' => 0, ]; } /** * Build a JSON-body WP_REST_Request for a Booktics controller call. * * Centralises the encode-and-guard, content-type header, optional id URL * param, and body assignment shared by every write tool. * * @param string $method HTTP method (POST, PUT). * @param string $route REST route. * @param array $body Request body to JSON-encode. * @param int|null $id Optional resource id for the URL params. * @return \WP_REST_Request|array The request, or ['error' => '...'] on encode failure. */ protected function build_json_request( string $method, string $route, array $body, ?int $id = null ): \WP_REST_Request|array { $json = wp_json_encode( $body ); if ( false === $json ) { Log::error( 'Booktics build_json_request failed to encode body', [ 'route' => $route, 'body_keys' => array_keys( $body ), ] ); return [ 'error' => 'Failed to encode request data. Ensure all input values are valid.' ]; } $request = new \WP_REST_Request( $method, $route ); $request->set_header( 'content-type', 'application/json' ); if ( null !== $id ) { $request->set_url_params( [ 'id' => $id ] ); } $request->set_body( $json ); return $request; } /** * Unwrap a Booktics REST controller response. * * Booktics controllers return a WP_HTTP_Response whose body is * `['success' => 1, 'message' => ..., 'data' => ...]` on success and a * 4xx/5xx status with a `message` on error. The `data` payload frequently * contains Booktics model objects (e.g. Appointment_Model) rather than * plain arrays, so it is normalised through JSON — honouring each model's * jsonSerialize() — into nested arrays the shaping helpers can consume. * * @param \WP_HTTP_Response $response Controller response. * @param string $fallback_error Human-readable fallback message. * @return array Normalised data array, or ['error' => '...'] on failure. */ protected function extract_response( \WP_HTTP_Response $response, string $fallback_error ): array { $status = $response->get_status(); $body = $response->get_data(); if ( $status >= 400 ) { // Log server errors (5xx) so they leave a server-side trace beyond // whatever the model tells the user. A 4xx is a normal answer. $context = [ 'status' => $status, 'message' => is_array( $body ) ? ( $body['message'] ?? '' ) : '', 'body' => is_array( $body ) ? '' : gettype( $body ), ]; if ( $status >= 500 ) { Log::error( 'Booktics controller returned a server error', $context ); } $message = is_array( $body ) ? ( $body['message'] ?? $fallback_error ) : $fallback_error; return [ 'error' => $message ]; } if ( ! is_array( $body ) || ! array_key_exists( 'data', $body ) ) { Log::warning( 'Booktics controller returned an unexpected response shape', [ 'status' => $status, 'body_keys' => is_array( $body ) ? array_keys( $body ) : gettype( $body ), ] ); return [ 'error' => $fallback_error ]; } $normalized = $this->normalize( $body['data'] ); if ( null === $normalized ) { return [ 'error' => 'The Booktics record could not be read; it may contain invalid characters.' ]; } return $normalized; } /** * Normalise a value (possibly containing model objects) to nested arrays. * * A single JSON round-trip honours each Booktics model's jsonSerialize() * and leaves already-plain arrays unchanged, so it handles both the * object and array cases without a special path. Returns null (not an empty * array) on encode failure so callers can distinguish a broken payload from * a legitimately empty one. * * @param mixed $value Any JSON-serialisable value. * @return array|null Nested arrays, or null when the payload cannot be encoded. */ private function normalize( $value ): ?array { $json = wp_json_encode( $value ); if ( false === $json ) { Log::error( 'Booktics normalize failed to JSON-encode a controller payload', [ 'type' => gettype( $value ) ] ); return null; } $decoded = json_decode( $json, true ); return is_array( $decoded ) ? $decoded : []; } /** * Map every row of a list through a shaper callback, dropping non-arrays. * * @param array $rows List of records. * @param callable $shape Receives one record, returns the curated record. * @return array */ protected function shape_rows( array $rows, callable $shape ): array { $arrays = array_filter( $rows, 'is_array' ); return array_values( array_map( $shape, $arrays ) ); } /** * Shape a paginated Booktics response that uses an 'items' key. * * Each item is curated through $shape; any scalar metadata alongside * `items` (total, per_page, current_page, last_page) is preserved so the * LLM can page through results. * * @param array $data Normalised controller data (expects an 'items' key). * @param callable $shape Receives one item, returns the curated item. * @return array */ protected function shape_paginated( array $data, callable $shape ): array { $items = ( isset( $data['items'] ) && is_array( $data['items'] ) ) ? $data['items'] : []; $shaped = [ 'items' => $this->shape_rows( $items, $shape ) ]; foreach ( $data as $key => $value ) { if ( 'items' !== $key && is_scalar( $value ) ) { $shaped[ $key ] = $value; } } return $shaped; } /** * Strip HTML and truncate free-text to a token-friendly length. * * @param string|null $text Raw text, possibly containing HTML. * @param int $length Maximum length in characters. * @return string */ protected function truncate( ?string $text, int $length = 300 ): string { $length = max( 1, $length ); $text = trim( wp_strip_all_tags( (string) $text ) ); if ( '' === $text ) { return ''; } return wp_html_excerpt( $text, $length, '…' ); } /** * Resolve a display name for a team member (WP user) ID. * * @param int|string $team_member_id Team-member / WP user ID. * @return string */ protected function team_member_name( $team_member_id ): string { $team_member_id = (int) $team_member_id; if ( $team_member_id <= 0 ) { return ''; } $user = get_userdata( $team_member_id ); return $user ? $user->display_name : ''; } /** * Curate a normalised appointment down to the fields the LLM needs. * * Deliberately whitelists keys so internal fields — notably the * `security_token` used for reschedule/cancel links — never reach output. * The `service`, `team_member`, and `customer` sub-objects come from * Appointment_Model::jsonSerialize(). * * @param array $appointment Normalised appointment record. * @param bool $detailed Include contact details and billing fields. * @return array */ protected function shape_appointment( array $appointment, bool $detailed = false ): array { $service = is_array( $appointment['service'] ?? null ) ? $appointment['service'] : []; $team_member = is_array( $appointment['team_member'] ?? null ) ? $appointment['team_member'] : []; $customer = is_array( $appointment['customer'] ?? null ) ? $appointment['customer'] : []; $team_member_name = $team_member['display_name'] ?? ( $team_member['name'] ?? ( $team_member['first_name'] ?? '' ) ); if ( '' === $team_member_name ) { $team_member_name = $this->team_member_name( $appointment['team_member_id'] ?? 0 ); } $shaped = [ 'id' => $appointment['id'] ?? null, 'date' => $appointment['date'] ?? '', 'start_time' => $appointment['start_time'] ?? '', 'end_time' => $appointment['end_time'] ?? '', 'status' => $appointment['status'] ?? '', 'payment_status' => $appointment['payment_status'] ?? '', 'service_id' => $appointment['service_id'] ?? null, 'service_name' => $service['title'] ?? '', 'team_member_id' => $appointment['team_member_id'] ?? null, 'team_member' => $team_member_name, 'customer_name' => $customer['name'] ?? ( $appointment['customer_name'] ?? '' ), 'total' => $appointment['total'] ?? '', ]; if ( $detailed ) { $shaped['customer_email'] = $customer['email'] ?? ''; $shaped['customer_phone'] = $customer['phone'] ?? ''; $shaped['duration'] = $appointment['duration'] ?? ''; $shaped['total_attendees'] = $appointment['total_attendees'] ?? ''; $shaped['order_id'] = $appointment['order_id'] ?? ''; $shaped['price'] = $appointment['price'] ?? ''; $shaped['subtotal'] = $appointment['subtotal'] ?? ''; $shaped['timezone'] = $appointment['timezone'] ?? ''; $shaped['note'] = $this->truncate( $appointment['note'] ?? '', 500 ); $shaped['cancel_note'] = $this->truncate( $appointment['cancel_note'] ?? '', 300 ); } return $shaped; } /** * Curate a normalised service down to the fields the LLM needs. * * @param array $service Normalised service record. * @param bool $detailed Include the fuller projection (durations, details, shortcode). * @return array */ protected function shape_service( array $service, bool $detailed = false ): array { $team_members = []; if ( ! empty( $service['team_member_details'] ) && is_array( $service['team_member_details'] ) ) { foreach ( $service['team_member_details'] as $member ) { if ( is_array( $member ) ) { $team_members[] = [ 'id' => $member['id'] ?? ( $member['ID'] ?? null ), 'name' => $member['name'] ?? ( $member['display_name'] ?? '' ), ]; } } } $shaped = [ 'id' => $service['id'] ?? null, 'title' => $service['title'] ?? '', 'status' => $service['status'] ?? '', 'description' => $this->truncate( $service['description'] ?? '', $detailed ? 500 : 200 ), 'interval_time' => $service['interval_time'] ?? '', 'before_buffer_time' => $service['before_buffer_time'] ?? '', 'after_buffer_time' => $service['after_buffer_time'] ?? '', 'categories' => $service['categories'] ?? [], 'team_member_ids' => $service['team_member'] ?? [], 'team_members' => $team_members, ]; if ( $detailed ) { // Lean {id,name} projection: base `categories` only has IDs, and the // raw `category_details` records are too heavy to echo verbatim. $category_details = []; if ( ! empty( $service['category_details'] ) && is_array( $service['category_details'] ) ) { foreach ( $service['category_details'] as $category ) { if ( is_array( $category ) ) { $category_details[] = [ 'id' => $category['id'] ?? ( $category['term_id'] ?? null ), 'name' => $category['name'] ?? '', ]; } } } $shaped['show_end_time'] = $service['show_end_time'] ?? ''; $shaped['additional_durations'] = $service['additional_durations'] ?? []; $shaped['category_details'] = $category_details; $shaped['shortcode'] = $service['shortcode'] ?? ''; } return $shaped; } /** * Curate a normalised Booktics service category (taxonomy term) for output. * * The controller enriches each term with an `id` (= term_id) plus a `services` * field that is sometimes an array of scalar IDs and sometimes an array of * hydrated `{id,title,...}` objects, so this collapses `services` to a stable * list of service IDs the LLM can rely on. * * @param array $category Normalised category term record. * @return array */ protected function shape_category( array $category ): array { $services = is_array( $category['services'] ?? null ) ? $category['services'] : []; $service_ids = array_values( array_filter( array_map( static function ( $service ) { if ( is_array( $service ) ) { return isset( $service['id'] ) ? (int) $service['id'] : 0; } return (int) $service; }, $services ) ) ); return [ 'id' => $category['id'] ?? ( $category['term_id'] ?? null ), 'name' => $category['name'] ?? '', 'slug' => $category['slug'] ?? '', 'description' => $this->truncate( $category['description'] ?? '', 300 ), 'parent' => $category['parent'] ?? 0, 'count' => $category['count'] ?? 0, 'service_ids' => $service_ids, 'image' => $category['image'] ?? '', ]; } /** * Curate a normalised Booktics team member (a WP user) for output. * * A team member's `id` is their WordPress user ID. Contact fields are only * ever exposed by admin-only tools (which see the controller's unmasked * response) and the self-service profile tool, so no extra masking is applied * here. * * @param array $member Normalised team-member record (from to_array()). * @param bool $detailed Include the working schedule and feature flags. * @return array */ protected function shape_team_member( array $member, bool $detailed = false ): array { $name = $member['display_name'] ?? ''; if ( '' === trim( (string) $name ) ) { $name = trim( ( $member['first_name'] ?? '' ) . ' ' . ( $member['last_name'] ?? '' ) ); } $service_ids = array_values( array_filter( array_map( 'intval', (array) ( $member['services'] ?? [] ) ) ) ); $shaped = [ 'id' => $member['id'] ?? ( $member['ID'] ?? null ), 'name' => $name, 'first_name' => $member['first_name'] ?? '', 'last_name' => $member['last_name'] ?? '', 'email' => $member['email'] ?? ( $member['user_email'] ?? '' ), 'phone' => $member['phone'] ?? '', 'status' => $member['status'] ?? '', 'service_ids' => $service_ids, 'booking_count' => $member['booking_count'] ?? 0, ]; if ( $detailed ) { $shaped['image'] = $member['image'] ?? ''; $shaped['schedule'] = $member['schedule'] ?? []; $shaped['custom'] = $member['custom'] ?? []; $shaped['holiday'] = $member['holiday'] ?? []; $shaped['workdays'] = $member['workdays'] ?? []; $shaped['enable_custom_schedule'] = $member['enable_custom_schedule'] ?? false; $shaped['enable_custom_holiday'] = $member['enable_custom_holiday'] ?? false; } return $shaped; } /** * Curate a normalised Booktics module/integration for output. * * @param array $module Normalised module record from booktics_modules_list(). * @return array */ protected function shape_module( array $module ): array { return [ 'slug' => $module['slug'] ?? '', 'name' => $module['title'] ?? ( $module['name'] ?? '' ), 'type' => $module['type'] ?? '', 'status' => $module['status'] ?? '', 'is_pro' => (bool) ( $module['is_pro'] ?? false ), 'description' => $this->truncate( $module['description'] ?? '', 200 ), ]; } /** * Assert that a WP user ID is a Booktics team member. * * The team-member model's find() hydrates any existing WP user regardless of * role, and the controller only 404s on a missing user — so a customer or * administrator ID would otherwise be treated as a team member. This confirms * the target actually holds the `booktics_team_member` role. * * @param int $team_member_id WordPress user ID. * @return array|null Error array on failure, null when the user is a team member. */ protected function assert_team_member( int $team_member_id ): ?array { if ( $team_member_id <= 0 ) { return [ 'error' => 'A valid team member ID is required.' ]; } $user = get_userdata( $team_member_id ); if ( ! $user || ! in_array( 'booktics_team_member', (array) $user->roles, true ) ) { return [ 'error' => "Team member #{$team_member_id} was not found." ]; } return null; } }