Files
homeproz/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-api-client.php
T
root b6df4dbb92 Snapshot: MLS sync fixes, image refresh, plugin/theme updates
MLS plugin fixes from this session:
- Fix silent insert failures: location column NOT NULL was rejecting wpdb->insert calls,
  causing ~18k new properties since Dec 2025 to be lost. Inserts now build raw SQL
  with ST_PointFromText so the spatial column is populated atomically.
- Auto-refresh expired media URLs in MLS_Media_Handler::fetch_and_cache(), guarded by
  a property-level GET_LOCK so concurrent fetches share one API refresh.
- Normalize WP_Error to null in mls_get_property_image() so callers can rely on the
  documented string|null contract.
- Support comma-separated property_type filters in MLS_Query and MLS_Cluster so the
  homepage "View All Commercial" link (?property_type=Commercial+Sale,Land,Farm)
  actually filters correctly.
- Incremental sync now looks back 10 minutes past the latest modification timestamp
  as a safety margin against missed records.
- Smart sync exits silently (info-level, not warning) when a full sync is in progress.

Operational:
- New cron: weekly full sync Sundays at 3 AM (/usr/local/bin/mls-full-sync).
- New cron: hourly 2GB cap on mls-thumbnails/ and cache/transformed-images/
  (/usr/local/bin/mls-image-cache-cap).
- Logrotate config for wp-content/debug.log (2-day retention, daily rotation,
  delaycompress).

Repo policy:
- CLAUDE.md updated with explicit "commit everything except build artifacts" policy.
- .gitignore: untrack runtime image caches and debug.log rotations.

Other modifications in this snapshot are pre-existing in-flight theme/plugin/db_content_updates work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:32:23 +00:00

571 lines
18 KiB
PHP
Executable File

