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>
This commit is contained in:
root
2026-04-29 15:32:23 +00:00
parent 57b752f54e
commit b6df4dbb92
5385 changed files with 838580 additions and 2416 deletions
@@ -50,6 +50,25 @@ class MLS_Query {
return "(street_number IS NULL OR (street_number != 'TBD' AND street_number NOT LIKE 'TBD %'))";
}
/**
* Build a WHERE clause for property_type, supporting comma-separated values.
*
* @param string $property_type Single type or comma-separated types
* @param array &$where WHERE clause fragments
* @param array &$values Prepared statement values
*/
private function build_property_type_clause($property_type, &$where, &$values) {
$types = array_filter(array_map('trim', explode(',', $property_type)));
if (count($types) === 1) {
$where[] = 'property_type = %s';
$values[] = $types[0];
} elseif (count($types) > 1) {
$placeholders = implode(',', array_fill(0, count($types), '%s'));
$where[] = "property_type IN ({$placeholders})";
$values = array_merge($values, $types);
}
}
/**
* Get coordinates for a zip code from geo table
*
@@ -246,13 +265,20 @@ class MLS_Query {
}
if ($args['status']) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
if (is_array($args['status'])) {
// Multiple statuses - use IN clause
$placeholders = implode(',', array_fill(0, count($args['status']), '%s'));
$where[] = "standard_status IN ({$placeholders})";
$values = array_merge($values, $args['status']);
} else {
// Single status
$where[] = 'standard_status = %s';
$values[] = $args['status'];
}
}
if ($args['property_type']) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
$this->build_property_type_clause($args['property_type'], $where, $values);
}
// City and postal_code are mutually exclusive - city takes priority
@@ -503,17 +529,16 @@ class MLS_Query {
}
$sql = "SELECT * FROM {$table}";
// Don't filter by status here - we filter AFTER normalization
// because status is derived from MLS for linked properties
$where = array("standard_status != 'Withdrawn'");
$values = array();
if ($args['status']) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
}
// Store requested status for post-normalization filtering
$requested_status = $args['status'];
if ($args['property_type']) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
$this->build_property_type_clause($args['property_type'], $where, $values);
}
if ($args['city']) {
@@ -566,6 +591,31 @@ class MLS_Query {
$values[] = $args['listing_id'];
}
// Filter by agent MLS ID - find agent post with this MLS ID
if (!empty($args['agent_mls_id'])) {
$agent_query = new WP_Query(array(
'post_type' => 'agent',
'posts_per_page' => 1,
'post_status' => 'publish',
'fields' => 'ids',
'meta_query' => array(
array(
'key' => 'agent_mls_id',
'value' => $args['agent_mls_id'],
'compare' => '=',
),
),
));
if ($agent_query->have_posts()) {
$where[] = 'list_agent_post_id = %d';
$values[] = $agent_query->posts[0];
} else {
// No agent found with this MLS ID - return empty for manual properties
return array();
}
wp_reset_postdata();
}
if ($args['search']) {
$search_term = '%' . $wpdb->esc_like($args['search']) . '%';
$where[] = '(full_address LIKE %s OR city LIKE %s OR public_remarks LIKE %s OR listing_id LIKE %s)';
@@ -616,10 +666,22 @@ class MLS_Query {
}
// Normalize results to match MLS schema
// This fetches the real status from MLS for linked properties
foreach ($results as $key => $property) {
$results[$key] = $this->normalize_manual_property($property);
}
// Filter by status AFTER normalization (status is now MLS-derived)
if ($requested_status) {
$results = array_filter($results, function($property) use ($requested_status) {
if (is_array($requested_status)) {
return in_array($property->standard_status, $requested_status);
}
return $property->standard_status === $requested_status;
});
$results = array_values($results); // Re-index array
}
return $results;
}
@@ -668,9 +730,99 @@ class MLS_Query {
}
}
// Check if media records need refresh (for single property view)
if ($property) {
$this->ensure_media_records($property);
}
return $property;
}
/**
* Ensure media records exist and are fresh for a property
*
* Checks if:
* 1. Media record count < photos_count (missing media records)
* 2. Any media URLs are expired
*
* If either condition is true, refreshes media from the API.
*
* @param object $property Property object with listing_key, listing_id, photos_count
*/
private function ensure_media_records($property) {
global $wpdb;
// Skip for spider/bot requests to avoid unnecessary API calls
if (function_exists('homeproz_is_spider') && homeproz_is_spider()) {
return;
}
// Skip if no photos or no listing_id (can't fetch from API without it)
if (empty($property->photos_count) || $property->photos_count <= 0) {
return;
}
if (empty($property->listing_id)) {
return;
}
$media_table = $this->db->media_table();
$listing_key = $property->listing_key;
// Single query to get media count and earliest expiry
$media_stats = $wpdb->get_row($wpdb->prepare(
"SELECT COUNT(*) as media_count, MIN(url_expires_at) as earliest_expiry
FROM {$media_table}
WHERE listing_key = %s",
$listing_key
));
$media_count = (int) ($media_stats ? $media_stats->media_count : 0);
$earliest_expiry = $media_stats ? $media_stats->earliest_expiry : null;
// Check 1: Do we have fewer media records than photos?
$needs_refresh = $media_count < $property->photos_count;
// Check 2: Are any URLs expired? (only check if we have records)
if (!$needs_refresh && $media_count > 0 && $earliest_expiry) {
$needs_refresh = strtotime($earliest_expiry) < time();
}
if (!$needs_refresh) {
return;
}
// Refresh media from API
$this->refresh_property_media($property->listing_key, $property->listing_id);
}
/**
* Refresh media records for a property from the MLS API
*
* @param string $listing_key Property listing key
* @param string $listing_id Property listing ID (MLS ID)
*/
private function refresh_property_media($listing_key, $listing_id) {
$plugin = mls_plugin();
$api_client = $plugin->get_api_client();
$media_handler = $plugin->get_media_handler();
if (!$api_client || !$media_handler) {
return;
}
// Fetch property with media from API
$property_data = $api_client->get_property_media($listing_id);
if (is_wp_error($property_data) || empty($property_data)) {
return;
}
// Sync media records (URLs only, no downloads)
if (isset($property_data['Media']) && is_array($property_data['Media'])) {
$media_handler->sync_property_media($listing_key, $property_data['Media']);
}
}
/**
* Get a manual property by listing key or listing ID
*
@@ -979,8 +1131,7 @@ class MLS_Query {
}
if (!empty($args['property_type'])) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
$this->build_property_type_clause($args['property_type'], $where, $values);
}
// City and postal_code are mutually exclusive - city takes priority
@@ -1095,8 +1246,7 @@ class MLS_Query {
}
if (!empty($args['property_type'])) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
$this->build_property_type_clause($args['property_type'], $where, $values);
}
if (!empty($args['city'])) {
@@ -1327,8 +1477,7 @@ class MLS_Query {
}
if (!empty($args['property_type'])) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
$this->build_property_type_clause($args['property_type'], $where, $values);
}
// City and postal_code are mutually exclusive - city takes priority