calendar = new Calendar(); $this->register(); } /** * Register the routes for the controller. */ public function register_routes(): void { add_filter( 'booktics_appointments_for_calendar_view', array( $this, 'add_google_appointments' ), 10, 2 ); add_filter( 'booktics_appointment_scheduled_notification_flow_data', [ $this, 'get_meet_url' ], 10, 2 ); add_filter( 'booktics_order_created_notification_flow_data', [ $this, 'get_meet_url_for_order_mail' ], 10, 3 ); add_filter( 'booktics_booking_calendar_urls', array( $this, 'get_add_to_calendar_links' ), 10, 2 ); add_action( 'booktics_after_appointment_created', array( $this, 'handle_appointment_created' ), 10, 1 ); add_action( 'booktics_after_appointment_updated', array( $this, 'handle_appointment_updated' ), 10, 1 ); add_action( 'booktics_after_appointment_cancelled', array( $this, 'handle_appointment_cancelled' ), 10, 1 ); add_action( 'before_delete_post', array( $this, 'capture_appointment_data_before_delete' ), 10, 1 ); add_action( 'booktics_after_appointment_deleted', array( $this, 'handle_appointment_deleted' ), 10, 1 ); } /** * Add Google Calendar appointments to the appointments list. * * @param array $appointments The appointments list. * @param array $args The arguments passed to the function. * @return array The modified appointments list. */ public function add_google_appointments( $appointments, $args ) { $api_filters = $this->get_google_calendar_api_filters( $args ); $google_calendar_appointments = $this->calendar->get_google_calendar_events( get_current_user_id(), $api_filters ); if ( is_wp_error( $google_calendar_appointments ) ) { return $appointments; } // Get all existing Google Calendar event IDs from Booktics appointments to avoid duplicates $existing_event_ids = $this->get_existing_google_event_ids( $appointments ); foreach ( $google_calendar_appointments as $event ) { // Skip events that already exist as Booktics appointments if ( isset( $event['id'] ) && in_array( $event['id'], $existing_event_ids ) ) { continue; } $appointments[] = $this->calendar->prepare_appointment_data_from_event( $event ); } return $appointments; } /** * Get existing Google Calendar event IDs from current appointments to avoid duplicates. * * @param array $appointments The current appointments list. * @return array Array of Google Calendar event IDs. */ private function get_existing_google_event_ids( $appointments ) { $existing_event_ids = array(); // Get event IDs from the passed appointments array foreach ( $appointments as $appointment ) { $event_id = get_post_meta( $appointment->id, '_google_calendar_event_id', true ); if ( ! empty( $event_id ) ) { $existing_event_ids[] = $event_id; } } return array_unique( $existing_event_ids ); } /** * On appointment created, create a Google Calendar event for the booking. * * @param \Booktics\Models\Appointment_Model $appointment appointment data */ public function handle_appointment_created( $appointment ) { try { $team_member_id = get_post_meta( $appointment->id, 'team_member_id', true ); // If not in meta, check if it's a property of the appointment object if ( !$team_member_id && isset( $appointment->team_member_id ) ) { $team_member_id = $appointment->team_member_id; } $event_id = $this->calendar->create_event( $appointment, $team_member_id ); if ( $event_id ) { update_post_meta( $appointment->id, '_google_calendar_event_id', $event_id ); } } catch ( \Throwable $e ) { if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log( '[Booktics Google Calendar] Failed to create Google Calendar event: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } } /** * On appointment updated, update the Google Calendar event for the booking. * * @param \Booktics\Models\Appointment_Model $appointment appointment data */ public function handle_appointment_updated( $appointment ) { try { $team_member_id = get_post_meta( $appointment->id, 'team_member_id', true ); $event_id = get_post_meta( $appointment->id, '_google_calendar_event_id', true ); if ( $event_id ) { $this->calendar->update_event( $event_id, $appointment, $team_member_id ); } } catch ( \Throwable $e ) { if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log( 'Failed to updated Google Calendar event: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } } /** * On appointment cancelled, remove its Google Calendar event. * * A cancellation only flips the appointment status (no post deletion), so * the delete hooks never fire. Without this the calendar event lingers in * the team member's calendar for a booking that was auto-cancelled (e.g. an * abandoned/unpaid online booking) or refunded, keeping the slot visually * blocked. Deleting the event frees the calendar slot to match the freed * appointment slot. * * @param \Booktics\Models\Appointment_Model $appointment appointment data */ public function handle_appointment_cancelled( $appointment ) { try { if ( ! isset( $appointment->id ) ) { return; } $event_id = get_post_meta( $appointment->id, '_google_calendar_event_id', true ); $team_member_id = get_post_meta( $appointment->id, 'team_member_id', true ); if ( ! $team_member_id && isset( $appointment->team_member_id ) ) { $team_member_id = $appointment->team_member_id; } if ( ! $event_id || ! $team_member_id ) { return; } if ( $this->calendar->delete_event( $event_id, $team_member_id ) ) { delete_post_meta( $appointment->id, '_google_calendar_event_id' ); } } catch ( \Throwable $e ) { if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log( '[Booktics Google Calendar] Failed to delete Google Calendar event on cancel: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } } /** * Capture appointment Google Calendar data before WordPress deletes the post. * * @param int $post_id Post ID being deleted */ public function capture_appointment_data_before_delete( $post_id ) { // Only process booktics appointments if ( get_post_type( $post_id ) !== 'booktics-appointment' ) { return; } // Capture the data we need before deletion $team_member_id = get_post_meta( $post_id, 'team_member_id', true ); $event_id = get_post_meta( $post_id, '_google_calendar_event_id', true ); if ( $team_member_id && $event_id ) { $this->deletion_data[ $post_id ] = array( 'team_member_id' => $team_member_id, 'event_id' => $event_id, ); } } public function handle_appointment_deleted( $appointment ) { try { // Get the data we captured before deletion if ( ! isset( $this->deletion_data[ $appointment->id ] ) ) { return; } $data = $this->deletion_data[ $appointment->id ]; $team_member_id = $data['team_member_id']; $event_id = $data['event_id']; if ( $event_id && $team_member_id ) { $deleted = $this->calendar->delete_event( $event_id, $team_member_id ); if ( $deleted ) { // Clean up the stored data unset( $this->deletion_data[ $appointment->id ] ); } } } catch ( \Throwable $e ) { if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log( '[Booktics Google Calendar] Exception in handle_appointment_deleted: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } } /** * Check if a given request has permission to sync events. * * @param WP_REST_Request $request Full data about the request. * @return bool|WP_Error */ public function permission_check( $request ) { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'rest_forbidden', __( 'You do not have sufficient permissions to access this endpoint.', 'booktics' ), array( 'status' => 403 ) ); } return true; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { return array( 'team_member_id' => array( 'description' => __( 'Team member ID to sync calendar for.', 'booktics' ), 'type' => 'integer', 'required' => true, 'validate_callback' => 'absint', 'sanitize_callback' => 'absint', ), ); } /** * Get Google Meet URL for order mail * * @param array $notification_flow_data The notification flow data * @param array $formatted_appointment The formatted appointment data * @param Order_Model $order_model The order model for the notification data * * @return array The updated notification flow data with Google Meet URL */ public function get_meet_url_for_order_mail( $notification_flow_data, $formatted_appointment, $order_model ) { try { return $this->get_meet_url( $notification_flow_data, $formatted_appointment ); } catch ( \Throwable $e) { return $notification_flow_data; } } /** * Get Google Meet URL * * @param array $notification_flow_data The notification flow data * @param array $formatted_appointment The formatted appointment data * * @return array The updated notification flow data with Google Meet URL */ public function get_meet_url( $notification_flow_data, $formatted_appointment ) { if ( ! isset ( $formatted_appointment['service']['enable_google_meet'] ) ) { return $notification_flow_data; } // If Google Meet is not enabled for the service, return the original notification flow data if ( ! $formatted_appointment['service']['enable_google_meet'] ) { return $notification_flow_data; } $user_id = $formatted_appointment['team']['id'] ?? null; if ( empty( $user_id ) ) { return $notification_flow_data; } // get_access_token_for_api() also refreshes the token when it has expired. $access_token = $this->calendar->get_access_token_for_api( $user_id ); if ( empty( $access_token ) ) { // The booking's own team member owns the Meet link, so a member who never // connected Google - or whose token has died - silently drops the link // from the email. Log it, otherwise the only symptom is an empty // google_meet_url smart tag with nothing to explain it. if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log( '[Booktics Google Calendar] No usable Google token for team member ' . $user_id . '; skipping Meet link.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } return $notification_flow_data; } try { // Get the WordPress timezone $timezone = wp_timezone(); $timezone_string = $timezone->getName(); // Appointment date/time are stored in UTC. Combine them so the Meet event lands on the actual appointment date instead of "today". $appointment_date = $formatted_appointment['date'] ?? ''; $start_timestamp = strtotime( $appointment_date . ' ' . $formatted_appointment['start_time'] . ' UTC' ); $end_timestamp = strtotime( $appointment_date . ' ' . $formatted_appointment['end_time'] . ' UTC' ); // Format dates in ISO 8601 format with timezone using wp_date $start_time = wp_date( 'c', $start_timestamp, $timezone ); $end_time = wp_date( 'c', $end_timestamp, $timezone ); $event_data = array( 'summary' => __( 'Booktics Meeting', 'booktics' ), 'start' => array( 'dateTime' => $start_time, 'timeZone' => $timezone_string, ), 'end' => array( 'dateTime' => $end_time, 'timeZone' => $timezone_string, ), 'conferenceData' => array( 'createRequest' => array( 'requestId' => uniqid( 'meet_', true ), 'conferenceSolutionKey' => array( 'type' => 'hangoutsMeet', ), ), ), ); $response = wp_remote_post( 'https://www.googleapis.com/calendar/v3/calendars/primary/events?conferenceDataVersion=1', array( 'headers' => array( 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $event_data ), 'timeout' => 15, ) ); if ( is_wp_error( $response ) ) { return $notification_flow_data; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); // Get the Meet link from conferenceData if ( isset( $body['hangoutLink'] ) ) { $notification_flow_data['google_meet_url'] = esc_url_raw( $body['hangoutLink'] ); return $notification_flow_data; } // Fallback or log error return $notification_flow_data; } catch ( \Throwable $e ) { return $notification_flow_data; } } /** * Get Google Calendar API filters. * * @param array $args The arguments passed to the function. * @return array The API filters. */ private function get_google_calendar_api_filters( $args ) { $api_filters = array(); // Get start and end dates from args in appropriate format if ( isset( $args['start_date'] ) ) { $api_filters['timeMin'] = rawurlencode( wp_date( DATE_RFC3339, strtotime( $args['start_date'] ) ) ); } if ( isset( $args['end_date'] ) ) { $api_filters['timeMax'] = rawurlencode( wp_date( DATE_RFC3339, strtotime( $args['end_date'] ) ) ); } return $api_filters; } /** * Adds add event to google calendar link for customers in the thank you page. * * @param array $calendars The calendars list. * @param array $request The request data. * @return array The modified calendars list. */ public function get_add_to_calendar_links( $calendars, $request ) { $meeting_id = ! empty( $request['meeting_id'] ) ? intval( $request['meeting_id'] ) : 0; $booking_date = ! empty( $request['booking_date'] ) ? sanitize_text_field( $request['booking_date'] ) : ''; $start_time = ! empty( $request['start_time'] ) ? sanitize_text_field( $request['start_time'] ) : ''; $end_time = ! empty( $request['end_time'] ) ? sanitize_text_field( $request['end_time'] ) : ''; $meeting = ( new Appointment_Model( $meeting_id ) )->to_array(); $service = ( new Service_Model( $meeting['service_id'] ) )->to_array(); // Use the appointment's timezone if available, otherwise fallback to WordPress timezone $appointment_timezone = ! empty( $meeting['timezone'] ) ? $meeting['timezone'] : wp_timezone_string(); $google_url = $this->get_add_to_google_calendar_url( array( 'date' => $booking_date, 'start_time' => $start_time, 'end_time' => $end_time, 'title' => $service['title'], 'description' => $meeting['note'], 'timezone' => $appointment_timezone, ) ); $calendars = array(); if ( $google_url ) { $calendars[] = array( 'id' => 'google', 'name' => esc_html__( 'Google', 'booktics' ), 'url' => esc_url_raw( $google_url ), ); } return $calendars; } /** * Get Google Calendar URL that will be used to add event to Google Calendar * From the customer side. * * @param array $args { * @type string $date Booking date in 'Y-m-d' format. * @type string $start_time Start time in 'H:i' format. * @type string $end_time End time in 'H:i' format. * @type string $location Event location. * @type string $title Event title. * @type string $description Event description. * @type string $timezone Event timezone. * } * * @return string Google Calendar URL */ private function get_add_to_google_calendar_url( $args = array() ) { $defaults = array( 'date' => '', 'start_time' => '', 'end_time' => '', 'location' => '', 'title' => '', 'description' => '', 'timezone' => '', ); $args = wp_parse_args( $args, $defaults ); if ( empty( $args['date'] ) || empty( $args['start_time'] ) || empty( $args['end_time'] ) ) { return ''; } // Create datetime objects with the appointment's timezone and convert to UTC try { // Use the provided timezone for the appointment $appointment_timezone = new \DateTimeZone( $args['timezone'] ?: wp_timezone_string() ); // Create DateTime objects in the appointment's timezone $start_dt = new \DateTime( $args['date'] . ' ' . $args['start_time'], $appointment_timezone ); $end_dt = new \DateTime( $args['date'] . ' ' . $args['end_time'], $appointment_timezone ); // Convert to UTC for Google Calendar $start_dt->setTimezone( new \DateTimeZone( 'UTC' ) ); $end_dt->setTimezone( new \DateTimeZone( 'UTC' ) ); // Format as Google expects: YYYYMMDDTHHMMSSZ $start_utc = $start_dt->format( 'Ymd\THis\Z' ); $end_utc = $end_dt->format( 'Ymd\THis\Z' ); } catch ( \Throwable $e ) { return ''; } $params = array( 'action' => 'TEMPLATE', 'text' => $args['title'], 'dates' => $start_utc . '/' . $end_utc, 'details' => $args['description'], 'location' => $args['location'], 'sf' => 'true', 'output' => 'xml', 'ctz' => $args['timezone'], ); return 'https://calendar.google.com/calendar/render?' . http_build_query( $params ); } }