<?php
/**
* MLS Grid API Client
*
* Handles all communication with the MLS Grid API including:
* - Authentication (Bearer token)
* - Gzip compression (required)
* - Pagination via @odata.nextLink
* - Rate limit compliance
* - Error handling and retries
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_API_Client {
/**
* Default records per page (max 5000 without expand, 1000 with expand)
*/
const DEFAULT_TOP = 1000;
const MAX_TOP_WITH_EXPAND = 1000;
const MAX_TOP_NO_EXPAND = 5000;
/**
* Request timeout in seconds
*/
const TIMEOUT = 60;
/**
* Max retry attempts
*/
const MAX_RETRIES = 3;
/**
* Options instance
*/
private $options;
/**
* Rate limiter instance
*/
private $rate_limiter;
/**
* Logger instance
*/
private $logger;
/**
* Constructor
*
* @param MLS_Options $options Options instance
* @param MLS_Rate_Limiter $rate_limiter Rate limiter instance
* @param MLS_Logger $logger Logger instance
*/
public function __construct(MLS_Options $options, MLS_Rate_Limiter $rate_limiter, MLS_Logger $logger) {
$this->options = $options;
$this->rate_limiter = $rate_limiter;
$this->logger = $logger;
}
/**
* Test API connection
*
* @return array Result with success status and message
*/
public function test_connection() {
$start_time = microtime(true);
$response = $this->request('');
$elapsed = round((microtime(true) - $start_time) * 1000);
if (is_wp_error($response)) {
return array(
'success' => false,
'error' => $response->get_error_message(),
'response_time' => $elapsed,
);
}
return array(
'success' => true,
'message' => 'Connection successful',
'response_time' => $elapsed,
'endpoints' => isset($response['value']) ? array_column($response['value'], 'name') : array(),
);
}
/**
* Test API authentication
*
* @return array Result with success status
*/
public function test_auth() {
// Try to fetch a single property to verify auth
$response = $this->get_properties(null, null, 1);
if (is_wp_error($response)) {
$error_code = $response->get_error_code();
$error_message = $response->get_error_message();
if (strpos($error_message, '401') !== false || strpos($error_message, 'Unauthorized') !== false) {
return array(
'success' => false,
'error' => 'Authentication failed. Please check your API token.',
);
}
return array(
'success' => false,
'error' => $error_message,
);
}
return array(
'success' => true,
'message' => 'Authentication successful',
'originating_system' => $this->options->get_originating_system(),
);
}
/**
* Make an API request
*
* @param string $endpoint API endpoint (relative to base URL)
* @param array $params Query parameters
* @param int $retry Current retry attempt
* @param string $channel Rate limit channel ('general' or 'image')
* @return array|WP_Error Response data or error
*/
public function request($endpoint, $params = array(), $retry = 0, $channel = 'general') {
// Check and wait for rate limits (uses global advisory lock coordination)
$this->rate_limiter->check_and_wait(true, $channel);
$url = $this->build_url($endpoint, $params);
$this->logger->debug('API Request', array(
'url' => $url,
'retry' => $retry,
));
$args = array(
'method' => 'GET',
'timeout' => self::TIMEOUT,
'headers' => array(
'Authorization' => 'Bearer ' . $this->options->get_api_token(),
'Accept' => 'application/json',
'Accept-Encoding' => 'gzip',
'User-Agent' => 'MLS-by-HansonXyz/' . MLS_PLUGIN_VERSION . ' WordPress/' . get_bloginfo('version'),
),
);
$response = wp_remote_get($url, $args);
// Track the request
$bytes = 0;
if (!is_wp_error($response)) {
$body = wp_remote_retrieve_body($response);
$bytes = strlen($body);
}
$this->rate_limiter->record_request($bytes);
// Handle errors
if (is_wp_error($response)) {
$this->logger->error('API Request Failed', array(
'error' => $response->get_error_message(),
'url' => $url,
));
// Retry on transient errors
if ($retry < self::MAX_RETRIES) {
sleep(pow(2, $retry)); // Exponential backoff
return $this->request($endpoint, $params, $retry + 1, $channel);
}
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
// Handle HTTP errors
if ($status_code >= 400) {
$error_message = $this->parse_error_response($body, $status_code);
$this->logger->error('API HTTP Error', array(
'status' => $status_code,
'error' => $error_message,
'url' => $url,
));
// Retry on 429 (rate limit) or 5xx errors
if (($status_code === 429 || $status_code >= 500) && $retry < self::MAX_RETRIES) {
$wait = $status_code === 429 ? 60 : pow(2, $retry);
sleep($wait);
return $this->request($endpoint, $params, $retry + 1, $channel);
}
return new WP_Error('api_error', $error_message, array('status' => $status_code));
}
// Parse JSON response
$data = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->logger->error('API JSON Parse Error', array(
'error' => json_last_error_msg(),
'body_preview' => substr($body, 0, 500),
));
return new WP_Error('json_error', 'Failed to parse API response: ' . json_last_error_msg());
}
$this->logger->debug('API Response', array(
'status' => $status_code,
'record_count' => isset($data['value']) ? count($data['value']) : 0,
'has_next' => isset($data['@odata.nextLink']),
));
return $data;
}
/**
* Build full URL with parameters
*
* @param string $endpoint Endpoint
* @param array $params Parameters
* @return string Full URL
*/
private function build_url($endpoint, $params = array()) {
$base_url = rtrim($this->options->get_api_url(), '/');
if (!empty($endpoint)) {
$url = $base_url . '/' . ltrim($endpoint, '/');
} else {
$url = $base_url . '/';
}
if (!empty($params)) {
$query_string = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
// OData uses $ prefix which gets encoded, decode it
$query_string = str_replace('%24', '$', $query_string);
$url .= '?' . $query_string;
}
return $url;
}
/**
* Parse error response
*
* @param string $body Response body
* @param int $status_code HTTP status code
* @return string Error message
*/
private function parse_error_response($body, $status_code) {
$data = json_decode($body, true);
if (isset($data['error']['message'])) {
return $data['error']['message'];
}
if (isset($data['message'])) {
return $data['message'];
}
return "HTTP Error {$status_code}";
}
/**
* Get properties from API
*
* @param string|null $filter OData filter
* @param string|null $expand Expand parameter (Media, Rooms, UnitTypes)
* @param int|null $top Number of records to fetch
* @return array|WP_Error Response data or error
*/
public function get_properties($filter = null, $expand = null, $top = null) {
$params = array();
// Build filter - always include originating system
$system = $this->options->get_originating_system();
$base_filter = "OriginatingSystemName eq '{$system}'";
if ($filter) {
$params['$filter'] = $base_filter . ' and ' . $filter;
} else {
$params['$filter'] = $base_filter . ' and MlgCanView eq true';
}
// Expand for media
if ($expand) {
$params['$expand'] = $expand;
}
// Records per page
if ($top) {
$params['$top'] = min($top, $expand ? self::MAX_TOP_WITH_EXPAND : self::MAX_TOP_NO_EXPAND);
} else {
$params['$top'] = $expand ? self::MAX_TOP_WITH_EXPAND : self::DEFAULT_TOP;
}
return $this->request('Property', $params);
}
/**
* Get properties modified since timestamp
*
* @param string $timestamp ISO 8601 timestamp
* @param string|null $expand Expand parameter
* @param int|null $top Number of records
* @return array|WP_Error Response data or error
*/
public function get_properties_since($timestamp, $expand = null, $top = null) {
$filter = "ModificationTimestamp gt {$timestamp}";
return $this->get_properties($filter, $expand, $top);
}
/**
* Get properties for initial sync (Active/Pending only)
*
* @param string|null $expand Expand parameter
* @param int|null $top Number of records
* @return array|WP_Error Response data or error
*/
public function get_properties_for_initial_sync($expand = null, $top = null) {
$params = array();
$system = $this->options->get_originating_system();
// Initial sync: only Active/Pending with MlgCanView=true
$params['$filter'] = "OriginatingSystemName eq '{$system}' and MlgCanView eq true and (StandardStatus eq 'Active' or StandardStatus eq 'Pending')";
if ($expand) {
$params['$expand'] = $expand;
}
if ($top) {
$params['$top'] = min($top, $expand ? self::MAX_TOP_WITH_EXPAND : self::MAX_TOP_NO_EXPAND);
} else {
$params['$top'] = $expand ? self::MAX_TOP_WITH_EXPAND : self::DEFAULT_TOP;
}
return $this->request('Property', $params);
}
/**
* Get properties modified since timestamp (for replication)
*
* Does NOT filter by MlgCanView or StandardStatus so we can detect:
* - Records that became unavailable (MlgCanView=false)
* - Records that changed status (Active -> Sold)
*
* @param string $timestamp ISO 8601 modification timestamp
* @param string|null $expand Expand parameter
* @param int|null $top Number of records
* @return array|WP_Error Response data or error
*/
public function get_properties_for_replication($timestamp, $expand = null, $top = null) {
$params = array();
$system = $this->options->get_originating_system();
// Replication: get ALL changes since timestamp (no MlgCanView or Status filter)
$params['$filter'] = "OriginatingSystemName eq '{$system}' and ModificationTimestamp gt {$timestamp}";
if ($expand) {
$params['$expand'] = $expand;
}
if ($top) {
$params['$top'] = min($top, $expand ? self::MAX_TOP_WITH_EXPAND : self::MAX_TOP_NO_EXPAND);
} else {
$params['$top'] = $expand ? self::MAX_TOP_WITH_EXPAND : self::DEFAULT_TOP;
}
return $this->request('Property', $params);
}
/**
* Get a single property by listing ID with media
*
* Used to refresh media URLs for a specific listing without
* fetching the entire dataset. Uses the 'image' rate limit channel
* with a 2-second interval for on-demand image requests.
*
* Note: MLS Grid only allows filtering by ListingId (not ListingKey)
* for the Property resource. The caller must provide the listing_id.
*
* @param string $listing_id The MLS listing ID (not listing_key)
* @return array|WP_Error Property data with Media or error
*/
public function get_property_media($listing_id) {
$params = array();
$system = $this->options->get_originating_system();
// Filter by ListingId (MLS Grid only allows certain fields for filtering)
$params['$filter'] = "OriginatingSystemName eq '{$system}' and ListingId eq '{$listing_id}'";
$params['$expand'] = 'Media';
$params['$top'] = 1;
// Use 'image' channel with 2-second rate limiting for on-demand media fetches
$response = $this->request('Property', $params, 0, 'image');
if (is_wp_error($response)) {
return $response;
}
// Return first result or null
if (isset($response['value']) && !empty($response['value'])) {
return $response['value'][0];
}
return null;
}
/**
* Get multiple properties by listing IDs with media (batched)
*
* Fetches up to 25 properties in a single API request using OData 'in' filter.
* Used for efficient media URL refresh without making individual API calls.
* Uses the 'image' rate limit channel with 2-second interval.
*
* @param array $listing_ids Array of MLS listing IDs (max 25)
* @return array|WP_Error Array of property data with Media, or error
*/
public function get_properties_by_ids($listing_ids) {
if (empty($listing_ids)) {
return array('value' => array());
}
// Limit to 25 (MLS Grid's max with $expand)
$listing_ids = array_slice($listing_ids, 0, 25);
$params = array();
$system = $this->options->get_originating_system();
// Build 'in' filter: ListingId in ('ID1', 'ID2', 'ID3')
$escaped_ids = array_map(function($id) {
return "'" . addslashes($id) . "'";
}, $listing_ids);
$in_list = implode(',', $escaped_ids);
$params['$filter'] = "OriginatingSystemName eq '{$system}' and ListingId in ({$in_list})";
$params['$expand'] = 'Media';
$params['$top'] = 25;
// Use 'image' channel with 2-second rate limiting for media fetches
return $this->request('Property', $params, 0, 'image');
}
/**
* Get next page of results
*
* @param string $next_link The @odata.nextLink URL
* @return array|WP_Error Response data or error
*/
public function get_next_page($next_link) {
// Check and wait for rate limits
$this->rate_limiter->check_and_wait(true);
$this->logger->debug('API Next Page Request', array(
'url' => $next_link,
));
$args = array(
'method' => 'GET',
'timeout' => self::TIMEOUT,
'headers' => array(
'Authorization' => 'Bearer ' . $this->options->get_api_token(),
'Accept' => 'application/json',
'Accept-Encoding' => 'gzip',
'User-Agent' => 'MLS-by-HansonXyz/' . MLS_PLUGIN_VERSION . ' WordPress/' . get_bloginfo('version'),
),
);
$response = wp_remote_get($next_link, $args);
// Track the request
$bytes = 0;
if (!is_wp_error($response)) {
$body = wp_remote_retrieve_body($response);
$bytes = strlen($body);
}
$this->rate_limiter->record_request($bytes);
if (is_wp_error($response)) {
$this->logger->error('API Next Page Failed', array(
'error' => $response->get_error_message(),
));
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
if ($status_code >= 400) {
$error_message = $this->parse_error_response($body, $status_code);
return new WP_Error('api_error', $error_message, array('status' => $status_code));
}
$data = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return new WP_Error('json_error', 'Failed to parse API response');
}
return $data;
}
/**
* Get members (agents) from API
*
* @param string|null $filter OData filter
* @return array|WP_Error Response data or error
*/
public function get_members($filter = null) {
$params = array();
$system = $this->options->get_originating_system();
$base_filter = "OriginatingSystemName eq '{$system}'";
if ($filter) {
$params['$filter'] = $base_filter . ' and ' . $filter;
} else {
$params['$filter'] = $base_filter;
}
return $this->request('Member', $params);
}
/**
* Get offices from API
*
* @param string|null $filter OData filter
* @return array|WP_Error Response data or error
*/
public function get_offices($filter = null) {
$params = array();
$system = $this->options->get_originating_system();
$base_filter = "OriginatingSystemName eq '{$system}'";
if ($filter) {
$params['$filter'] = $base_filter . ' and ' . $filter;
} else {
$params['$filter'] = $base_filter;
}
return $this->request('Office', $params);
}
/**
* Get lookup values (field definitions)
* Note: Should not be called more than once per day
*
* @return array|WP_Error Response data or error
*/
public function get_lookups() {
$system = $this->options->get_originating_system();
$params = array(
'$filter' => "OriginatingSystemName eq '{$system}'",
);
return $this->request('Lookup', $params);
}
}