b6df4dbb92
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>
1535 lines
53 KiB
PHP
Executable File
1535 lines
53 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* MLS Query Class
|
|
*
|
|
* Public API for querying cached MLS data
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class MLS_Query {
|
|
|
|
/**
|
|
* Database instance
|
|
*/
|
|
private $db;
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
public function __construct(MLS_DB $db) {
|
|
$this->db = $db;
|
|
}
|
|
|
|
/**
|
|
* Get the state filter SQL clause
|
|
* Restricts results to MN and IA only
|
|
*
|
|
* @return string SQL clause
|
|
*/
|
|
private function get_state_filter() {
|
|
if (!defined('MLS_ALLOWED_STATES') || empty(MLS_ALLOWED_STATES)) {
|
|
return '';
|
|
}
|
|
$states = array_map(function($s) {
|
|
global $wpdb;
|
|
return $wpdb->prepare('%s', $s);
|
|
}, MLS_ALLOWED_STATES);
|
|
return 'state_or_province IN (' . implode(',', $states) . ')';
|
|
}
|
|
|
|
/**
|
|
* Get the TBD address exclusion filter
|
|
* Excludes properties with "TBD" as street number
|
|
*
|
|
* @return string SQL clause
|
|
*/
|
|
private function get_tbd_exclusion_filter() {
|
|
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
|
|
*
|
|
* @param string $zipcode The zip code to look up
|
|
* @return array|null Array with 'latitude' and 'longitude' or null if not found
|
|
*/
|
|
private function get_zipcode_coordinates($zipcode) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->geo_zipcodes_table();
|
|
$result = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT latitude, longitude FROM {$table} WHERE zipcode = %s",
|
|
$zipcode
|
|
));
|
|
|
|
if ($result) {
|
|
return array(
|
|
'latitude' => (float) $result->latitude,
|
|
'longitude' => (float) $result->longitude,
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get coordinates for a city from geo table
|
|
* Searches for city in MN and IA states (matching MLS_ALLOWED_STATES)
|
|
*
|
|
* @param string $city The city name to look up
|
|
* @return array|null Array with 'latitude' and 'longitude' or null if not found
|
|
*/
|
|
private function get_city_coordinates($city) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->geo_cities_table();
|
|
|
|
// Search in allowed states (MN, IA)
|
|
$allowed_states = defined('MLS_ALLOWED_STATES') ? MLS_ALLOWED_STATES : array('MN', 'IA');
|
|
$placeholders = implode(',', array_fill(0, count($allowed_states), '%s'));
|
|
|
|
$values = array_merge(array($city), $allowed_states);
|
|
|
|
$result = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT latitude, longitude FROM {$table} WHERE city = %s AND state_code IN ({$placeholders}) LIMIT 1",
|
|
$values
|
|
));
|
|
|
|
if ($result) {
|
|
return array(
|
|
'latitude' => (float) $result->latitude,
|
|
'longitude' => (float) $result->longitude,
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build SQL for distance-based filtering using spatial index
|
|
* Uses bounding box pre-filter + ST_Distance_Sphere for accuracy
|
|
*
|
|
* @param float $lat Center latitude
|
|
* @param float $lng Center longitude
|
|
* @param float $miles Radius in miles
|
|
* @return string SQL expression for distance filter
|
|
*/
|
|
private function get_distance_filter_sql($lat, $lng, $miles) {
|
|
// Convert miles to meters for ST_Distance_Sphere (returns meters)
|
|
$meters = $miles * 1609.344;
|
|
|
|
// Create center point (SRID 4326 uses lat, lng order in MySQL 8.0+)
|
|
// Use ST_Distance_Sphere with the spatial indexed location column
|
|
return sprintf(
|
|
"ST_Distance_Sphere(location, ST_PointFromText('POINT(%f %f)', 4326)) <= %f",
|
|
$lat,
|
|
$lng,
|
|
$meters
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build SQL for bounding box pre-filter
|
|
* Uses simple BETWEEN for fast initial filtering before distance calc
|
|
* This narrows down candidates significantly before the expensive ST_Distance_Sphere
|
|
*
|
|
* @param float $lat Center latitude
|
|
* @param float $lng Center longitude
|
|
* @param float $miles Radius in miles
|
|
* @return string SQL expression for bounding box filter
|
|
*/
|
|
private function get_bounding_box_filter_sql($lat, $lng, $miles) {
|
|
// Approximate degrees per mile (varies by latitude, using average)
|
|
// 1 degree latitude ≈ 69 miles
|
|
// 1 degree longitude ≈ 69 miles * cos(latitude)
|
|
$lat_delta = $miles / 69.0;
|
|
$lng_delta = $miles / (69.0 * cos(deg2rad($lat)));
|
|
|
|
$min_lat = $lat - $lat_delta;
|
|
$max_lat = $lat + $lat_delta;
|
|
$min_lng = $lng - $lng_delta;
|
|
$max_lng = $lng + $lng_delta;
|
|
|
|
// Use BETWEEN for bounding box - efficient with indexes on lat/lng
|
|
return sprintf(
|
|
"(latitude BETWEEN %f AND %f AND longitude BETWEEN %f AND %f)",
|
|
$min_lat, $max_lat,
|
|
$min_lng, $max_lng
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get properties matching criteria
|
|
*
|
|
* Queries both MLS and manual properties, excluding MLS entries
|
|
* that have manual overrides.
|
|
*
|
|
* @param array $args Query arguments
|
|
* @return array Property objects
|
|
*/
|
|
public function get_properties($args = array()) {
|
|
global $wpdb;
|
|
|
|
$defaults = array(
|
|
'status' => null, // Active, Pending, Closed
|
|
'property_type' => null, // Residential, Land, Commercial
|
|
'city' => null,
|
|
'county' => null,
|
|
'postal_code' => null,
|
|
'center_lat' => null, // Latitude for radius search
|
|
'center_lng' => null, // Longitude for radius search
|
|
'radius' => 30, // Miles for radius search (default 30)
|
|
'min_price' => null,
|
|
'max_price' => null,
|
|
'min_beds' => null,
|
|
'max_beds' => null,
|
|
'min_baths' => null,
|
|
'min_sqft' => null,
|
|
'max_sqft' => null,
|
|
'year_built_min' => null,
|
|
'year_built_max' => null,
|
|
'listing_key' => null,
|
|
'listing_id' => null,
|
|
'agent_mls_id' => null, // Filter by list_agent_mls_id
|
|
'search' => null, // Search in address/remarks
|
|
'bounds' => null, // Map bounds: array(sw_lat, sw_lng, ne_lat, ne_lng)
|
|
'center' => null, // Map center for distance sort: array(lat, lng)
|
|
'featured_ids' => null, // Array of listing_id values to prioritize after HomeProz
|
|
'limit' => 20,
|
|
'offset' => 0,
|
|
'orderby' => 'modification_timestamp',
|
|
'order' => 'DESC',
|
|
'include_media' => false,
|
|
'include_manual' => true, // Include manual properties
|
|
'fields' => '*', // Specific fields or *
|
|
);
|
|
|
|
$args = wp_parse_args($args, $defaults);
|
|
|
|
// Get manual override IDs to exclude from MLS query
|
|
$override_ids = $args['include_manual'] ? $this->get_manual_override_listing_ids() : array();
|
|
|
|
// Build query
|
|
$table = $this->db->properties_table();
|
|
|
|
// Fields
|
|
if ($args['fields'] === '*') {
|
|
$select = '*';
|
|
} else {
|
|
$fields = array_map('sanitize_key', (array) $args['fields']);
|
|
$select = implode(', ', $fields);
|
|
}
|
|
|
|
$sql = "SELECT {$select} FROM {$table}";
|
|
|
|
// WHERE conditions
|
|
// Exclude properties with no price or price < 100 (invalid data)
|
|
$where = array('mlg_can_view = 1', 'list_price >= 100');
|
|
$values = array();
|
|
|
|
// Add state filter (MN and IA only)
|
|
$state_filter = $this->get_state_filter();
|
|
if ($state_filter) {
|
|
$where[] = $state_filter;
|
|
}
|
|
|
|
// Exclude TBD addresses
|
|
$where[] = $this->get_tbd_exclusion_filter();
|
|
|
|
// Exclude MLS properties that have manual overrides
|
|
if (!empty($override_ids)) {
|
|
$placeholders = implode(',', array_fill(0, count($override_ids), '%s'));
|
|
$where[] = $wpdb->prepare("listing_id NOT IN ({$placeholders})", $override_ids);
|
|
}
|
|
|
|
if ($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']) {
|
|
$this->build_property_type_clause($args['property_type'], $where, $values);
|
|
}
|
|
|
|
// City and postal_code are mutually exclusive - city takes priority
|
|
if ($args['city']) {
|
|
// Exact city match
|
|
$where[] = 'city = %s';
|
|
$values[] = $args['city'];
|
|
} elseif ($args['postal_code']) {
|
|
// Exact postal code match
|
|
$where[] = 'postal_code = %s';
|
|
$values[] = $args['postal_code'];
|
|
} elseif ($args['center_lat'] && $args['center_lng']) {
|
|
// Direct lat/lng radius search (from homepage location search)
|
|
// Use bounding box pre-filter for spatial index, then exact distance
|
|
$bbox_filter = $this->get_bounding_box_filter_sql(
|
|
(float) $args['center_lat'],
|
|
(float) $args['center_lng'],
|
|
(int) $args['radius']
|
|
);
|
|
$distance_filter = $this->get_distance_filter_sql(
|
|
(float) $args['center_lat'],
|
|
(float) $args['center_lng'],
|
|
(int) $args['radius']
|
|
);
|
|
$where[] = "({$bbox_filter} AND {$distance_filter})";
|
|
}
|
|
|
|
if ($args['county']) {
|
|
$where[] = 'county = %s';
|
|
$values[] = $args['county'];
|
|
}
|
|
|
|
if ($args['agent_mls_id']) {
|
|
$where[] = 'list_agent_mls_id = %s';
|
|
$values[] = $args['agent_mls_id'];
|
|
}
|
|
|
|
if ($args['min_price']) {
|
|
$where[] = 'list_price >= %d';
|
|
$values[] = (int) $args['min_price'];
|
|
}
|
|
|
|
if ($args['max_price']) {
|
|
$where[] = 'list_price <= %d';
|
|
$values[] = (int) $args['max_price'];
|
|
}
|
|
|
|
if ($args['min_beds']) {
|
|
$where[] = 'bedrooms_total >= %d';
|
|
$values[] = (int) $args['min_beds'];
|
|
}
|
|
|
|
if ($args['max_beds']) {
|
|
$where[] = 'bedrooms_total <= %d';
|
|
$values[] = (int) $args['max_beds'];
|
|
}
|
|
|
|
if ($args['min_baths']) {
|
|
$where[] = 'bathrooms_total >= %d';
|
|
$values[] = (int) $args['min_baths'];
|
|
}
|
|
|
|
if ($args['min_sqft']) {
|
|
$where[] = 'living_area >= %d';
|
|
$values[] = (int) $args['min_sqft'];
|
|
}
|
|
|
|
if ($args['max_sqft']) {
|
|
$where[] = 'living_area <= %d';
|
|
$values[] = (int) $args['max_sqft'];
|
|
}
|
|
|
|
if ($args['year_built_min']) {
|
|
$where[] = 'year_built >= %d';
|
|
$values[] = (int) $args['year_built_min'];
|
|
}
|
|
|
|
if ($args['year_built_max']) {
|
|
$where[] = 'year_built <= %d';
|
|
$values[] = (int) $args['year_built_max'];
|
|
}
|
|
|
|
if ($args['listing_key']) {
|
|
$where[] = 'listing_key = %s';
|
|
$values[] = $args['listing_key'];
|
|
}
|
|
|
|
if ($args['listing_id']) {
|
|
$where[] = 'listing_id = %s';
|
|
$values[] = $args['listing_id'];
|
|
}
|
|
|
|
if ($args['search']) {
|
|
$search_term = '%' . $wpdb->esc_like($args['search']) . '%';
|
|
$where[] = '(street_name LIKE %s OR city LIKE %s OR public_remarks LIKE %s OR listing_id LIKE %s)';
|
|
$values[] = $search_term;
|
|
$values[] = $search_term;
|
|
$values[] = $search_term;
|
|
$values[] = $search_term;
|
|
}
|
|
|
|
// Map bounds filtering
|
|
if ($args['bounds'] && is_array($args['bounds']) && count($args['bounds']) === 4) {
|
|
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
|
|
$where[] = 'latitude BETWEEN %f AND %f';
|
|
$where[] = 'longitude BETWEEN %f AND %f';
|
|
$where[] = 'latitude IS NOT NULL';
|
|
$where[] = 'longitude IS NOT NULL';
|
|
$values[] = (float) $sw_lat;
|
|
$values[] = (float) $ne_lat;
|
|
$values[] = (float) $sw_lng;
|
|
$values[] = (float) $ne_lng;
|
|
}
|
|
|
|
$sql .= ' WHERE ' . implode(' AND ', $where);
|
|
|
|
// ORDER BY
|
|
// Always prioritize: 1) HomeProz listings, 2) Featured listings, 3) Regular listings
|
|
// Build featured sort expression if featured_ids provided
|
|
$featured_sort = '';
|
|
if (!empty($args['featured_ids']) && is_array($args['featured_ids'])) {
|
|
$featured_ids = array_map('sanitize_text_field', $args['featured_ids']);
|
|
$featured_placeholders = implode(',', array_fill(0, count($featured_ids), '%s'));
|
|
$featured_sort = $wpdb->prepare(
|
|
", (CASE WHEN listing_id IN ({$featured_placeholders}) THEN 1 ELSE 0 END) DESC",
|
|
...$featured_ids
|
|
);
|
|
}
|
|
|
|
// If center provided, sort by distance from center
|
|
if ($args['center'] && is_array($args['center']) && count($args['center']) === 2) {
|
|
list($center_lat, $center_lng) = $args['center'];
|
|
// Haversine formula approximation for distance (good enough for sorting)
|
|
// Using squared Euclidean distance with latitude adjustment for speed
|
|
$lat_factor = cos(deg2rad((float) $center_lat));
|
|
$sql .= $wpdb->prepare(
|
|
" ORDER BY is_homeproz DESC{$featured_sort}, (POW(latitude - %f, 2) + POW((longitude - %f) * %f, 2)) ASC",
|
|
(float) $center_lat,
|
|
(float) $center_lng,
|
|
$lat_factor
|
|
);
|
|
} else {
|
|
$allowed_orderby = array(
|
|
'modification_timestamp',
|
|
'list_price',
|
|
'bedrooms_total',
|
|
'bathrooms_total',
|
|
'living_area',
|
|
'year_built',
|
|
'days_on_market',
|
|
'city',
|
|
'created_at',
|
|
);
|
|
|
|
$orderby = in_array($args['orderby'], $allowed_orderby) ? $args['orderby'] : 'modification_timestamp';
|
|
$order = strtoupper($args['order']) === 'ASC' ? 'ASC' : 'DESC';
|
|
$sql .= " ORDER BY is_homeproz DESC{$featured_sort}, {$orderby} {$order}";
|
|
}
|
|
|
|
// LIMIT/OFFSET
|
|
$sql .= ' LIMIT %d OFFSET %d';
|
|
$values[] = (int) $args['limit'];
|
|
$values[] = (int) $args['offset'];
|
|
|
|
// Execute MLS query
|
|
$results = $wpdb->get_results($wpdb->prepare($sql, $values));
|
|
|
|
// Query manual properties with same filters
|
|
if ($args['include_manual']) {
|
|
$manual_results = $this->get_manual_properties($args);
|
|
if (!empty($manual_results)) {
|
|
// Merge manual properties with MLS results
|
|
$results = array_merge($manual_results, $results);
|
|
|
|
// Re-sort: HomeProz first, then featured, then by orderby
|
|
$featured_ids = !empty($args['featured_ids']) ? (array) $args['featured_ids'] : array();
|
|
$order_desc = strtoupper($args['order']) !== 'ASC';
|
|
$orderby = $args['orderby'];
|
|
|
|
usort($results, function($a, $b) use ($featured_ids, $order_desc, $orderby) {
|
|
// 1. HomeProz listings first
|
|
$a_homeproz = !empty($a->is_homeproz) ? 1 : 0;
|
|
$b_homeproz = !empty($b->is_homeproz) ? 1 : 0;
|
|
if ($a_homeproz !== $b_homeproz) {
|
|
return $b_homeproz - $a_homeproz;
|
|
}
|
|
|
|
// 2. Featured listings second
|
|
$a_featured = in_array($a->listing_id, $featured_ids) || !empty($a->is_featured) ? 1 : 0;
|
|
$b_featured = in_array($b->listing_id, $featured_ids) || !empty($b->is_featured) ? 1 : 0;
|
|
if ($a_featured !== $b_featured) {
|
|
return $b_featured - $a_featured;
|
|
}
|
|
|
|
// 3. Order by specified field
|
|
$a_val = isset($a->$orderby) ? $a->$orderby : '';
|
|
$b_val = isset($b->$orderby) ? $b->$orderby : '';
|
|
|
|
if ($a_val === $b_val) {
|
|
return 0;
|
|
}
|
|
|
|
// Compare as numbers if numeric
|
|
if (is_numeric($a_val) && is_numeric($b_val)) {
|
|
$cmp = ($a_val < $b_val) ? -1 : 1;
|
|
} else {
|
|
$cmp = strcmp($a_val, $b_val);
|
|
}
|
|
|
|
return $order_desc ? -$cmp : $cmp;
|
|
});
|
|
|
|
// Apply offset and limit after merge
|
|
$results = array_slice($results, (int) $args['offset'], (int) $args['limit']);
|
|
}
|
|
}
|
|
|
|
// Include media if requested
|
|
if ($args['include_media'] && $results) {
|
|
foreach ($results as &$property) {
|
|
$property->media = $this->get_property_media($property->listing_key);
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* Get manual properties matching criteria
|
|
*
|
|
* @param array $args Query arguments (same as get_properties)
|
|
* @return array Manual property objects
|
|
*/
|
|
private function get_manual_properties($args) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->manual_properties_table();
|
|
|
|
// Check if table exists
|
|
$table_exists = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
|
|
DB_NAME,
|
|
$table
|
|
));
|
|
|
|
if (!$table_exists) {
|
|
return array();
|
|
}
|
|
|
|
$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();
|
|
|
|
// Store requested status for post-normalization filtering
|
|
$requested_status = $args['status'];
|
|
|
|
if ($args['property_type']) {
|
|
$this->build_property_type_clause($args['property_type'], $where, $values);
|
|
}
|
|
|
|
if ($args['city']) {
|
|
$where[] = 'city = %s';
|
|
$values[] = $args['city'];
|
|
}
|
|
|
|
if ($args['county']) {
|
|
$where[] = 'county = %s';
|
|
$values[] = $args['county'];
|
|
}
|
|
|
|
if ($args['postal_code']) {
|
|
$where[] = 'postal_code = %s';
|
|
$values[] = $args['postal_code'];
|
|
}
|
|
|
|
if ($args['min_price']) {
|
|
$where[] = 'list_price >= %d';
|
|
$values[] = (int) $args['min_price'];
|
|
}
|
|
|
|
if ($args['max_price']) {
|
|
$where[] = 'list_price <= %d';
|
|
$values[] = (int) $args['max_price'];
|
|
}
|
|
|
|
if ($args['min_beds']) {
|
|
$where[] = 'bedrooms_total >= %d';
|
|
$values[] = (int) $args['min_beds'];
|
|
}
|
|
|
|
if ($args['max_beds']) {
|
|
$where[] = 'bedrooms_total <= %d';
|
|
$values[] = (int) $args['max_beds'];
|
|
}
|
|
|
|
if ($args['min_baths']) {
|
|
$where[] = 'bathrooms_total >= %d';
|
|
$values[] = (int) $args['min_baths'];
|
|
}
|
|
|
|
if ($args['listing_key']) {
|
|
$where[] = 'listing_key = %s';
|
|
$values[] = $args['listing_key'];
|
|
}
|
|
|
|
if ($args['listing_id']) {
|
|
$where[] = 'listing_id = %s';
|
|
$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)';
|
|
$values[] = $search_term;
|
|
$values[] = $search_term;
|
|
$values[] = $search_term;
|
|
$values[] = $search_term;
|
|
}
|
|
|
|
// Map bounds filtering
|
|
if ($args['bounds'] && is_array($args['bounds']) && count($args['bounds']) === 4) {
|
|
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
|
|
$where[] = 'latitude BETWEEN %f AND %f';
|
|
$where[] = 'longitude BETWEEN %f AND %f';
|
|
$where[] = 'latitude IS NOT NULL';
|
|
$where[] = 'longitude IS NOT NULL';
|
|
$values[] = (float) $sw_lat;
|
|
$values[] = (float) $ne_lat;
|
|
$values[] = (float) $sw_lng;
|
|
$values[] = (float) $ne_lng;
|
|
}
|
|
|
|
// Radius search for manual properties
|
|
if ($args['center_lat'] && $args['center_lng']) {
|
|
$lat = (float) $args['center_lat'];
|
|
$lng = (float) $args['center_lng'];
|
|
$radius = (int) $args['radius'];
|
|
|
|
// Use simple bounding box for manual properties (no spatial index)
|
|
$lat_delta = $radius / 69.0;
|
|
$lng_delta = $radius / (69.0 * cos(deg2rad($lat)));
|
|
|
|
$where[] = 'latitude BETWEEN %f AND %f';
|
|
$where[] = 'longitude BETWEEN %f AND %f';
|
|
$values[] = $lat - $lat_delta;
|
|
$values[] = $lat + $lat_delta;
|
|
$values[] = $lng - $lng_delta;
|
|
$values[] = $lng + $lng_delta;
|
|
}
|
|
|
|
$sql .= ' WHERE ' . implode(' AND ', $where);
|
|
|
|
// Execute
|
|
if (!empty($values)) {
|
|
$results = $wpdb->get_results($wpdb->prepare($sql, $values));
|
|
} else {
|
|
$results = $wpdb->get_results($sql);
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Get a single property
|
|
*
|
|
* Checks manual properties first (for overrides), then MLS.
|
|
*
|
|
* @param string $identifier Listing key or listing ID
|
|
* @param bool $skip_manual_override Skip manual property override (for A/B testing)
|
|
* @return object|null Property object
|
|
*/
|
|
public function get_property($identifier, $skip_manual_override = false) {
|
|
global $wpdb;
|
|
|
|
// Check manual properties first (they can override MLS)
|
|
if (!$skip_manual_override) {
|
|
$manual = $this->get_manual_property($identifier);
|
|
if ($manual) {
|
|
return $manual;
|
|
}
|
|
}
|
|
|
|
$table = $this->db->properties_table();
|
|
|
|
// Try listing_key first
|
|
$property = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT * FROM {$table} WHERE listing_key = %s AND mlg_can_view = 1",
|
|
$identifier
|
|
));
|
|
|
|
// Try listing_id if not found
|
|
if (!$property) {
|
|
$property = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT * FROM {$table} WHERE listing_id = %s AND mlg_can_view = 1",
|
|
$identifier
|
|
));
|
|
}
|
|
|
|
// If found by listing_id, check if there's a manual override
|
|
if (!$skip_manual_override && $property && $property->listing_id) {
|
|
$override_ids = $this->get_manual_override_listing_ids();
|
|
if (in_array($property->listing_id, $override_ids)) {
|
|
// Manual override exists, return the manual version
|
|
return $this->get_manual_property($property->listing_id);
|
|
}
|
|
}
|
|
|
|
// 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
|
|
*
|
|
* @param string $identifier Listing key (MANUAL-xxx) or listing_id
|
|
* @return object|null Property object normalized to match MLS schema
|
|
*/
|
|
private function get_manual_property($identifier) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->manual_properties_table();
|
|
|
|
// Check if table exists
|
|
$table_exists = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
|
|
DB_NAME,
|
|
$table
|
|
));
|
|
|
|
if (!$table_exists) {
|
|
return null;
|
|
}
|
|
|
|
// Try by listing_key first (MANUAL-xxx format)
|
|
$property = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT * FROM {$table} WHERE listing_key = %s AND standard_status != 'Withdrawn'",
|
|
$identifier
|
|
));
|
|
|
|
if (!$property) {
|
|
// Try by listing_id (MLS ID for overrides)
|
|
$property = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT * FROM {$table} WHERE listing_id = %s AND standard_status != 'Withdrawn'",
|
|
$identifier
|
|
));
|
|
}
|
|
|
|
if ($property) {
|
|
// Normalize to match MLS schema
|
|
$property = $this->normalize_manual_property($property);
|
|
}
|
|
|
|
return $property;
|
|
}
|
|
|
|
/**
|
|
* Normalize a manual property to match MLS schema
|
|
*
|
|
* @param object $property Manual property row
|
|
* @return object Normalized property
|
|
*/
|
|
private function normalize_manual_property($property) {
|
|
global $wpdb;
|
|
|
|
// Set defaults for MLS-specific fields
|
|
$property->originating_system = 'manual';
|
|
$property->mls_status = null;
|
|
$property->mlg_can_view = 1;
|
|
$property->original_list_price = $property->original_list_price ?? $property->list_price;
|
|
|
|
// For manual properties linked to MLS, use the MLS status and days_on_market
|
|
// If not in MLS (no listing_id or MLS listing not found), default to Closed
|
|
$property->days_on_market = null; // Default to null (won't display)
|
|
|
|
if (!empty($property->listing_id)) {
|
|
$mls_table = $this->db->properties_table();
|
|
$mls_data = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT standard_status, days_on_market FROM {$mls_table} WHERE listing_id = %s AND mlg_can_view = 1",
|
|
$property->listing_id
|
|
));
|
|
|
|
if ($mls_data) {
|
|
$property->standard_status = $mls_data->standard_status;
|
|
$property->mls_status = $mls_data->standard_status;
|
|
$property->days_on_market = $mls_data->days_on_market;
|
|
} else {
|
|
// MLS listing no longer exists - assume Closed
|
|
$property->standard_status = 'Closed';
|
|
}
|
|
} else {
|
|
// No MLS link - pure manual property, assume Closed
|
|
$property->standard_status = 'Closed';
|
|
}
|
|
|
|
// Get agent info from linked Agent CPT
|
|
if (!empty($property->list_agent_post_id)) {
|
|
$agent_name = get_the_title($property->list_agent_post_id);
|
|
$agent_mls_id = get_field('mls_id', $property->list_agent_post_id);
|
|
$property->list_agent_name = $agent_name;
|
|
$property->list_agent_mls_id = $agent_mls_id;
|
|
$property->list_agent_key = null;
|
|
} else {
|
|
$property->list_agent_name = null;
|
|
$property->list_agent_mls_id = null;
|
|
$property->list_agent_key = null;
|
|
}
|
|
|
|
// Office info (manual listings are HomeProz)
|
|
$property->list_office_key = null;
|
|
$property->list_office_mls_id = $property->is_homeproz ? (defined('MLS_HOMEPROZ_OFFICE_ID') ? MLS_HOMEPROZ_OFFICE_ID : null) : null;
|
|
$property->list_office_name = $property->is_homeproz ? 'HomeProz Real Estate' : null;
|
|
|
|
// Date fields
|
|
$property->modification_timestamp = $property->updated_at;
|
|
$property->photos_change_timestamp = $property->updated_at;
|
|
$property->listing_contract_date = $property->list_date;
|
|
// days_on_market is set above from MLS data (or null if not in MLS)
|
|
$property->media_expires_at = null;
|
|
$property->coordinates_invalid = 0;
|
|
|
|
// Location column for spatial queries (null for manual)
|
|
$property->location = null;
|
|
|
|
// Raw data
|
|
$property->raw_data = null;
|
|
|
|
// Mark as manual for frontend display logic
|
|
$property->is_manual = 1;
|
|
|
|
return $property;
|
|
}
|
|
|
|
/**
|
|
* Get listing IDs that have manual overrides
|
|
*
|
|
* @return array Array of MLS listing_id values
|
|
*/
|
|
private function get_manual_override_listing_ids() {
|
|
global $wpdb;
|
|
|
|
// Cache result for this request
|
|
static $override_ids = null;
|
|
if ($override_ids !== null) {
|
|
return $override_ids;
|
|
}
|
|
|
|
$table = $this->db->manual_properties_table();
|
|
|
|
// Check if table exists
|
|
$table_exists = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
|
|
DB_NAME,
|
|
$table
|
|
));
|
|
|
|
if (!$table_exists) {
|
|
$override_ids = array();
|
|
return $override_ids;
|
|
}
|
|
|
|
$override_ids = $wpdb->get_col(
|
|
"SELECT listing_id FROM {$table}
|
|
WHERE listing_id IS NOT NULL
|
|
AND listing_id != ''
|
|
AND standard_status != 'Withdrawn'"
|
|
);
|
|
|
|
return array_filter($override_ids);
|
|
}
|
|
|
|
/**
|
|
* Get media for a property
|
|
*
|
|
* Handles both MLS and manual properties.
|
|
*
|
|
* @param string $listing_key Listing key
|
|
* @return array Media objects
|
|
*/
|
|
public function get_property_media($listing_key) {
|
|
global $wpdb;
|
|
|
|
// Check if this is a manual property
|
|
if (strpos($listing_key, 'MANUAL-') === 0) {
|
|
return MLS_Manual_Property::get_manual_property_images($listing_key);
|
|
}
|
|
|
|
return $wpdb->get_results($wpdb->prepare(
|
|
"SELECT * FROM {$this->db->media_table()}
|
|
WHERE listing_key = %s
|
|
ORDER BY media_order ASC",
|
|
$listing_key
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Get primary image URL
|
|
*
|
|
* @param string $listing_key Listing key
|
|
* @return string|null Image URL
|
|
*/
|
|
public function get_primary_image($listing_key) {
|
|
global $wpdb;
|
|
|
|
return $wpdb->get_var($wpdb->prepare(
|
|
"SELECT local_url FROM {$this->db->media_table()}
|
|
WHERE listing_key = %s AND local_url IS NOT NULL
|
|
ORDER BY media_order ASC
|
|
LIMIT 1",
|
|
$listing_key
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Get distinct cities
|
|
*
|
|
* @param string|null $status Optional status filter
|
|
* @return array City names
|
|
*/
|
|
public function get_distinct_cities($status = null) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
$state_filter = $this->get_state_filter();
|
|
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
|
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
|
|
|
|
if ($status) {
|
|
$cities = $wpdb->get_col($wpdb->prepare(
|
|
"SELECT DISTINCT city FROM {$table}
|
|
WHERE mlg_can_view = 1 AND standard_status = %s AND city IS NOT NULL{$state_clause}{$tbd_clause}
|
|
ORDER BY city ASC",
|
|
$status
|
|
));
|
|
} else {
|
|
$cities = $wpdb->get_col(
|
|
"SELECT DISTINCT city FROM {$table}
|
|
WHERE mlg_can_view = 1 AND city IS NOT NULL{$state_clause}{$tbd_clause}
|
|
ORDER BY city ASC"
|
|
);
|
|
}
|
|
|
|
return $cities;
|
|
}
|
|
|
|
/**
|
|
* Get distinct counties
|
|
*
|
|
* @param string|null $status Optional status filter
|
|
* @return array County names
|
|
*/
|
|
public function get_distinct_counties($status = null) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
$state_filter = $this->get_state_filter();
|
|
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
|
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
|
|
|
|
if ($status) {
|
|
$counties = $wpdb->get_col($wpdb->prepare(
|
|
"SELECT DISTINCT county FROM {$table}
|
|
WHERE mlg_can_view = 1 AND standard_status = %s AND county IS NOT NULL{$state_clause}{$tbd_clause}
|
|
ORDER BY county ASC",
|
|
$status
|
|
));
|
|
} else {
|
|
$counties = $wpdb->get_col(
|
|
"SELECT DISTINCT county FROM {$table}
|
|
WHERE mlg_can_view = 1 AND county IS NOT NULL{$state_clause}{$tbd_clause}
|
|
ORDER BY county ASC"
|
|
);
|
|
}
|
|
|
|
return $counties;
|
|
}
|
|
|
|
/**
|
|
* Get property count
|
|
*
|
|
* Counts both MLS and manual properties.
|
|
*
|
|
* @param array $args Filter arguments (same as get_properties)
|
|
* @return int Count
|
|
*/
|
|
public function get_count($args = array()) {
|
|
global $wpdb;
|
|
|
|
$include_manual = !isset($args['include_manual']) || $args['include_manual'];
|
|
|
|
// Get override IDs to exclude from MLS count
|
|
$override_ids = $include_manual ? $this->get_manual_override_listing_ids() : array();
|
|
|
|
$table = $this->db->properties_table();
|
|
|
|
// Exclude properties with no price or price < 100 (invalid data)
|
|
$where = array('mlg_can_view = 1', 'list_price >= 100');
|
|
$values = array();
|
|
|
|
// Add state filter (MN and IA only)
|
|
$state_filter = $this->get_state_filter();
|
|
if ($state_filter) {
|
|
$where[] = $state_filter;
|
|
}
|
|
|
|
// Exclude TBD addresses
|
|
$where[] = $this->get_tbd_exclusion_filter();
|
|
|
|
// Exclude MLS properties that have manual overrides
|
|
if (!empty($override_ids)) {
|
|
$placeholders = implode(',', array_fill(0, count($override_ids), '%s'));
|
|
$where[] = $wpdb->prepare("listing_id NOT IN ({$placeholders})", $override_ids);
|
|
}
|
|
|
|
if (!empty($args['status'])) {
|
|
$where[] = 'standard_status = %s';
|
|
$values[] = $args['status'];
|
|
}
|
|
|
|
if (!empty($args['property_type'])) {
|
|
$this->build_property_type_clause($args['property_type'], $where, $values);
|
|
}
|
|
|
|
// City and postal_code are mutually exclusive - city takes priority
|
|
if (!empty($args['city'])) {
|
|
// Exact city match
|
|
$where[] = 'city = %s';
|
|
$values[] = $args['city'];
|
|
} elseif (!empty($args['postal_code'])) {
|
|
// Exact postal code match
|
|
$where[] = 'postal_code = %s';
|
|
$values[] = $args['postal_code'];
|
|
} elseif (!empty($args['center_lat']) && !empty($args['center_lng'])) {
|
|
// Direct lat/lng radius search (from homepage location search)
|
|
// Use bounding box pre-filter for spatial index, then exact distance
|
|
$radius = !empty($args['radius']) ? (int) $args['radius'] : 30;
|
|
$bbox_filter = $this->get_bounding_box_filter_sql(
|
|
(float) $args['center_lat'],
|
|
(float) $args['center_lng'],
|
|
$radius
|
|
);
|
|
$distance_filter = $this->get_distance_filter_sql(
|
|
(float) $args['center_lat'],
|
|
(float) $args['center_lng'],
|
|
$radius
|
|
);
|
|
$where[] = "({$bbox_filter} AND {$distance_filter})";
|
|
}
|
|
|
|
if (!empty($args['county'])) {
|
|
$where[] = 'county = %s';
|
|
$values[] = $args['county'];
|
|
}
|
|
|
|
if (!empty($args['min_price'])) {
|
|
$where[] = 'list_price >= %d';
|
|
$values[] = (int) $args['min_price'];
|
|
}
|
|
|
|
if (!empty($args['max_price'])) {
|
|
$where[] = 'list_price <= %d';
|
|
$values[] = (int) $args['max_price'];
|
|
}
|
|
|
|
if (!empty($args['min_beds'])) {
|
|
$where[] = 'bedrooms_total >= %d';
|
|
$values[] = (int) $args['min_beds'];
|
|
}
|
|
|
|
if (!empty($args['min_baths'])) {
|
|
$where[] = 'bathrooms_total >= %d';
|
|
$values[] = (int) $args['min_baths'];
|
|
}
|
|
|
|
// Map bounds filtering
|
|
if (!empty($args['bounds']) && is_array($args['bounds']) && count($args['bounds']) === 4) {
|
|
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
|
|
$where[] = 'latitude BETWEEN %f AND %f';
|
|
$where[] = 'longitude BETWEEN %f AND %f';
|
|
$where[] = 'latitude IS NOT NULL';
|
|
$where[] = 'longitude IS NOT NULL';
|
|
$values[] = (float) $sw_lat;
|
|
$values[] = (float) $ne_lat;
|
|
$values[] = (float) $sw_lng;
|
|
$values[] = (float) $ne_lng;
|
|
}
|
|
|
|
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
|
|
|
|
if (!empty($values)) {
|
|
$mls_count = (int) $wpdb->get_var($wpdb->prepare($sql, $values));
|
|
} else {
|
|
$mls_count = (int) $wpdb->get_var($sql);
|
|
}
|
|
|
|
// Add manual property count
|
|
if ($include_manual) {
|
|
$manual_count = $this->get_manual_count($args);
|
|
return $mls_count + $manual_count;
|
|
}
|
|
|
|
return $mls_count;
|
|
}
|
|
|
|
/**
|
|
* Get manual property count
|
|
*
|
|
* @param array $args Filter arguments
|
|
* @return int Count
|
|
*/
|
|
private function get_manual_count($args) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->manual_properties_table();
|
|
|
|
// Check if table exists
|
|
$table_exists = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
|
|
DB_NAME,
|
|
$table
|
|
));
|
|
|
|
if (!$table_exists) {
|
|
return 0;
|
|
}
|
|
|
|
$where = array("standard_status != 'Withdrawn'");
|
|
$values = array();
|
|
|
|
if (!empty($args['status'])) {
|
|
$where[] = 'standard_status = %s';
|
|
$values[] = $args['status'];
|
|
}
|
|
|
|
if (!empty($args['property_type'])) {
|
|
$this->build_property_type_clause($args['property_type'], $where, $values);
|
|
}
|
|
|
|
if (!empty($args['city'])) {
|
|
$where[] = 'city = %s';
|
|
$values[] = $args['city'];
|
|
} elseif (!empty($args['postal_code'])) {
|
|
$where[] = 'postal_code = %s';
|
|
$values[] = $args['postal_code'];
|
|
} elseif (!empty($args['center_lat']) && !empty($args['center_lng'])) {
|
|
$lat = (float) $args['center_lat'];
|
|
$lng = (float) $args['center_lng'];
|
|
$radius = !empty($args['radius']) ? (int) $args['radius'] : 30;
|
|
$lat_delta = $radius / 69.0;
|
|
$lng_delta = $radius / (69.0 * cos(deg2rad($lat)));
|
|
|
|
$where[] = 'latitude BETWEEN %f AND %f';
|
|
$where[] = 'longitude BETWEEN %f AND %f';
|
|
$values[] = $lat - $lat_delta;
|
|
$values[] = $lat + $lat_delta;
|
|
$values[] = $lng - $lng_delta;
|
|
$values[] = $lng + $lng_delta;
|
|
}
|
|
|
|
if (!empty($args['county'])) {
|
|
$where[] = 'county = %s';
|
|
$values[] = $args['county'];
|
|
}
|
|
|
|
if (!empty($args['min_price'])) {
|
|
$where[] = 'list_price >= %d';
|
|
$values[] = (int) $args['min_price'];
|
|
}
|
|
|
|
if (!empty($args['max_price'])) {
|
|
$where[] = 'list_price <= %d';
|
|
$values[] = (int) $args['max_price'];
|
|
}
|
|
|
|
if (!empty($args['min_beds'])) {
|
|
$where[] = 'bedrooms_total >= %d';
|
|
$values[] = (int) $args['min_beds'];
|
|
}
|
|
|
|
if (!empty($args['min_baths'])) {
|
|
$where[] = 'bathrooms_total >= %d';
|
|
$values[] = (int) $args['min_baths'];
|
|
}
|
|
|
|
if (!empty($args['bounds']) && is_array($args['bounds']) && count($args['bounds']) === 4) {
|
|
list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds'];
|
|
$where[] = 'latitude BETWEEN %f AND %f';
|
|
$where[] = 'longitude BETWEEN %f AND %f';
|
|
$where[] = 'latitude IS NOT NULL';
|
|
$where[] = 'longitude IS NOT NULL';
|
|
$values[] = (float) $sw_lat;
|
|
$values[] = (float) $ne_lat;
|
|
$values[] = (float) $sw_lng;
|
|
$values[] = (float) $ne_lng;
|
|
}
|
|
|
|
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
|
|
|
|
if (!empty($values)) {
|
|
return (int) $wpdb->get_var($wpdb->prepare($sql, $values));
|
|
}
|
|
|
|
return (int) $wpdb->get_var($sql);
|
|
}
|
|
|
|
/**
|
|
* Check if data exists
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function has_data() {
|
|
global $wpdb;
|
|
|
|
$state_filter = $this->get_state_filter();
|
|
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
|
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
|
|
|
|
$count = $wpdb->get_var(
|
|
"SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE mlg_can_view = 1{$state_clause}{$tbd_clause}"
|
|
);
|
|
|
|
return (int) $count > 0;
|
|
}
|
|
|
|
/**
|
|
* Get property types with counts
|
|
*
|
|
* @param string|null $status Optional status filter
|
|
* @return array Property types with counts
|
|
*/
|
|
public function get_property_types($status = null) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
$state_filter = $this->get_state_filter();
|
|
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
|
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
|
|
|
|
if ($status) {
|
|
return $wpdb->get_results($wpdb->prepare(
|
|
"SELECT property_type, COUNT(*) as count
|
|
FROM {$table}
|
|
WHERE mlg_can_view = 1 AND standard_status = %s AND property_type IS NOT NULL{$state_clause}{$tbd_clause}
|
|
GROUP BY property_type
|
|
ORDER BY count DESC",
|
|
$status
|
|
));
|
|
}
|
|
|
|
return $wpdb->get_results(
|
|
"SELECT property_type, COUNT(*) as count
|
|
FROM {$table}
|
|
WHERE mlg_can_view = 1 AND property_type IS NOT NULL{$state_clause}{$tbd_clause}
|
|
GROUP BY property_type
|
|
ORDER BY count DESC"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get price range
|
|
*
|
|
* @param string|null $status Optional status filter
|
|
* @return object Min and max prices
|
|
*/
|
|
public function get_price_range($status = null) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
$state_filter = $this->get_state_filter();
|
|
$state_clause = $state_filter ? " AND {$state_filter}" : '';
|
|
$tbd_clause = " AND " . $this->get_tbd_exclusion_filter();
|
|
|
|
if ($status) {
|
|
return $wpdb->get_row($wpdb->prepare(
|
|
"SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
|
|
FROM {$table}
|
|
WHERE mlg_can_view = 1 AND standard_status = %s AND list_price > 0{$state_clause}{$tbd_clause}",
|
|
$status
|
|
));
|
|
}
|
|
|
|
return $wpdb->get_row(
|
|
"SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
|
|
FROM {$table}
|
|
WHERE mlg_can_view = 1 AND list_price > 0{$state_clause}{$tbd_clause}"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get formatted address for a property
|
|
*
|
|
* @param object $property Property object
|
|
* @return string Formatted address
|
|
*/
|
|
public function format_address($property) {
|
|
$parts = array();
|
|
|
|
if ($property->street_number) {
|
|
$parts[] = $property->street_number;
|
|
}
|
|
|
|
if ($property->street_name) {
|
|
$parts[] = $property->street_name;
|
|
}
|
|
|
|
if ($property->street_suffix) {
|
|
$parts[] = $property->street_suffix;
|
|
}
|
|
|
|
if ($property->unit_number) {
|
|
$parts[] = '#' . $property->unit_number;
|
|
}
|
|
|
|
$street = implode(' ', $parts);
|
|
|
|
$location_parts = array();
|
|
if ($property->city) {
|
|
$location_parts[] = $property->city;
|
|
}
|
|
if ($property->state_or_province) {
|
|
$location_parts[] = $property->state_or_province;
|
|
}
|
|
if ($property->postal_code) {
|
|
$location_parts[] = $property->postal_code;
|
|
}
|
|
|
|
$location = implode(', ', $location_parts);
|
|
|
|
if ($street && $location) {
|
|
return $street . ', ' . $location;
|
|
}
|
|
|
|
return $street ?: $location;
|
|
}
|
|
|
|
/**
|
|
* Get geographic bounds for filtered properties
|
|
* Returns min/max lat/lng for all properties matching the filters
|
|
*
|
|
* @param array $args Filter arguments (same as get_properties, but bounds is ignored)
|
|
* @return array|null Bounds array with sw_lat, sw_lng, ne_lat, ne_lng or null if no results
|
|
*/
|
|
public function get_filter_bounds($args = array()) {
|
|
global $wpdb;
|
|
|
|
$table = $this->db->properties_table();
|
|
|
|
// Exclude properties with invalid coordinates or invalid price from map bounds
|
|
$where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL', 'coordinates_invalid = 0', 'list_price >= 100');
|
|
$values = array();
|
|
|
|
// Add state filter (MN and IA only)
|
|
$state_filter = $this->get_state_filter();
|
|
if ($state_filter) {
|
|
$where[] = $state_filter;
|
|
}
|
|
|
|
// Exclude TBD addresses
|
|
$where[] = $this->get_tbd_exclusion_filter();
|
|
|
|
if (!empty($args['status'])) {
|
|
$where[] = 'standard_status = %s';
|
|
$values[] = $args['status'];
|
|
}
|
|
|
|
if (!empty($args['property_type'])) {
|
|
$this->build_property_type_clause($args['property_type'], $where, $values);
|
|
}
|
|
|
|
// City and postal_code are mutually exclusive - city takes priority
|
|
if (!empty($args['city'])) {
|
|
// Exact city match
|
|
$where[] = 'city = %s';
|
|
$values[] = $args['city'];
|
|
} elseif (!empty($args['postal_code'])) {
|
|
// Exact postal code match
|
|
$where[] = 'postal_code = %s';
|
|
$values[] = $args['postal_code'];
|
|
}
|
|
|
|
if (!empty($args['min_price'])) {
|
|
$where[] = 'list_price >= %d';
|
|
$values[] = (int) $args['min_price'];
|
|
}
|
|
|
|
if (!empty($args['max_price'])) {
|
|
$where[] = 'list_price <= %d';
|
|
$values[] = (int) $args['max_price'];
|
|
}
|
|
|
|
if (!empty($args['min_beds'])) {
|
|
$where[] = 'bedrooms_total >= %d';
|
|
$values[] = (int) $args['min_beds'];
|
|
}
|
|
|
|
$sql = "SELECT
|
|
MIN(latitude) as sw_lat,
|
|
MIN(longitude) as sw_lng,
|
|
MAX(latitude) as ne_lat,
|
|
MAX(longitude) as ne_lng
|
|
FROM {$table}
|
|
WHERE " . implode(' AND ', $where);
|
|
|
|
if (!empty($values)) {
|
|
$result = $wpdb->get_row($wpdb->prepare($sql, $values));
|
|
} else {
|
|
$result = $wpdb->get_row($sql);
|
|
}
|
|
|
|
if (!$result || $result->sw_lat === null) {
|
|
return null;
|
|
}
|
|
|
|
return array(
|
|
'sw_lat' => (float) $result->sw_lat,
|
|
'sw_lng' => (float) $result->sw_lng,
|
|
'ne_lat' => (float) $result->ne_lat,
|
|
'ne_lng' => (float) $result->ne_lng,
|
|
);
|
|
}
|
|
}
|