load_api_key(); $this->init_hooks(); } /** * Load API key from secure storage or plain option * Uses dedicated geocoding key if available, falls back to maps key */ private function load_api_key(): void { // First try dedicated geocoding API key (IP-restricted for server-side use) // Check plain option first (simpler setup) $this->api_key = get_option('hvac_google_geocoding_api_key', ''); // Try secure storage if plain option not set if (empty($this->api_key) && class_exists('HVAC_Secure_Storage')) { $this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_geocoding_api_key', ''); } // Fall back to maps API key if geocoding key not set if (empty($this->api_key)) { if (class_exists('HVAC_Secure_Storage')) { $this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', ''); } if (empty($this->api_key)) { $this->api_key = get_option('hvac_google_maps_api_key', ''); } } } /** * Initialize hooks */ private function init_hooks(): void { // Auto-geocode new venues on save add_action('save_post_tribe_venue', [$this, 'maybe_geocode_venue'], 20, 2); // Register geocoding action for async processing add_action('hvac_geocode_venue', [$this, 'geocode_venue']); // Admin action for batch geocoding add_action('wp_ajax_hvac_batch_geocode_venues', [$this, 'ajax_batch_geocode']); // Admin action for marking venues as approved labs (legacy) add_action('wp_ajax_hvac_mark_venues_approved', [$this, 'ajax_mark_venues_approved']); // Admin action for updating approved labs list add_action('wp_ajax_hvac_update_approved_labs', [$this, 'ajax_update_approved_labs']); // Clear venue coordinates when address changes add_action('updated_post_meta', [$this, 'on_venue_meta_update'], 10, 4); } /** * Maybe geocode venue on save * * @param int $venue_id Venue post ID * @param WP_Post $post Post object */ public function maybe_geocode_venue(int $venue_id, WP_Post $post): void { // Skip autosaves and revisions if (wp_is_post_autosave($venue_id) || wp_is_post_revision($venue_id)) { return; } // Check if coordinates already exist $has_coords = $this->venue_has_coordinates($venue_id); if (!$has_coords) { // Schedule geocoding to avoid blocking save wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$venue_id]); } } /** * Check if venue already has coordinates * * @param int $venue_id Venue post ID * @return bool */ public function venue_has_coordinates(int $venue_id): bool { // Check custom coordinates $lat = get_post_meta($venue_id, 'venue_latitude', true); $lng = get_post_meta($venue_id, 'venue_longitude', true); if (!empty($lat) && !empty($lng)) { return true; } // Check TEC built-in coordinates $tec_lat = get_post_meta($venue_id, '_VenueLat', true); $tec_lng = get_post_meta($venue_id, '_VenueLng', true); return !empty($tec_lat) && !empty($tec_lng); } /** * Geocode a venue * * @param int $venue_id Venue post ID * @return bool Success */ public function geocode_venue(int $venue_id): bool { // Rate limiting check if (!$this->check_rate_limit()) { // Reschedule wp_schedule_single_event(time() + 60, 'hvac_geocode_venue', [$venue_id]); return false; } // Build address $address = $this->build_venue_address($venue_id); if (empty($address)) { update_post_meta($venue_id, '_venue_geocoding_status', 'no_address'); return false; } update_post_meta($venue_id, '_venue_geocoding_attempt', time()); // Check cache first $cache_key = 'venue_geo_' . md5($address); $cached = get_transient($cache_key); if ($cached !== false) { return $this->save_coordinates($venue_id, $cached); } // Make API request $result = $this->geocode_address($address); if ($result && isset($result['lat'], $result['lng'])) { // Cache result set_transient($cache_key, $result, $this->cache_duration); return $this->save_coordinates($venue_id, $result); } // Handle failure $this->handle_geocoding_failure($venue_id, $result); return false; } /** * Build venue address string from TEC meta * * @param int $venue_id Venue post ID * @return string Full address */ private function build_venue_address(int $venue_id): string { $parts = []; $address = get_post_meta($venue_id, '_VenueAddress', true); $city = get_post_meta($venue_id, '_VenueCity', true); $state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true); $zip = get_post_meta($venue_id, '_VenueZip', true); $country = get_post_meta($venue_id, '_VenueCountry', true); if (!empty($address)) { $parts[] = $address; } if (!empty($city)) { $parts[] = $city; } if (!empty($state)) { $parts[] = $state; } if (!empty($zip)) { $parts[] = $zip; } if (!empty($country)) { $parts[] = $country; } return implode(', ', $parts); } /** * Make geocoding API request * * @param string $address Address to geocode * @return array|null Result with lat/lng or null on failure */ private function geocode_address(string $address): ?array { if (empty($this->api_key)) { return ['error' => 'No API key configured']; } $url = 'https://maps.googleapis.com/maps/api/geocode/json'; $params = [ 'address' => $address, 'key' => $this->api_key, 'components' => 'country:US|country:CA' // Restrict to North America ]; $response = wp_remote_get($url . '?' . http_build_query($params), [ 'timeout' => 10, 'user-agent' => 'HVAC Training Directory/1.0' ]); if (is_wp_error($response)) { return ['error' => $response->get_error_message()]; } $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); if (!$data || $data['status'] !== 'OK' || empty($data['results'])) { return ['error' => $data['status'] ?? 'Unknown error']; } $location = $data['results'][0]['geometry']['location']; return [ 'lat' => $location['lat'], 'lng' => $location['lng'], 'formatted_address' => $data['results'][0]['formatted_address'], 'place_id' => $data['results'][0]['place_id'] ?? '' ]; } /** * Save coordinates to venue * * @param int $venue_id Venue post ID * @param array $result Geocoding result * @return bool Success */ private function save_coordinates(int $venue_id, array $result): bool { if (!isset($result['lat']) || !isset($result['lng'])) { return false; } // Save to our custom meta update_post_meta($venue_id, 'venue_latitude', $result['lat']); update_post_meta($venue_id, 'venue_longitude', $result['lng']); // Also update TEC's meta for compatibility update_post_meta($venue_id, '_VenueLat', $result['lat']); update_post_meta($venue_id, '_VenueLng', $result['lng']); if (!empty($result['formatted_address'])) { update_post_meta($venue_id, '_venue_formatted_address', $result['formatted_address']); } update_post_meta($venue_id, '_venue_geocoding_status', 'success'); update_post_meta($venue_id, '_venue_geocoding_date', time()); return true; } /** * Handle geocoding failure * * @param int $venue_id Venue post ID * @param array|null $result Error result */ private function handle_geocoding_failure(int $venue_id, ?array $result): void { $error = $result['error'] ?? 'Unknown error'; update_post_meta($venue_id, '_venue_geocoding_status', 'failed'); update_post_meta($venue_id, '_venue_geocoding_error', $error); // Handle specific errors switch ($error) { case 'OVER_QUERY_LIMIT': // Retry in 1 hour wp_schedule_single_event(time() + HOUR_IN_SECONDS, 'hvac_geocode_venue', [$venue_id]); break; case 'ZERO_RESULTS': // Try fallback with less specific address $this->try_fallback_geocoding($venue_id); break; default: // Log error if (defined('WP_DEBUG') && WP_DEBUG) { error_log("HVAC Venue Geocoding failed for venue {$venue_id}: {$error}"); } } } /** * Try fallback geocoding with city/state only * * @param int $venue_id Venue post ID */ private function try_fallback_geocoding(int $venue_id): void { $city = get_post_meta($venue_id, '_VenueCity', true); $state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true); $country = get_post_meta($venue_id, '_VenueCountry', true) ?: 'USA'; if (empty($city) && empty($state)) { return; } $address = implode(', ', array_filter([$city, $state, $country])); $result = $this->geocode_address($address); if ($result && isset($result['lat'], $result['lng'])) { $this->save_coordinates($venue_id, $result); update_post_meta($venue_id, '_venue_geocoding_status', 'success_fallback'); } } /** * Check rate limiting * * @return bool Can make request */ private function check_rate_limit(): bool { $rate_key = 'hvac_venue_geocoding_rate_' . gmdate('Y-m-d-H-i'); $current = get_transient($rate_key) ?: 0; if ($current >= $this->rate_limit) { return false; } set_transient($rate_key, $current + 1, 60); return true; } /** * Clear coordinates when venue address changes * * @param int $meta_id Meta ID * @param int $post_id Post ID * @param string $meta_key Meta key * @param mixed $meta_value Meta value */ public function on_venue_meta_update(int $meta_id, int $post_id, string $meta_key, $meta_value): void { if (get_post_type($post_id) !== 'tribe_venue') { return; } $address_fields = ['_VenueAddress', '_VenueCity', '_VenueStateProvince', '_VenueState', '_VenueZip']; if (in_array($meta_key, $address_fields, true)) { // Address changed - clear coordinates to force re-geocoding delete_post_meta($post_id, 'venue_latitude'); delete_post_meta($post_id, 'venue_longitude'); delete_post_meta($post_id, '_venue_geocoding_status'); // Schedule re-geocoding wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$post_id]); } } /** * AJAX handler for batch geocoding venues */ public function ajax_batch_geocode(): void { // Check permissions if (!current_user_can('manage_options')) { wp_send_json_error(['message' => 'Permission denied']); return; } // Verify nonce if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_batch_geocode_venues')) { wp_send_json_error(['message' => 'Invalid security token']); return; } $limit = absint($_POST['limit'] ?? 10); $result = $this->batch_geocode_venues($limit); wp_send_json_success($result); } /** * AJAX handler for marking geocoded venues as approved training labs */ public function ajax_mark_venues_approved(): void { // Check permissions if (!current_user_can('manage_options')) { wp_send_json_error(['message' => 'Permission denied']); return; } // Verify nonce if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_mark_venues_approved')) { wp_send_json_error(['message' => 'Invalid security token']); return; } $result = $this->mark_geocoded_venues_as_approved(); wp_send_json_success($result); } /** * Mark all geocoded venues as approved training labs * * @return array Results with count of venues marked */ public function mark_geocoded_venues_as_approved(): array { // Get all venues that have coordinates but are NOT already approved labs $venues = get_posts([ 'post_type' => 'tribe_venue', 'posts_per_page' => -1, 'post_status' => 'publish', 'meta_query' => [ 'relation' => 'OR', [ 'key' => 'venue_latitude', 'compare' => 'EXISTS' ], [ 'key' => '_VenueLat', 'compare' => 'EXISTS' ] ], 'tax_query' => [ [ 'taxonomy' => 'venue_type', 'field' => 'slug', 'terms' => 'mq-approved-lab', 'operator' => 'NOT IN' ] ] ]); $results = [ 'marked' => 0, 'failed' => 0, 'total_approved' => 0 ]; // Load venue categories class if (!class_exists('HVAC_Venue_Categories')) { require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-venue-categories.php'; } $venue_categories = HVAC_Venue_Categories::instance(); foreach ($venues as $venue) { $result = $venue_categories->set_as_approved_lab($venue->ID); if (is_wp_error($result)) { $results['failed']++; } else { $results['marked']++; } } // Count total approved labs $approved_query = new WP_Query([ 'post_type' => 'tribe_venue', 'posts_per_page' => 1, 'post_status' => 'publish', 'fields' => 'ids', 'tax_query' => [ [ 'taxonomy' => 'venue_type', 'field' => 'slug', 'terms' => 'mq-approved-lab', ] ] ]); $results['total_approved'] = $approved_query->found_posts; return $results; } /** * AJAX handler for updating approved labs list with specific venue IDs */ public function ajax_update_approved_labs(): void { // Check permissions if (!current_user_can('manage_options')) { wp_send_json_error(['message' => 'Permission denied']); return; } // Verify nonce if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_mark_venues_approved')) { wp_send_json_error(['message' => 'Invalid security token']); return; } // Get venue IDs from request (these are the ones that should be approved) $selected_ids = isset($_POST['venue_ids']) ? array_map('absint', (array)$_POST['venue_ids']) : []; // Load venue categories class if (!class_exists('HVAC_Venue_Categories')) { require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-venue-categories.php'; } $venue_categories = HVAC_Venue_Categories::instance(); // Get all venues $all_venues = get_posts([ 'post_type' => 'tribe_venue', 'posts_per_page' => -1, 'post_status' => 'publish', 'fields' => 'ids' ]); $added = 0; $removed = 0; foreach ($all_venues as $venue_id) { $is_currently_approved = has_term('mq-approved-lab', 'venue_type', $venue_id); $should_be_approved = in_array($venue_id, $selected_ids); if ($should_be_approved && !$is_currently_approved) { // Add the term wp_set_post_terms($venue_id, ['mq-approved-lab'], 'venue_type', false); $added++; } elseif (!$should_be_approved && $is_currently_approved) { // Remove the term wp_remove_object_terms($venue_id, 'mq-approved-lab', 'venue_type'); $removed++; } } wp_send_json_success([ 'approved_count' => count($selected_ids), 'added' => $added, 'removed' => $removed ]); } /** * Batch geocode venues without coordinates * * @param int $limit Maximum venues to process * @return array Results */ public function batch_geocode_venues(int $limit = 10): array { $venues = get_posts([ 'post_type' => 'tribe_venue', 'posts_per_page' => $limit, 'post_status' => 'publish', 'meta_query' => [ 'relation' => 'AND', [ 'key' => 'venue_latitude', 'compare' => 'NOT EXISTS' ], [ 'key' => '_VenueLat', 'compare' => 'NOT EXISTS' ] ] ]); $results = [ 'processed' => 0, 'success' => 0, 'failed' => 0, 'remaining' => 0 ]; foreach ($venues as $venue) { if (!$this->check_rate_limit()) { break; } $results['processed']++; if ($this->geocode_venue($venue->ID)) { $results['success']++; } else { $results['failed']++; } } // Count remaining $remaining_query = new WP_Query([ 'post_type' => 'tribe_venue', 'posts_per_page' => 1, 'post_status' => 'publish', 'fields' => 'ids', 'meta_query' => [ 'relation' => 'AND', [ 'key' => 'venue_latitude', 'compare' => 'NOT EXISTS' ], [ 'key' => '_VenueLat', 'compare' => 'NOT EXISTS' ] ] ]); $results['remaining'] = $remaining_query->found_posts; return $results; } /** * Get geocoding status for a venue * * @param int $venue_id Venue post ID * @return array Status info */ public function get_geocoding_status(int $venue_id): array { return [ 'has_coordinates' => $this->venue_has_coordinates($venue_id), 'latitude' => get_post_meta($venue_id, 'venue_latitude', true) ?: get_post_meta($venue_id, '_VenueLat', true), 'longitude' => get_post_meta($venue_id, 'venue_longitude', true) ?: get_post_meta($venue_id, '_VenueLng', true), 'status' => get_post_meta($venue_id, '_venue_geocoding_status', true), 'error' => get_post_meta($venue_id, '_venue_geocoding_error', true), 'last_attempt' => get_post_meta($venue_id, '_venue_geocoding_attempt', true), 'geocoded_date' => get_post_meta($venue_id, '_venue_geocoding_date', true) ]; } /** * Clear coordinates for a venue * * @param int $venue_id Venue post ID */ public function clear_coordinates(int $venue_id): void { delete_post_meta($venue_id, 'venue_latitude'); delete_post_meta($venue_id, 'venue_longitude'); delete_post_meta($venue_id, '_VenueLat'); delete_post_meta($venue_id, '_VenueLng'); delete_post_meta($venue_id, '_venue_formatted_address'); delete_post_meta($venue_id, '_venue_geocoding_status'); delete_post_meta($venue_id, '_venue_geocoding_error'); delete_post_meta($venue_id, '_venue_geocoding_date'); } }