1,'status_code'=>200,'data'=>..]` * on success and a 4xx status with a `message` (string OR array) on error. * {@see extract_response()} normalises both. Free-text-heavy fields such as the * per-staff weekly `schedule` are summarised by the shapers so they never flood * the model context. */ abstract class Timetics_Base_Tool extends Tool { /** * Weekday keys as Timetics stores them in a meeting schedule. * * A meeting schedule is keyed `[staff_id][DayName][{start,end}]` where * DayName is `ucfirst( gmdate( 'D' ) )` — i.e. Mon..Sun. */ protected const WEEKDAYS = [ 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun' ]; /** * Accepted meeting types. */ protected const MEETING_TYPES = [ 'one-to-one', 'group' ]; /** * Location types that require no third-party (Google Meet / Zoom) connection. * * Timetics also supports `google-meet` and `zoom`, but those fail on save * unless the assigned staff has the integration connected, so the write tools * only offer these self-contained types. */ protected const SIMPLE_LOCATION_TYPES = [ 'in-person-meeting', 'phone-call', 'attendee-call' ]; /** * Accepted meeting visibility values. */ protected const VISIBILITY = [ 'enabled', 'disabled' ]; /** * Whether the Timetics plugin is active. */ protected function plugin_active(): bool { // Not function_exists( 'timetics' ): that helper is namespaced. return class_exists( 'Timetics\Bootstrap' ); } /** * Whether the current user is a full administrator. * * `edit_users` is a core administrator capability that the `timetics-staff` * role does not carry, so it reliably separates a true admin from a staff * member even though both hold `manage_timetics`. */ protected function is_full_admin(): bool { return current_user_can( 'manage_options' ) && current_user_can( 'edit_users' ); } /** * Guard an admin-only tool. */ protected function guard_admin(): ?array { if ( ! $this->plugin_active() ) { return [ 'error' => 'Timetics plugin is not active.' ]; } if ( ! $this->is_full_admin() ) { return [ 'error' => 'You do not have permission for this action. It requires Timetics administrator access.' ]; } return null; } /** * Build a WP_REST_Request for a Timetics controller call. * * Read tools pass query params; write tools (later slices) pass a JSON body. * The optional URL params carry the id segment Timetics controllers read * (e.g. `appointment_id`, `category_id`). * * @param string $method HTTP method (GET, POST, PUT). * @param string $route REST route. * @param array $query Query params (GET) or empty. * @param array|null $body Body to JSON-encode, or null for none. * @param array $url_params URL params such as [ 'appointment_id' => 5 ]. */ protected function build_request( string $method, string $route, array $query = [], ?array $body = null, array $url_params = [] ): \WP_REST_Request|array { $request = new \WP_REST_Request( $method, $route ); if ( $query ) { $request->set_query_params( $query ); } if ( $url_params ) { $request->set_url_params( $url_params ); } if ( null !== $body ) { $json = wp_json_encode( $body ); if ( false === $json ) { Log::error( 'Timetics build_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->set_header( 'content-type', 'application/json' ); $request->set_body( $json ); } return $request; } /** * Unwrap a Timetics REST controller result, normalising the inconsistent * return types (WP_HTTP_Response, WP_Error, or plain array) to a data * array or `[ 'error' => ... ]`. * * @param mixed $result Controller return value. * @param string $fallback_error Human-readable fallback message. */ protected function extract_response( $result, string $fallback_error ): array { if ( is_wp_error( $result ) ) { $message = $this->flatten_message( $result->get_error_message(), $fallback_error ); return [ 'error' => $message ]; } if ( $result instanceof \WP_HTTP_Response ) { $status = (int) $result->get_status(); $body = $result->get_data(); } elseif ( is_array( $result ) ) { // Plain-array return: the HTTP status lives inside the body. $body = $result; $status = (int) ( $result['status_code'] ?? $result['status'] ?? 200 ); } else { Log::warning( 'Timetics controller returned an unexpected type', [ 'type' => gettype( $result ) ] ); return [ 'error' => $fallback_error ]; } // A body may flag failure with success => 0 even on a 200-ish status. $success_flag = is_array( $body ) && array_key_exists( 'success', $body ) ? $body['success'] : null; $is_error = $status >= 400 || ( null !== $success_flag && ! $success_flag ); if ( $is_error ) { $message = $this->flatten_message( is_array( $body ) ? ( $body['message'] ?? '' ) : '', $fallback_error ); $context = [ 'status' => $status, 'message' => $message, ]; if ( $status >= 500 ) { Log::error( 'Timetics controller returned a server error', $context ); } return [ 'error' => $message ]; } if ( ! is_array( $body ) || ! array_key_exists( 'data', $body ) ) { Log::warning( 'Timetics 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 Timetics record could not be read; it may contain invalid characters.' ]; } return $normalized; } /** * Flatten a controller message (string or array) into one string. * * @param mixed $message Raw message value. * @param string $fallback Fallback when empty. */ protected function flatten_message( $message, string $fallback ): string { if ( is_array( $message ) ) { $message = implode( ' ', array_map( 'strval', $message ) ); } $message = trim( (string) $message ); return '' !== $message ? $message : $fallback; } /** * Normalise a value (possibly containing objects) to nested arrays. * * @param mixed $value Any JSON-serialisable value. */ private function normalize( $value ): ?array { $json = wp_json_encode( $value ); if ( false === $json ) { Log::error( 'Timetics 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. */ protected function shape_rows( array $rows, callable $shape ): array { $arrays = array_filter( $rows, 'is_array' ); return array_values( array_map( $shape, $arrays ) ); } /** * Clamp caller-supplied pagination to a safe query fragment. * * Centralises the "default 20, at least 1, at most 50" policy. * * @param int|null $per_page Requested page size. * @param int|null $paged Requested page number. */ protected function clamp_pagination( ?int $per_page, ?int $paged ): array { return [ 'per_page' => min( 50, max( 1, (int) ( $per_page ?? 20 ) ) ), 'paged' => max( 1, (int) ( $paged ?? 1 ) ), ]; } /** * Extract a controller response's list under a given key. * * @param array $data Normalised controller data. * @param string $key List key (defaults to `items`). */ protected function list_items( array $data, string $key = 'items' ): array { return ( isset( $data[ $key ] ) && is_array( $data[ $key ] ) ) ? $data[ $key ] : []; } /** * Sanitise a raw list of IDs to unique, positive integers. * * @param mixed $ids Raw ID list (any iterable of scalar IDs). */ protected function sanitize_id_list( $ids ): array { return array_values( array_unique( array_filter( array_map( 'intval', (array) $ids ) ) ) ); } /** * 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. */ 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 display names for a list of staff (WP user) IDs. * * @param array $staff_ids Staff/WP user IDs. */ protected function staff_names( array $staff_ids ): array { $names = []; foreach ( $staff_ids as $id ) { $id = (int) $id; if ( $id <= 0 ) { continue; } $user = get_userdata( $id ); $names[] = [ 'id' => $id, 'name' => $user ? $user->display_name : '', ]; } return $names; } /** * Extract staff (WP user) IDs from a meeting's `staff` value. * * @param mixed $raw The `staff` value (list of objects, or list of IDs). */ protected function extract_staff_ids( $raw ): array { $ids = []; foreach ( (array) $raw as $entry ) { $id = is_array( $entry ) ? (int) ( $entry['id'] ?? 0 ) : (int) $entry; if ( $id > 0 ) { $ids[] = $id; } } return array_values( array_unique( $ids ) ); } /** * Curate a normalised meeting (appointment) down to what the LLM needs. * * @param array $meeting Normalised meeting record (from prepare_item()). * @param bool $detailed Include the schedule summary, locations, and buffers. */ protected function shape_meeting( array $meeting, bool $detailed = false ): array { $staff_ids = $this->extract_staff_ids( $meeting['staff'] ?? [] ); $category_ids = $this->sanitize_id_list( $meeting['categories'] ?? [] ); $shaped = [ 'id' => $meeting['id'] ?? null, 'name' => $meeting['name'] ?? '', 'type' => $meeting['type'] ?? '', 'duration' => $meeting['duration'] ?? '', 'price' => $this->summarize_price( $meeting['price'] ?? '' ), 'capacity' => $meeting['capacity'] ?? '', 'visibility' => $meeting['visibility'] ?? '', 'staff_ids' => $staff_ids, 'staff' => $this->staff_names( $staff_ids ), 'category_ids' => $category_ids, 'permalink' => $meeting['permalink'] ?? ( $meeting['link'] ?? '' ), ]; if ( $detailed ) { $shaped['description'] = $this->truncate( $meeting['description'] ?? '', 500 ); $shaped['timezone'] = $meeting['timezone'] ?? ''; $shaped['guest_enabled'] = (bool) ( $meeting['guest_enabled'] ?? false ); $shaped['guest_limit'] = $meeting['guest_limit'] ?? ''; $shaped['locations'] = $this->summarize_locations( $meeting['locations'] ?? [] ); $shaped['schedule'] = $this->summarize_schedule( $meeting['schedule'] ?? [] ); $shaped['buffer_before'] = trim( ( $meeting['buffer_time_before_value'] ?? '' ) . ' ' . ( $meeting['buffer_time_before_unit'] ?? '' ) ); $shaped['buffer_after'] = trim( ( $meeting['buffer_time_after_value'] ?? '' ) . ' ' . ( $meeting['buffer_time_after_unit'] ?? '' ) ); } else { $shaped['description'] = $this->truncate( $meeting['description'] ?? '', 160 ); } return $shaped; } /** * Summarise a meeting price (scalar for one-to-one, ticket array for group). * * @param mixed $price Raw price prop. * @return mixed A scalar amount, or a compact list of ticket rows. */ protected function summarize_price( $price ) { if ( ! is_array( $price ) ) { return $price; } $tickets = []; foreach ( $price as $ticket ) { if ( ! is_array( $ticket ) ) { continue; } $tickets[] = [ 'name' => $ticket['ticket_name'] ?? ( $ticket['name'] ?? '' ), 'price' => $ticket['ticket_price'] ?? 0, 'quantity' => $ticket['ticket_quantity'] ?? 0, ]; } return $tickets; } /** * Summarise a meeting's locations to type + address pairs. * * @param mixed $locations Raw locations prop. */ protected function summarize_locations( $locations ): array { if ( ! is_array( $locations ) ) { return []; } $summary = []; foreach ( $locations as $location ) { if ( ! is_array( $location ) ) { continue; } $summary[] = [ 'type' => $location['location_type'] ?? '', 'address' => $this->truncate( $location['location'] ?? ( $location['address'] ?? '' ), 200 ), ]; } return $summary; } /** * Summarise a per-staff weekly schedule into a compact day => hours map. * * @param mixed $schedule Raw schedule prop. */ protected function summarize_schedule( $schedule ): array { if ( ! is_array( $schedule ) ) { return []; } $summary = []; foreach ( $schedule as $staff_id => $days ) { if ( ! is_array( $days ) ) { continue; } $day_map = $this->summarize_day_windows( $days ); $user = get_userdata( (int) $staff_id ); $summary[] = [ 'staff_id' => (int) $staff_id, 'staff_name' => $user ? $user->display_name : '', 'days' => $day_map, ]; } return $summary; } /** * Curate a normalised Timetics meeting category (a WP_Term) for output. * * @param array $category Normalised category term record. */ protected function shape_category( array $category ): array { $meeting_ids = $this->sanitize_id_list( $category['meeting_ids'] ?? [] ); return [ 'id' => $category['term_id'] ?? ( $category['id'] ?? null ), 'name' => $category['name'] ?? '', 'slug' => $category['slug'] ?? '', 'description' => $this->truncate( $category['description'] ?? '', 300 ), 'parent' => $category['parent'] ?? 0, 'count' => $category['count'] ?? 0, 'meeting_ids' => $meeting_ids, ]; } /** * Resolve a valid IANA/offset timezone string for a meeting write. * * @param string|null $preferred Caller-supplied timezone, or null. */ protected function resolve_timezone( ?string $preferred = null ): string { $candidates = [ $preferred ]; if ( function_exists( 'timetics_get_option' ) ) { $candidates[] = timetics_get_option( 'timezone' ); } $candidates[] = aisentic_timezone_string(); foreach ( $candidates as $tz ) { $tz = is_string( $tz ) ? trim( $tz ) : ''; if ( $this->is_valid_timezone( $tz ) ) { return $tz; } } return 'UTC'; } /** * Whether a timezone string is parseable by DateTimeZone. * * @param string $timezone Timezone string. */ protected function is_valid_timezone( string $timezone ): bool { $timezone = trim( $timezone ); if ( '' === $timezone ) { return false; } try { new \DateTimeZone( $timezone ); return true; } catch ( \Exception $e ) { return false; } } /** * Normalise caller-supplied weekday names (any case, full or 3-letter) to * the canonical WEEKDAYS tokens. Unrecognised entries are dropped. * * @param array $days Raw weekday names from the caller. * @return array Canonical weekday tokens, in WEEKDAYS order, de-duplicated. */ protected function normalize_weekdays( array $days ): array { static $map = [ 'mon' => 'Mon', 'monday' => 'Mon', 'tue' => 'Tue', 'tues' => 'Tue', 'tuesday' => 'Tue', 'wed' => 'Wed', 'wednesday' => 'Wed', 'thu' => 'Thu', 'thur' => 'Thu', 'thurs' => 'Thu', 'thursday' => 'Thu', 'fri' => 'Fri', 'friday' => 'Fri', 'sat' => 'Sat', 'saturday' => 'Sat', 'sun' => 'Sun', 'sunday' => 'Sun', ]; $resolved = []; foreach ( $days as $day ) { $key = strtolower( trim( (string) $day ) ); if ( isset( $map[ $key ] ) ) { $resolved[ $map[ $key ] ] = true; } } // Preserve canonical WEEKDAYS order. return array_values( array_intersect( self::WEEKDAYS, array_keys( $resolved ) ) ); } /** * Build a default weekly availability schedule for a set of staff. * * @param array $staff_ids Assigned staff (WP user) IDs. * @param array $days Weekday keys to make available (subset of WEEKDAYS). * @param string $start Window start time, e.g. "09:00 am". * @param string $end Window end time, e.g. "05:00 pm". */ protected function build_default_schedule( array $staff_ids, array $days, string $start, string $end ): array { // Normalise so e.g. "Saturday"/"sat" isn't dropped and silently replaced with Mon-Fri. $days = $this->normalize_weekdays( $days ); if ( empty( $days ) ) { $days = [ 'Mon', 'Tue', 'Wed', 'Thu', 'Fri' ]; } $window = [ [ 'start' => $start, 'end' => $end, ], ]; $schedule = []; foreach ( $staff_ids as $staff_id ) { $staff_id = (int) $staff_id; if ( $staff_id <= 0 ) { continue; } $week = []; foreach ( self::WEEKDAYS as $day ) { $week[ $day ] = in_array( $day, $days, true ) ? $window : []; } $schedule[ $staff_id ] = $week; } return $schedule; } /** * Load a meeting's full current state as a `save_appointment()` body. * * An update must resend every field so this reads live values straight * from the Appointment model for the caller to overlay changes onto. * * @param int $meeting_id Meeting (appointment) post ID. */ protected function load_meeting_state( int $meeting_id ): ?array { if ( ! class_exists( '\Timetics\Core\Appointments\Appointment' ) ) { return null; } $appoint = new \Timetics\Core\Appointments\Appointment( $meeting_id ); if ( ! $appoint->is_appointment() ) { return null; } return [ 'name' => $appoint->get_name(), 'type' => $appoint->get_type(), 'description' => $appoint->get_description(), 'staff' => $appoint->get_staff_ids(), 'locations' => $appoint->get_locations(), 'duration' => $appoint->get_duration(), 'schedule' => $appoint->get_schedule(), 'blocked_schedule' => $appoint->get_blocked_schedule(), 'price' => $appoint->get_price(), 'categories' => $appoint->get_category_ids(), 'timezone' => $appoint->get_timezone(), 'availability' => $appoint->get_availability(), 'visibility' => $appoint->get_visibility(), 'capacity' => $appoint->get_capacity(), 'guest_enabled' => $appoint->get_guest_enabled(), 'guest_limit' => $appoint->get_guest_limit(), 'buffer_time' => $appoint->get_buffer_time(), 'buffer_time_before_value' => $appoint->get_buffer_time_before_value(), 'buffer_time_before_unit' => $appoint->get_buffer_time_before_unit(), 'buffer_time_after_value' => $appoint->get_buffer_time_after_value(), 'buffer_time_after_unit' => $appoint->get_buffer_time_after_unit(), 'min_notice_time' => $appoint->get_min_notice_time(), ]; } /** * Curate a normalised booking down to what the LLM needs. * * @param array $booking Normalised booking record (from prepare_item()). */ protected function shape_booking( array $booking ): array { $customer = is_array( $booking['customer'] ?? null ) ? $booking['customer'] : []; $appointment = is_array( $booking['appointment'] ?? null ) ? $booking['appointment'] : []; $staff = is_array( $booking['staff'] ?? null ) ? $booking['staff'] : []; return [ 'id' => $booking['id'] ?? null, 'status' => $booking['status'] ?? '', 'start_date' => $booking['start_date'] ?? '', 'end_date' => $booking['end_date'] ?? '', 'start_time' => $booking['start_time'] ?? '', 'end_time' => $booking['end_time'] ?? '', 'timezone' => $appointment['timezone'] ?? '', 'order_total' => $booking['order_total'] ?? '', 'payment_method' => $booking['payment_method'] ?? '', 'payment_status' => $booking['payment_status'] ?? '', 'location' => $this->truncate( $booking['location'] ?? '', 200 ), 'location_type' => $booking['location_type'] ?? '', 'meeting_link' => $booking['meeting_link'] ?? '', 'customer' => [ 'id' => $customer['id'] ?? null, 'name' => $customer['full_name'] ?? trim( ( $customer['first_name'] ?? '' ) . ' ' . ( $customer['last_name'] ?? '' ) ), 'email' => $customer['email'] ?? '', 'phone' => $customer['phone'] ?? '', ], 'meeting' => [ 'id' => $appointment['id'] ?? null, 'name' => $appointment['name'] ?? '', 'type' => $appointment['type'] ?? '', 'duration' => $appointment['duration'] ?? '', ], 'staff' => [ 'id' => $staff['id'] ?? null, 'name' => $staff['full_name'] ?? '', 'email' => $staff['email_name'] ?? ( $staff['email'] ?? '' ), ], ]; } /** * Curate a normalised staff (from Staff::get_data()) for output. * * @param array $staff Normalised staff record. */ protected function shape_staff( array $staff ): array { return [ 'id' => $staff['id'] ?? null, 'full_name' => $staff['full_name'] ?? '', 'first_name' => $staff['first_name'] ?? '', 'last_name' => $staff['last_name'] ?? '', 'user_name' => $staff['user_name'] ?? '', 'email' => $staff['email'] ?? '', 'phone' => $staff['phone'] ?? '', 'status' => $staff['status'] ?? '', 'schedule' => $this->summarize_day_windows( $staff['schedule'] ?? [] ), ]; } /** * Curate a normalised customer (from Api_Customer::prepare_item()) for output. * * @param array $customer Normalised customer record. */ protected function shape_customer( array $customer ): array { return [ 'id' => $customer['id'] ?? null, 'full_name' => $customer['full_name'] ?? '', 'first_name' => $customer['first_name'] ?? '', 'last_name' => $customer['last_name'] ?? '', 'email' => $customer['email'] ?? '', 'phone' => $customer['phone'] ?? '', 'total_booking' => $customer['total_booking'] ?? 0, ]; } /** * Summarise a single-level weekly schedule into a compact day => hours map. * * @param mixed $schedule Raw schedule prop. */ protected function summarize_day_windows( $schedule ): array { if ( ! is_array( $schedule ) ) { return []; } $day_map = []; foreach ( $schedule as $day_name => $windows ) { if ( ! is_array( $windows ) ) { continue; } $ranges = []; foreach ( $windows as $window ) { if ( is_array( $window ) && isset( $window['start'], $window['end'] ) ) { $ranges[] = $window['start'] . '-' . $window['end']; } } if ( $ranges ) { $day_map[ $day_name ] = implode( ', ', $ranges ); } } return $day_map; } /** * Reduce a booking-entries `days` payload to available times per date. * * @param mixed $days Raw `days` array from get_entries(). */ protected function shape_slots( $days ): array { if ( ! is_array( $days ) ) { return []; } $out = []; foreach ( $days as $day ) { if ( ! is_array( $day ) ) { continue; } $times = []; foreach ( (array) ( $day['slots'] ?? [] ) as $slot ) { if ( is_array( $slot ) && 'available' === ( $slot['status'] ?? '' ) && ! empty( $slot['start_time'] ) ) { $times[] = $slot['start_time']; } } $out[] = [ 'date' => $day['date'] ?? '', 'available_times' => $times, ]; } return $out; } /** * Load a booking's full current state as a `save_bookings()` body. * * A reschedule or cancel must resend every field. Customer details are * read from the linked Customer user so the resend carries valid values. * * @param int $booking_id Booking post ID. */ protected function load_booking_state( int $booking_id ): ?array { if ( ! class_exists( '\Timetics\Core\Bookings\Booking' ) || ! class_exists( '\Timetics\Core\Customers\Customer' ) ) { return null; } $booking = new \Timetics\Core\Bookings\Booking( $booking_id ); if ( ! $booking->is_booking() ) { return null; } $customer = new \Timetics\Core\Customers\Customer( $booking->get_customer_id() ); $payment_method = (string) $booking->get_payment_method(); return [ 'first_name' => $customer->get_first_name(), 'last_name' => $customer->get_last_name(), 'email' => $customer->get_email(), 'phone' => $customer->get_phone(), 'appointment' => (int) $booking->get_appointment(), 'staff' => (int) $booking->get_staff_id(), 'start_date' => $booking->get_start_date(), 'end_date' => $booking->get_end_date(), 'start_time' => $booking->get_start_time(), 'end_time' => $booking->get_end_time(), 'timezone' => $booking->get_timezone(), 'location' => $booking->get_location(), 'location_type' => $booking->get_location_type(), // payment_method is a required field; fall back to a manual marker so // a reschedule/cancel of a legacy booking without one still validates. 'payment_method' => '' !== $payment_method ? $payment_method : 'offline', 'description' => $booking->get_description(), // Preserve the current status so a reschedule does not silently // re-approve a pending booking (the controller defaults omitted // status to the site's default booking status). 'status' => (string) $booking->get_status(), ]; } /** * Human-readable booking descriptor (customer, meeting, slot) for approval cards. * * @param int $booking_id Booking post ID. * @return string Empty string when the booking can't be loaded. */ protected function describe_booking( int $booking_id ): string { // Renders on the approval card; never let a Timetics getter crash it. try { $state = $this->load_booking_state( $booking_id ); if ( null === $state ) { return ''; } $who = trim( (string) ( $state['first_name'] ?? '' ) . ' ' . (string) ( $state['last_name'] ?? '' ) ); $who = '' !== $who ? $who : (string) ( $state['email'] ?? '' ); $name = ( (int) ( $state['appointment'] ?? 0 ) ) > 0 ? get_the_title( (int) $state['appointment'] ) : ''; $when = trim( (string) ( $state['start_date'] ?? '' ) . ' ' . (string) ( $state['start_time'] ?? '' ) ); $descriptor = $who; if ( '' !== $name ) { $descriptor .= ( '' !== $descriptor ? ' — ' : '' ) . "\"{$name}\""; } if ( '' !== $when ) { $descriptor .= ( '' !== $descriptor ? ' on ' : '' ) . $when; } return $descriptor; } catch ( \Throwable $e ) { return ''; } } /** * Duration of a meeting in seconds (its per-slot interval). * * @param int $meeting_id Meeting (appointment) post ID. */ protected function meeting_interval_seconds( int $meeting_id ): int { if ( ! class_exists( '\Timetics\Core\Appointments\Appointment' ) ) { return 0; } $appoint = new \Timetics\Core\Appointments\Appointment( $meeting_id ); return (int) $appoint->get_interval(); } /** * Normalise a time string to Timetics' slot format (e.g. "9:00am"). * * @param string $time Any strtotime-parseable time. */ protected function normalize_slot_time( string $time ): string { $ts = strtotime( $time ); return false === $ts ? '' : gmdate( 'g:ia', $ts ); } /** * Compute a slot's end time from a start time plus the meeting duration. * * @param string $start_time Start time (strtotime-parseable). * @param int $meeting_id Meeting post ID (supplies the interval). */ protected function compute_end_time( string $start_time, int $meeting_id ): string { $ts = strtotime( $start_time ); if ( false === $ts ) { return ''; } $interval = $this->meeting_interval_seconds( $meeting_id ); return gmdate( 'g:ia', $ts + $interval ); } /** * Validate that a start time is a real, bookable slot for a meeting/staff/day. * * Mirrors the controller's gate: skips the slot check entirely for * buffered meetings, since the controller does not enforce it there. * * @param int $meeting_id Meeting post ID. * @param int $staff_id Staff (WP user) ID. * @param string $start_date Booking date (Y-m-d). * @param string $start_time Requested start time (strtotime-parseable). * @param string $timezone Booking timezone the slots are expressed in. */ protected function validate_booking_slot( int $meeting_id, int $staff_id, string $start_date, string $start_time, string $timezone ): ?array { if ( ! class_exists( '\Timetics\Core\Appointments\Appointment' ) ) { return null; } $appoint = new \Timetics\Core\Appointments\Appointment( $meeting_id ); $has_buffer = $appoint->get_buffer_time_before_in_seconds() > 0 || $appoint->get_buffer_time_after_in_seconds() > 0; if ( $has_buffer ) { // Controller does not enforce the slot list for buffered meetings. return null; } $slots = (array) $appoint->get_avilable_timeslots( $start_date, $staff_id, $timezone ); $requested = $this->normalize_slot_time( $start_time ); if ( '' === $requested || ! in_array( $requested, $slots, true ) ) { return [ 'error' => sprintf( '%s is not an available slot for staff #%d on %s.', '' !== $requested ? $requested : $start_time, $staff_id, $start_date ), 'available_times' => array_values( $slots ), ]; } return null; } }