/** * 1 Asbestos Academy * Amelia Public Booking Bridge * * Current stage: * - Loads public booking configuration * - Retrieves live Amelia availability * - Validates two consecutive days for Level 1 + 2 * - Validates customer and participant information * - Calculates pricing and GST * - Performs a safe booking dry run for normal visitors * - Creates controlled pending Amelia test bookings only for the configured test email * - Prevents notifications, calendar actions, Zoom creation, and duplicate test submissions * * This code does not yet: * - Process a Square payment * - Approve a booking after payment * - Run Amelia post-booking actions * - Send booking notifications */ if ( ! defined( 'ABSPATH' ) ) { exit; } /* |-------------------------------------------------------------------------- | Amelia API key |-------------------------------------------------------------------------- */ if ( ! defined( 'AA_AMELIA_API_KEY' ) ) { define( 'AA_AMELIA_API_KEY', 'DLEF1n39eJvITr2q0NcEkOgyI93N5k2NBJiD/hn9+U/U' ); } /* |-------------------------------------------------------------------------- | Controlled real-booking test mode |-------------------------------------------------------------------------- | | Only the exact email below can create a real Amelia booking at this stage. | Every other visitor continues to receive the safe dry-run response. | */ if ( ! defined( 'AA_BOOKING_REAL_CREATION_ENABLED' ) ) { define( 'AA_BOOKING_REAL_CREATION_ENABLED', true ); } if ( ! defined( 'AA_BOOKING_TEST_EMAIL' ) ) { define( 'AA_BOOKING_TEST_EMAIL', 'training@1asbestosacademy.ca' ); } /* |-------------------------------------------------------------------------- | Locations |-------------------------------------------------------------------------- */ function aa_amelia_locations() { return array( 1 => array( 'id' => 1, 'name' => 'Surrey', 'type' => 'in_person', 'address' => '19232 Enterprise Way, Unit 202, Surrey, BC', 'note' => 'Public Surrey courses may require a minimum number of registered workers.', ), 2 => array( 'id' => 2, 'name' => 'Victoria', 'type' => 'in_person', 'address' => 'Victoria, British Columbia', 'note' => 'The confirmed Victoria venue is provided after the booking is reviewed.', ), 3 => array( 'id' => 3, 'name' => 'Online', 'type' => 'online', 'address' => '', 'note' => 'Virtual appointments are delivered live through Zoom.', ), ); } /* |-------------------------------------------------------------------------- | Public services |-------------------------------------------------------------------------- | | Employee IDs: | 1 = In-Person Training | 9 = WorkSafeBC Online Exams | 10 = VILT Training | */ function aa_amelia_allowed_services() { return array( 3 => array( 'id' => 3, 'provider_id' => 1, 'name' => 'Asbestos Safety – Level 1 + 2', 'theme' => 'asbestos', 'duration' => 480, 'price' => 939.99, 'pricing_type' => 'per_person', 'min_capacity' => 1, 'max_capacity' => 8, 'location_ids' => array( 1, 2 ), 'two_day' => true, 'delivery' => 'in_person', 'appointment_type' => 'training', ), 4 => array( 'id' => 4, 'provider_id' => 9, 'name' => 'WorkSafeBC Asbestos Level 3 Exam', 'theme' => 'exam', 'duration' => 60, 'price' => 99.95, 'pricing_type' => 'per_person', 'min_capacity' => 1, 'max_capacity' => 1, 'location_ids' => array( 3 ), 'two_day' => false, 'delivery' => 'online', 'appointment_type' => 'exam', ), 5 => array( 'id' => 5, 'provider_id' => 9, 'name' => 'WorkSafeBC Asbestos Level S Exam', 'theme' => 'exam', 'duration' => 60, 'price' => 99.95, 'pricing_type' => 'per_person', 'min_capacity' => 1, 'max_capacity' => 1, 'location_ids' => array( 3 ), 'two_day' => false, 'delivery' => 'online', 'appointment_type' => 'exam', ), 6 => array( 'id' => 6, 'provider_id' => 1, 'name' => 'Lead Awareness & Safety – In Person', 'theme' => 'lead', 'duration' => 360, 'price' => 639.99, 'pricing_type' => 'per_person', 'min_capacity' => 1, 'max_capacity' => 16, 'location_ids' => array( 1, 2 ), 'two_day' => false, 'delivery' => 'in_person', 'appointment_type' => 'training', ), 7 => array( 'id' => 7, 'provider_id' => 1, 'name' => 'Asbestos Awareness – Level 1', 'theme' => 'asbestos', 'duration' => 240, 'price' => 175.00, 'pricing_type' => 'per_person', 'min_capacity' => 1, 'max_capacity' => 30, 'location_ids' => array( 1, 2 ), 'two_day' => false, 'delivery' => 'in_person', 'appointment_type' => 'training', ), 8 => array( 'id' => 8, 'provider_id' => 1, 'name' => 'WorkSafeBC Asbestos Level 2 Exam + Practical', 'theme' => 'exam', 'duration' => 480, 'price' => 639.99, 'pricing_type' => 'per_person', 'min_capacity' => 1, 'max_capacity' => 8, 'location_ids' => array( 1, 2 ), 'two_day' => false, 'delivery' => 'in_person', 'appointment_type' => 'exam_practical', ), 9 => array( 'id' => 9, 'provider_id' => 9, 'name' => 'WorkSafeBC Asbestos Level 1 Exam', 'theme' => 'exam', 'duration' => 60, 'price' => 99.95, 'pricing_type' => 'per_person', 'min_capacity' => 1, 'max_capacity' => 1, 'location_ids' => array( 3 ), 'two_day' => false, 'delivery' => 'online', 'appointment_type' => 'exam', ), 10 => array( 'id' => 10, 'provider_id' => 1, 'name' => 'Silica Safety for Concrete Cutting Workers – In Person', 'theme' => 'silica', 'duration' => 180, 'price' => 149.99, 'pricing_type' => 'per_person', 'min_capacity' => 1, 'max_capacity' => 30, 'location_ids' => array( 1, 2 ), 'two_day' => false, 'delivery' => 'in_person', 'appointment_type' => 'training', ), 12 => array( 'id' => 12, 'provider_id' => 10, 'name' => 'Lead Awareness & Safety – VILT', 'theme' => 'lead', 'duration' => 360, 'price' => 639.99, 'pricing_type' => 'per_person', 'min_capacity' => 1, 'max_capacity' => 16, 'location_ids' => array( 3 ), 'two_day' => false, 'delivery' => 'online', 'appointment_type' => 'training', ), 13 => array( 'id' => 13, 'provider_id' => 10, 'name' => 'WHMIS – VILT', 'theme' => 'whmis', 'duration' => 180, 'price' => 149.99, 'pricing_type' => 'whmis_tiered', 'min_capacity' => 1, 'max_capacity' => 30, 'location_ids' => array( 3 ), 'two_day' => false, 'delivery' => 'online', 'appointment_type' => 'training', ), 14 => array( 'id' => 14, 'provider_id' => 1, 'name' => 'WHMIS – In Person', 'theme' => 'whmis', 'duration' => 180, 'price' => 149.99, 'pricing_type' => 'whmis_tiered', 'min_capacity' => 1, 'max_capacity' => 30, 'location_ids' => array( 1, 2 ), 'two_day' => false, 'delivery' => 'in_person', 'appointment_type' => 'training', ), 15 => array( 'id' => 15, 'provider_id' => 10, 'name' => 'Silica Safety for Concrete Cutting Workers – VILT', 'theme' => 'silica', 'duration' => 180, 'price' => 149.99, 'pricing_type' => 'per_person', 'min_capacity' => 1, 'max_capacity' => 30, 'location_ids' => array( 3 ), 'two_day' => false, 'delivery' => 'online', 'appointment_type' => 'training', ), ); } /* |-------------------------------------------------------------------------- | Public service format |-------------------------------------------------------------------------- */ function aa_amelia_public_service_data( $service ) { return array( 'id' => (int) $service['id'], 'providerId' => (int) $service['provider_id'], 'name' => (string) $service['name'], 'theme' => (string) $service['theme'], 'duration' => (int) $service['duration'], 'price' => (float) $service['price'], 'pricingType' => (string) $service['pricing_type'], 'minCapacity' => (int) $service['min_capacity'], 'maxCapacity' => (int) $service['max_capacity'], 'locationIds' => array_values( array_map( 'intval', $service['location_ids'] ) ), 'twoDay' => (bool) $service['two_day'], 'delivery' => (string) $service['delivery'], 'appointmentType' => (string) $service['appointment_type'], ); } /* |-------------------------------------------------------------------------- | API helpers |-------------------------------------------------------------------------- */ function aa_amelia_api_key_is_configured() { if ( ! defined( 'AA_AMELIA_API_KEY' ) ) { return false; } $key = trim( (string) AA_AMELIA_API_KEY ); if ( '' === $key ) { return false; } $placeholders = array( 'INSERT_AMELIA_API_KEY_HERE', 'YOUR_AMELIA_API_KEY', 'PASTE_YOUR_WORKING_AMELIA_API_KEY_HERE', 'PASTE_AMELIA_API_KEY_HERE', ); return ! in_array( $key, $placeholders, true ); } function aa_amelia_api_url( $endpoint, $query = array() ) { $endpoint = '/' . ltrim( (string) $endpoint, '/' ); $args = array_merge( array( 'action' => 'wpamelia_api', 'call' => '/api/v1' . $endpoint, ), $query ); return add_query_arg( $args, admin_url( 'admin-ajax.php' ) ); } function aa_amelia_api_request( $endpoint, $method = 'GET', $body = array(), $query = array() ) { if ( ! aa_amelia_api_key_is_configured() ) { return new WP_Error( 'aa_amelia_missing_key', 'The Amelia API key has not been added.' ); } $args = array( 'method' => strtoupper( $method ), 'timeout' => 30, 'redirection' => 3, 'headers' => array( 'Amelia' => trim( AA_AMELIA_API_KEY ), 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), ); if ( ! empty( $body ) ) { $args['body'] = wp_json_encode( $body ); } $response = wp_remote_request( aa_amelia_api_url( $endpoint, $query ), $args ); if ( is_wp_error( $response ) ) { return $response; } $status_code = (int) wp_remote_retrieve_response_code( $response ); $raw_body = wp_remote_retrieve_body( $response ); $decoded = json_decode( $raw_body, true ); if ( $status_code < 200 || $status_code >= 300 ) { return new WP_Error( 'aa_amelia_http_error', 'Amelia returned HTTP status ' . $status_code . '.', array( 'statusCode' => $status_code, 'response' => is_array( $decoded ) ? $decoded : $raw_body, ) ); } if ( JSON_ERROR_NONE !== json_last_error() ) { return new WP_Error( 'aa_amelia_invalid_json', 'Amelia returned invalid JSON.' ); } return $decoded; } /* |-------------------------------------------------------------------------- | Normalize Amelia slots |-------------------------------------------------------------------------- */ function aa_amelia_normalize_slots( $result ) { $slots = array(); if ( ! is_array( $result ) ) { return $slots; } $raw_slots = array(); if ( isset( $result['data']['slots'] ) && is_array( $result['data']['slots'] ) ) { $raw_slots = $result['data']['slots']; } elseif ( isset( $result['slots'] ) && is_array( $result['slots'] ) ) { $raw_slots = $result['slots']; } elseif ( isset( $result['data'] ) && is_array( $result['data'] ) ) { $raw_slots = $result['data']; } foreach ( $raw_slots as $date => $times ) { if ( ! is_array( $times ) ) { continue; } $clean_times = array(); foreach ( $times as $time_key => $time_value ) { $possible_time = is_string( $time_key ) ? $time_key : $time_value; if ( is_string( $possible_time ) && preg_match( '/^\d{2}:\d{2}$/', $possible_time ) ) { $clean_times[] = $possible_time; } } if ( ! empty( $clean_times ) ) { sort( $clean_times ); $slots[ $date ] = array_values( array_unique( $clean_times ) ); } } ksort( $slots ); return $slots; } /* |-------------------------------------------------------------------------- | Two-day helpers |-------------------------------------------------------------------------- */ function aa_amelia_next_date_string( $date ) { $timezone = wp_timezone(); $date_object = DateTimeImmutable::createFromFormat( '!Y-m-d', (string) $date, $timezone ); if ( false === $date_object ) { return ''; } $date_errors = DateTimeImmutable::getLastErrors(); if ( is_array( $date_errors ) && ( $date_errors['warning_count'] > 0 || $date_errors['error_count'] > 0 ) ) { return ''; } return $date_object ->modify( '+1 day' ) ->format( 'Y-m-d' ); } function aa_amelia_filter_two_day_slots( $slots ) { if ( ! is_array( $slots ) || empty( $slots ) ) { return array(); } $two_day_slots = array(); foreach ( $slots as $first_date => $first_times ) { $second_date = aa_amelia_next_date_string( $first_date ); if ( '' === $second_date || empty( $slots[ $second_date ] ) || ! is_array( $first_times ) || ! is_array( $slots[ $second_date ] ) ) { continue; } $matching_times = array_values( array_intersect( $first_times, $slots[ $second_date ] ) ); if ( empty( $matching_times ) ) { continue; } sort( $matching_times ); $two_day_slots[ $first_date ] = array_values( array_unique( $matching_times ) ); } ksort( $two_day_slots ); return $two_day_slots; } /* |-------------------------------------------------------------------------- | Location validation |-------------------------------------------------------------------------- */ function aa_amelia_service_allows_location( $service, $location_id ) { if ( empty( $service['location_ids'] ) || ! is_array( $service['location_ids'] ) ) { return false; } return in_array( (int) $location_id, array_map( 'intval', $service['location_ids'] ), true ); } /* |-------------------------------------------------------------------------- | Price calculation |-------------------------------------------------------------------------- */ function aa_amelia_calculate_price( $service, $persons ) { $persons = max( 1, (int) $persons ); $unit_price = (float) $service['price']; if ( 'whmis_tiered' === $service['pricing_type'] ) { if ( $persons >= 21 ) { $unit_price = 75.00; } elseif ( $persons >= 11 ) { $unit_price = 100.00; } else { $unit_price = 149.99; } } $subtotal = round( $unit_price * $persons, 2 ); $gst = round( $subtotal * 0.05, 2 ); $total = round( $subtotal + $gst, 2 ); return array( 'currency' => 'CAD', 'persons' => $persons, 'unitPrice' => $unit_price, 'subtotal' => $subtotal, 'gst' => $gst, 'total' => $total, ); } /* |-------------------------------------------------------------------------- | Booking configuration endpoint |-------------------------------------------------------------------------- */ function aa_public_booking_config() { $services = aa_amelia_allowed_services(); $locations = aa_amelia_locations(); $public_services = array(); foreach ( $services as $service ) { $public_services[] = aa_amelia_public_service_data( $service ); } $public_locations = array(); foreach ( $locations as $location ) { $public_locations[] = array( 'id' => (int) $location['id'], 'name' => (string) $location['name'], 'type' => (string) $location['type'], 'address' => (string) $location['address'], 'note' => (string) $location['note'], ); } wp_send_json_success( array( 'timezone' => wp_timezone_string(), 'currency' => 'CAD', 'gstRate' => 0.05, 'nonce' => wp_create_nonce( 'aa_booking_dry_run' ), 'services' => $public_services, 'locations' => $public_locations, ) ); } add_action( 'wp_ajax_aa_public_booking_config', 'aa_public_booking_config' ); add_action( 'wp_ajax_nopriv_aa_public_booking_config', 'aa_public_booking_config' ); /* |-------------------------------------------------------------------------- | Public availability |-------------------------------------------------------------------------- */ function aa_public_availability() { $services = aa_amelia_allowed_services(); $service_id = isset( $_GET['service_id'] ) ? absint( $_GET['service_id'] ) : 0; if ( ! isset( $services[ $service_id ] ) ) { wp_send_json_error( array( 'message' => 'This service is not available through the public booking interface.', ), 400 ); } $service = $services[ $service_id ]; $location_id = isset( $_GET['location_id'] ) ? absint( $_GET['location_id'] ) : 0; $persons = isset( $_GET['persons'] ) ? absint( $_GET['persons'] ) : 1; $persons = max( (int) $service['min_capacity'], min( (int) $service['max_capacity'], $persons ) ); $days = isset( $_GET['days'] ) ? absint( $_GET['days'] ) : 60; $days = max( 7, min( 90, $days ) ); if ( ! aa_amelia_service_allows_location( $service, $location_id ) ) { wp_send_json_error( array( 'message' => 'The selected location is not available for this service.', ), 400 ); } $timezone = wp_timezone(); $start_object = new DateTimeImmutable( 'today', $timezone ); $query_days = ! empty( $service['two_day'] ) ? $days + 1 : $days; $query_end = $start_object->modify( '+' . $query_days . ' days' ); $display_end = $start_object ->modify( '+' . $days . ' days' ) ->format( 'Y-m-d' ); $query = array( 'serviceId' => (int) $service_id, 'locationId' => (int) $location_id, 'persons' => (int) $persons, 'startDateTime' => $start_object->format( 'Y-m-d 00:00' ), 'endDateTime' => $query_end->format( 'Y-m-d 23:59' ), ); $cache_key = 'aa_slots_v5_' . md5( wp_json_encode( $query ) ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { wp_send_json_success( $cached ); } $result = aa_amelia_api_request( '/slots', 'GET', array(), $query ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => 'Availability could not be loaded right now.', ), 503 ); } $clean_slots = aa_amelia_normalize_slots( $result ); if ( ! empty( $service['two_day'] ) ) { $clean_slots = aa_amelia_filter_two_day_slots( $clean_slots ); } foreach ( array_keys( $clean_slots ) as $date ) { if ( $date > $display_end ) { unset( $clean_slots[ $date ] ); } } $response = array( 'service' => aa_amelia_public_service_data( $service ), 'providerId' => (int) $service['provider_id'], 'persons' => $persons, 'locationId' => $location_id, 'pricing' => aa_amelia_calculate_price( $service, $persons ), 'range' => array( 'start' => $start_object->format( 'Y-m-d' ), 'end' => $display_end, ), 'slots' => $clean_slots, ); set_transient( $cache_key, $response, 5 * MINUTE_IN_SECONDS ); wp_send_json_success( $response ); } add_action( 'wp_ajax_aa_public_availability', 'aa_public_availability' ); add_action( 'wp_ajax_nopriv_aa_public_availability', 'aa_public_availability' ); /* |-------------------------------------------------------------------------- | Request helpers |-------------------------------------------------------------------------- */ function aa_amelia_read_json_request() { $raw_body = file_get_contents( 'php://input' ); if ( ! is_string( $raw_body ) || '' === trim( $raw_body ) ) { return array(); } $decoded = json_decode( $raw_body, true ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) { return array(); } return $decoded; } function aa_amelia_request_ip() { return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; } function aa_amelia_rate_limit_check( $action, $maximum_requests = 20, $window_seconds = 300 ) { $key = 'aa_rate_' . md5( $action . '|' . aa_amelia_request_ip() ); $current = get_transient( $key ); if ( false === $current ) { set_transient( $key, 1, $window_seconds ); return true; } $current = (int) $current; if ( $current >= $maximum_requests ) { return false; } set_transient( $key, $current + 1, $window_seconds ); return true; } /* |-------------------------------------------------------------------------- | Customer validation |-------------------------------------------------------------------------- */ function aa_amelia_validate_customer( $customer ) { $errors = array(); $first_name = isset( $customer['firstName'] ) ? sanitize_text_field( $customer['firstName'] ) : ''; $last_name = isset( $customer['lastName'] ) ? sanitize_text_field( $customer['lastName'] ) : ''; $email = isset( $customer['email'] ) ? sanitize_email( $customer['email'] ) : ''; $phone = isset( $customer['phone'] ) ? sanitize_text_field( $customer['phone'] ) : ''; $company = isset( $customer['company'] ) ? sanitize_text_field( $customer['company'] ) : ''; if ( strlen( $first_name ) < 2 ) { $errors['firstName'] = 'Enter the customer’s first name.'; } if ( strlen( $last_name ) < 2 ) { $errors['lastName'] = 'Enter the customer’s last name.'; } if ( ! is_email( $email ) ) { $errors['email'] = 'Enter a valid email address.'; } $phone_digits = preg_replace( '/\D+/', '', $phone ); if ( strlen( $phone_digits ) < 10 ) { $errors['phone'] = 'Enter a valid phone number.'; } return array( 'valid' => empty( $errors ), 'errors' => $errors, 'data' => array( 'firstName' => $first_name, 'lastName' => $last_name, 'email' => $email, 'phone' => $phone, 'company' => $company, ), ); } /* |-------------------------------------------------------------------------- | Participant validation |-------------------------------------------------------------------------- */ function aa_amelia_validate_participants( $participants, $persons ) { $errors = array(); $clean = array(); if ( ! is_array( $participants ) ) { $participants = array(); } if ( count( $participants ) !== (int) $persons ) { $errors['participants'] = 'Participant information is required for every worker.'; return array( 'valid' => false, 'errors' => $errors, 'data' => array(), ); } foreach ( $participants as $index => $participant ) { $number = $index + 1; $first_name = isset( $participant['firstName'] ) ? sanitize_text_field( $participant['firstName'] ) : ''; $last_name = isset( $participant['lastName'] ) ? sanitize_text_field( $participant['lastName'] ) : ''; $email = isset( $participant['email'] ) ? sanitize_email( $participant['email'] ) : ''; if ( strlen( $first_name ) < 2 ) { $errors[ 'participant_' . $number . '_firstName' ] = 'Enter participant ' . $number . ' first name.'; } if ( strlen( $last_name ) < 2 ) { $errors[ 'participant_' . $number . '_lastName' ] = 'Enter participant ' . $number . ' last name.'; } if ( '' !== $email && ! is_email( $email ) ) { $errors[ 'participant_' . $number . '_email' ] = 'Enter a valid email for participant ' . $number . '.'; } $clean[] = array( 'firstName' => $first_name, 'lastName' => $last_name, 'email' => $email, ); } return array( 'valid' => empty( $errors ), 'errors' => $errors, 'data' => $clean, ); } /* |-------------------------------------------------------------------------- | Date and time validation |-------------------------------------------------------------------------- */ function aa_amelia_validate_date_time( $date, $time ) { if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { return false; } if ( ! preg_match( '/^\d{2}:\d{2}$/', $time ) ) { return false; } $timezone = wp_timezone(); $date_time = DateTimeImmutable::createFromFormat( '!Y-m-d H:i', $date . ' ' . $time, $timezone ); if ( false === $date_time ) { return false; } $date_errors = DateTimeImmutable::getLastErrors(); if ( is_array( $date_errors ) && ( $date_errors['warning_count'] > 0 || $date_errors['error_count'] > 0 ) ) { return false; } $now = new DateTimeImmutable( 'now', $timezone ); return $date_time > $now; } /* |-------------------------------------------------------------------------- | Recheck one Amelia slot |-------------------------------------------------------------------------- */ function aa_amelia_slot_is_available( $service_id, $location_id, $persons, $date, $time ) { $query = array( 'serviceId' => (int) $service_id, 'locationId' => (int) $location_id, 'persons' => (int) $persons, 'startDateTime' => $date . ' 00:00', 'endDateTime' => $date . ' 23:59', ); $result = aa_amelia_api_request( '/slots', 'GET', array(), $query ); if ( is_wp_error( $result ) ) { return $result; } $slots = aa_amelia_normalize_slots( $result ); return ( ! empty( $slots[ $date ] ) && in_array( $time, $slots[ $date ], true ) ); } /* |-------------------------------------------------------------------------- | Controlled Amelia booking creation helpers |-------------------------------------------------------------------------- */ function aa_amelia_exact_test_email_matches( $email ) { if ( ! defined( 'AA_BOOKING_REAL_CREATION_ENABLED' ) || true !== AA_BOOKING_REAL_CREATION_ENABLED ) { return false; } if ( ! defined( 'AA_BOOKING_TEST_EMAIL' ) ) { return false; } $test_email = sanitize_email( (string) AA_BOOKING_TEST_EMAIL ); $email = sanitize_email( (string) $email ); return ( '' !== $test_email && strtolower( $test_email ) === strtolower( $email ) ); } function aa_amelia_find_customer_by_email( $email ) { $email = sanitize_email( $email ); if ( ! is_email( $email ) ) { return new WP_Error( 'aa_invalid_customer_email', 'The customer email is invalid.' ); } $result = aa_amelia_api_request( '/users/customers', 'GET', array(), array( 'page' => 1, 'search' => $email, ) ); if ( is_wp_error( $result ) ) { return $result; } $users = ( isset( $result['data']['users'] ) && is_array( $result['data']['users'] ) ) ? $result['data']['users'] : array(); foreach ( $users as $user ) { $user_email = isset( $user['email'] ) ? sanitize_email( $user['email'] ) : ''; if ( '' !== $user_email && strtolower( $user_email ) === strtolower( $email ) ) { return $user; } } return null; } function aa_amelia_get_or_create_customer( $customer ) { $existing = aa_amelia_find_customer_by_email( $customer['email'] ); if ( is_wp_error( $existing ) ) { return $existing; } if ( is_array( $existing ) && ! empty( $existing['id'] ) ) { return array( 'id' => (int) $existing['id'], 'created' => false, 'user' => $existing, ); } $note_parts = array( 'Created through the 1 Asbestos Academy controlled public booking test.', ); if ( ! empty( $customer['company'] ) ) { $note_parts[] = 'Company: ' . $customer['company']; } $result = aa_amelia_api_request( '/users/customers', 'POST', array( 'firstName' => $customer['firstName'], 'lastName' => $customer['lastName'], 'email' => $customer['email'], 'phone' => $customer['phone'], 'countryPhoneIso' => 'ca', 'note' => implode( "\n", $note_parts ), 'language' => 'en_US', ) ); if ( is_wp_error( $result ) ) { return $result; } $user = ( isset( $result['data']['user'] ) && is_array( $result['data']['user'] ) ) ? $result['data']['user'] : array(); if ( empty( $user['id'] ) ) { return new WP_Error( 'aa_amelia_customer_id_missing', 'Amelia created a customer but did not return a customer ID.' ); } return array( 'id' => (int) $user['id'], 'created' => true, 'user' => $user, ); } function aa_amelia_build_internal_notes( $service, $location, $customer, $participants, $pricing, $day_label = '' ) { $lines = array( 'Created through the 1 Asbestos Academy controlled public booking test.', 'No Square payment was collected.', 'Amelia post-booking actions were intentionally not run.', ); if ( '' !== $day_label ) { $lines[] = 'Course day: ' . $day_label; } $lines[] = 'Service: ' . $service['name']; $lines[] = 'Location: ' . $location['name']; if ( ! empty( $customer['company'] ) ) { $lines[] = 'Company: ' . $customer['company']; } $lines[] = sprintf( 'Booking contact: %s %s <%s> %s', $customer['firstName'], $customer['lastName'], $customer['email'], $customer['phone'] ); $lines[] = 'Participants: ' . count( $participants ); foreach ( $participants as $index => $participant ) { $participant_line = sprintf( 'Participant %d: %s %s', $index + 1, $participant['firstName'], $participant['lastName'] ); if ( ! empty( $participant['email'] ) ) { $participant_line .= ' <' . $participant['email'] . '>'; } $lines[] = $participant_line; } $lines[] = sprintf( 'Quoted price: CAD $%.2f subtotal + $%.2f GST = $%.2f total.', $pricing['subtotal'], $pricing['gst'], $pricing['total'] ); return implode( "\n", $lines ); } function aa_amelia_extract_created_booking( $result ) { $data = ( isset( $result['data'] ) && is_array( $result['data'] ) ) ? $result['data'] : array(); $appointment = ( isset( $data['appointment'] ) && is_array( $data['appointment'] ) ) ? $data['appointment'] : array(); $booking = ( isset( $data['booking'] ) && is_array( $data['booking'] ) ) ? $data['booking'] : array(); if ( empty( $booking ) && ! empty( $appointment['bookings'][0] ) && is_array( $appointment['bookings'][0] ) ) { $booking = $appointment['bookings'][0]; } $payment = ( isset( $data['payment'] ) && is_array( $data['payment'] ) ) ? $data['payment'] : array(); if ( empty( $payment ) && ! empty( $booking['payments'][0] ) && is_array( $booking['payments'][0] ) ) { $payment = $booking['payments'][0]; } return array( 'appointmentId' => isset( $appointment['id'] ) ? (int) $appointment['id'] : 0, 'bookingId' => isset( $booking['id'] ) ? (int) $booking['id'] : 0, 'customerId' => isset( $booking['customerId'] ) ? (int) $booking['customerId'] : 0, 'paymentId' => isset( $payment['id'] ) ? (int) $payment['id'] : 0, ); } function aa_amelia_delete_appointment_safely( $appointment_id ) { $appointment_id = absint( $appointment_id ); if ( 0 === $appointment_id ) { return true; } $result = aa_amelia_api_request( '/appointments/delete/' . $appointment_id, 'POST' ); return ! is_wp_error( $result ); } function aa_amelia_create_one_pending_booking( $service, $location, $customer, $customer_id, $participants, $persons, $date, $time, $pricing, $day_label = '' ) { $duration_seconds = (int) $service['duration'] * 60; $result = aa_amelia_api_request( '/bookings', 'POST', array( 'type' => 'appointment', 'bookings' => array( array( 'extras' => array(), 'customFields' => new stdClass(), 'deposit' => false, 'locale' => 'en_US', 'utcOffset' => null, 'persons' => (int) $persons, 'customerId' => (int) $customer_id, 'customer' => array( 'id' => (int) $customer_id, 'firstName' => $customer['firstName'], 'lastName' => $customer['lastName'], 'email' => $customer['email'], 'phone' => $customer['phone'], 'countryPhoneIso' => 'ca', 'externalId' => null, ), 'duration' => $duration_seconds, ) ), 'payment' => array( 'gateway' => 'onSite', 'currency' => 'CAD', 'data' => new stdClass(), ), 'bookingStart' => $date . ' ' . $time, 'notifyParticipants' => 0, 'locationId' => (int) $location['id'], 'providerId' => (int) $service['provider_id'], 'serviceId' => (int) $service['id'], 'timeZone' => wp_timezone_string(), 'recurring' => array(), 'runInstantPostBookingActions' => false, ) ); if ( is_wp_error( $result ) ) { return $result; } $created = aa_amelia_extract_created_booking( $result ); if ( empty( $created['appointmentId'] ) || empty( $created['bookingId'] ) ) { return new WP_Error( 'aa_amelia_booking_ids_missing', 'Amelia created a booking but did not return the required IDs.' ); } $notes = aa_amelia_build_internal_notes( $service, $location, $customer, $participants, $pricing, $day_label ); $notes_result = aa_amelia_api_request( '/appointments/' . $created['appointmentId'], 'POST', array( 'internalNotes' => $notes, 'notifyParticipants' => 0, ) ); if ( is_wp_error( $notes_result ) ) { aa_amelia_delete_appointment_safely( $created['appointmentId'] ); return new WP_Error( 'aa_amelia_notes_failed', 'Amelia created the appointment, but its internal notes could not be saved. The appointment was rolled back.' ); } $status_result = aa_amelia_api_request( '/appointments/status/' . $created['appointmentId'], 'POST', array( 'status' => 'pending', 'packageCustomerId' => null, ) ); if ( is_wp_error( $status_result ) ) { aa_amelia_delete_appointment_safely( $created['appointmentId'] ); return new WP_Error( 'aa_amelia_pending_status_failed', 'Amelia created the appointment, but it could not be secured as Pending. The appointment was rolled back.' ); } $created['date'] = $date; $created['time'] = $time; $created['status'] = 'pending'; return $created; } function aa_amelia_create_pending_test_booking( $service, $location, $customer, $participants, $persons, $date, $time, $pricing ) { $customer_result = aa_amelia_get_or_create_customer( $customer ); if ( is_wp_error( $customer_result ) ) { return $customer_result; } $created_appointments = array(); $first_day_label = ! empty( $service['two_day'] ) ? 'Day 1 of 2' : ''; $first_result = aa_amelia_create_one_pending_booking( $service, $location, $customer, $customer_result['id'], $participants, $persons, $date, $time, $pricing, $first_day_label ); if ( is_wp_error( $first_result ) ) { return $first_result; } $created_appointments[] = $first_result; if ( ! empty( $service['two_day'] ) ) { $second_date = aa_amelia_next_date_string( $date ); $second_result = aa_amelia_create_one_pending_booking( $service, $location, $customer, $customer_result['id'], $participants, $persons, $second_date, $time, $pricing, 'Day 2 of 2' ); if ( is_wp_error( $second_result ) ) { $rollback_ok = aa_amelia_delete_appointment_safely( $first_result['appointmentId'] ); if ( ! $rollback_ok ) { return new WP_Error( 'aa_amelia_second_day_failed_manual_cleanup', 'The second course date failed and Amelia could not automatically remove the first appointment. Remove appointment #' . $first_result['appointmentId'] . ' manually before testing again.' ); } return new WP_Error( 'aa_amelia_second_day_failed', 'The second course date could not be created. The first appointment was rolled back.' ); } $created_appointments[] = $second_result; } return array( 'customerId' => (int) $customer_result['id'], 'customerCreated' => (bool) $customer_result['created'], 'appointments' => $created_appointments, ); } /* |-------------------------------------------------------------------------- | Dry-run and controlled real-booking endpoint |-------------------------------------------------------------------------- */ function aa_public_booking_dry_run() { if ( ! aa_amelia_rate_limit_check( 'booking_dry_run', 20, 300 ) ) { wp_send_json_error( array( 'message' => 'Too many requests. Please wait a few minutes and try again.', ), 429 ); } $request = aa_amelia_read_json_request(); $nonce = isset( $request['nonce'] ) ? sanitize_text_field( $request['nonce'] ) : ''; if ( ! wp_verify_nonce( $nonce, 'aa_booking_dry_run' ) ) { wp_send_json_error( array( 'message' => 'The booking session expired. Refresh the page and try again.', ), 403 ); } $services = aa_amelia_allowed_services(); $locations = aa_amelia_locations(); $service_id = isset( $request['serviceId'] ) ? absint( $request['serviceId'] ) : 0; $location_id = isset( $request['locationId'] ) ? absint( $request['locationId'] ) : 0; $persons = isset( $request['persons'] ) ? absint( $request['persons'] ) : 0; $date = isset( $request['date'] ) ? sanitize_text_field( $request['date'] ) : ''; $time = isset( $request['time'] ) ? sanitize_text_field( $request['time'] ) : ''; $errors = array(); if ( ! isset( $services[ $service_id ] ) ) { $errors['serviceId'] = 'Select a valid service.'; } if ( ! isset( $locations[ $location_id ] ) ) { $errors['locationId'] = 'Select a valid location.'; } if ( ! empty( $errors ) ) { wp_send_json_error( array( 'message' => 'The booking selection is invalid.', 'errors' => $errors, ), 400 ); } $service = $services[ $service_id ]; $location = $locations[ $location_id ]; if ( ! aa_amelia_service_allows_location( $service, $location_id ) ) { $errors['locationId'] = 'That location is not available for the selected service.'; } if ( $persons < (int) $service['min_capacity'] || $persons > (int) $service['max_capacity'] ) { $errors['persons'] = sprintf( 'Select between %d and %d participants.', (int) $service['min_capacity'], (int) $service['max_capacity'] ); } if ( 'exam' === $service['appointment_type'] && 1 !== $persons ) { $errors['persons'] = 'Online examination appointments are limited to one candidate.'; } if ( ! aa_amelia_validate_date_time( $date, $time ) ) { $errors['dateTime'] = 'Select a valid future appointment date and time.'; } $customer_input = ( isset( $request['customer'] ) && is_array( $request['customer'] ) ) ? $request['customer'] : array(); $participant_input = ( isset( $request['participants'] ) && is_array( $request['participants'] ) ) ? $request['participants'] : array(); $customer_validation = aa_amelia_validate_customer( $customer_input ); $participant_validation = aa_amelia_validate_participants( $participant_input, $persons ); if ( ! $customer_validation['valid'] ) { $errors = array_merge( $errors, $customer_validation['errors'] ); } if ( ! $participant_validation['valid'] ) { $errors = array_merge( $errors, $participant_validation['errors'] ); } if ( ! empty( $errors ) ) { wp_send_json_error( array( 'message' => 'Please correct the highlighted booking information.', 'errors' => $errors, ), 422 ); } $first_date_available = aa_amelia_slot_is_available( $service_id, $location_id, $persons, $date, $time ); if ( is_wp_error( $first_date_available ) ) { wp_send_json_error( array( 'message' => 'The selected appointment could not be rechecked with Amelia.', ), 503 ); } if ( true !== $first_date_available ) { wp_send_json_error( array( 'message' => 'That appointment is no longer available. Please choose another date or time.', ), 409 ); } $second_date = null; if ( ! empty( $service['two_day'] ) ) { $second_date = aa_amelia_next_date_string( $date ); $second_date_available = aa_amelia_slot_is_available( $service_id, $location_id, $persons, $second_date, $time ); if ( is_wp_error( $second_date_available ) ) { wp_send_json_error( array( 'message' => 'The second training date could not be rechecked with Amelia.', ), 503 ); } if ( true !== $second_date_available ) { wp_send_json_error( array( 'message' => 'The full two-day course is no longer available. Please select another first training date.', ), 409 ); } } $pricing = aa_amelia_calculate_price( $service, $persons ); $timezone = wp_timezone(); $first_start = DateTimeImmutable::createFromFormat( '!Y-m-d H:i', $date . ' ' . $time, $timezone ); $first_end = $first_start->modify( '+' . (int) $service['duration'] . ' minutes' ); $second_training_date = null; if ( ! empty( $service['two_day'] ) ) { $second_start = DateTimeImmutable::createFromFormat( '!Y-m-d H:i', $second_date . ' ' . $time, $timezone ); $second_end = $second_start->modify( '+' . (int) $service['duration'] . ' minutes' ); $second_training_date = array( 'date' => $second_date, 'start' => $second_start->format( DateTimeInterface::ATOM ), 'end' => $second_end->format( DateTimeInterface::ATOM ), ); } $booking_summary = array( 'service' => array( 'id' => $service_id, 'name' => $service['name'], ), 'providerId' => (int) $service['provider_id'], 'location' => array( 'id' => $location_id, 'name' => $location['name'], 'type' => $location['type'], 'address' => $location['address'], ), 'persons' => $persons, 'firstTrainingDate' => array( 'date' => $date, 'start' => $first_start->format( DateTimeInterface::ATOM ), 'end' => $first_end->format( DateTimeInterface::ATOM ), ), 'secondTrainingDate' => $second_training_date, 'customer' => $customer_validation['data'], 'participants' => $participant_validation['data'], 'pricing' => $pricing, 'delivery' => $service['delivery'], 'zoomExpected' => false, 'status' => 'pending', ); $is_real_test = aa_amelia_exact_test_email_matches( $customer_validation['data']['email'] ); if ( ! $is_real_test ) { wp_send_json_success( array( 'message' => 'The booking information passed validation. No booking or payment was created.', 'dryRun' => true, 'booking' => $booking_summary, ) ); } $fingerprint = md5( wp_json_encode( array( 'serviceId' => $service_id, 'locationId' => $location_id, 'persons' => $persons, 'date' => $date, 'time' => $time, 'email' => strtolower( $customer_validation['data']['email'] ), ) ) ); $lock_key = 'aa_real_booking_' . $fingerprint; $existing_lock = get_transient( $lock_key ); if ( is_array( $existing_lock ) ) { if ( isset( $existing_lock['state'] ) && 'complete' === $existing_lock['state'] ) { wp_send_json_success( $existing_lock['response'] ); } wp_send_json_error( array( 'message' => 'This test booking is already being processed. Wait a moment before trying again.', ), 409 ); } set_transient( $lock_key, array( 'state' => 'processing', ), 2 * MINUTE_IN_SECONDS ); $creation_result = aa_amelia_create_pending_test_booking( $service, $location, $customer_validation['data'], $participant_validation['data'], $persons, $date, $time, $pricing ); if ( is_wp_error( $creation_result ) ) { delete_transient( $lock_key ); wp_send_json_error( array( 'message' => $creation_result->get_error_message(), 'code' => $creation_result->get_error_code(), ), 502 ); } $response = array( 'message' => 'Test booking created in Amelia as Pending. No payment, notification, calendar action, or Zoom meeting was created.', 'dryRun' => false, 'testMode' => true, 'booking' => $booking_summary, 'amelia' => $creation_result, ); set_transient( $lock_key, array( 'state' => 'complete', 'response' => $response, ), 15 * MINUTE_IN_SECONDS ); wp_send_json_success( $response ); } add_action( 'wp_ajax_aa_public_booking_dry_run', 'aa_public_booking_dry_run' ); add_action( 'wp_ajax_nopriv_aa_public_booking_dry_run', 'aa_public_booking_dry_run' ); https://ratsbc.ca/page-sitemap.xml 2026-06-12T04:11:51+00:00