upskill-event-manager/includes/find-training/class-hvac-venue-geocoding.php
ben 21c908af81 feat(find-training): New Google Maps page replacing buggy MapGeo implementation
Implements /find-training page with Google Maps JavaScript API:
- Interactive map showing trainers (teal) and venues (orange) markers
- MarkerClusterer for dense areas
- Filter by State, Certification, Training Format
- Search by name/location
- "Near Me" geolocation with proximity filtering
- Trainer profile modal with contact form
- Venue info modal with upcoming events
- 301 redirect from /find-a-trainer to /find-training
- Auto-geocoding for new TEC venues via Google API

Multi-model code review fixes (GPT-5, Gemini 3, Zen MCP):
- Added missing contact form AJAX handler with rate limiting
- Fixed XSS risk in InfoWindow (DOM creation vs inline onclick)
- Added caching for filter dropdown queries (1-hour TTL)
- Added AJAX abort handling to prevent race conditions
- Replaced alert() with inline error notifications

New files:
- includes/find-training/class-hvac-find-training-page.php
- includes/find-training/class-hvac-training-map-data.php
- includes/find-training/class-hvac-venue-geocoding.php
- templates/page-find-training.php
- assets/js/find-training-map.js
- assets/js/find-training-filters.js
- assets/css/find-training-map.css
- assets/images/marker-trainer.svg
- assets/images/marker-venue.svg

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 23:20:34 -04:00

517 lines
16 KiB
PHP

<?php
/**
* Venue Geocoding Service
*
* Handles geocoding for TEC venues to add lat/lng coordinates.
*
* @package HVAC_Community_Events
* @since 2.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class HVAC_Venue_Geocoding
*
* Manages geocoding of venue addresses using Google Maps Geocoding API.
* Auto-geocodes new venues and provides batch processing for existing venues.
*/
class HVAC_Venue_Geocoding {
/**
* Singleton instance
*
* @var HVAC_Venue_Geocoding|null
*/
private static ?self $instance = null;
/**
* Google Maps API key
*
* @var string
*/
private string $api_key = '';
/**
* Rate limit per minute
*
* @var int
*/
private int $rate_limit = 50;
/**
* Cache duration for geocoding results
*
* @var int
*/
private int $cache_duration = DAY_IN_SECONDS;
/**
* Get singleton instance
*
* @return HVAC_Venue_Geocoding
*/
public static function get_instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->load_api_key();
$this->init_hooks();
}
/**
* Load API key from secure storage
*/
private function load_api_key(): void {
if (class_exists('HVAC_Secure_Storage')) {
$this->api_key = HVAC_Secure_Storage::get_credential('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']);
// 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);
}
/**
* 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');
}
}