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>
884 lines
32 KiB
PHP
Executable File
884 lines
32 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* Database handler class
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
class MLS_DB {
|
|
|
|
/**
|
|
* Schema version for index migrations
|
|
* Increment this when adding new indexes or columns
|
|
*/
|
|
const SCHEMA_VERSION = 6;
|
|
|
|
/**
|
|
* Get table name with prefix
|
|
*
|
|
* @param string $table Table name constant
|
|
* @return string Full table name
|
|
*/
|
|
public function get_table_name($table) {
|
|
global $wpdb;
|
|
return $wpdb->prefix . $table;
|
|
}
|
|
|
|
/**
|
|
* Get properties table name
|
|
*/
|
|
public function properties_table() {
|
|
return $this->get_table_name(MLS_TABLE_PROPERTIES);
|
|
}
|
|
|
|
/**
|
|
* Get media table name
|
|
*/
|
|
public function media_table() {
|
|
return $this->get_table_name(MLS_TABLE_MEDIA);
|
|
}
|
|
|
|
/**
|
|
* Get sync state table name
|
|
*/
|
|
public function sync_state_table() {
|
|
return $this->get_table_name(MLS_TABLE_SYNC_STATE);
|
|
}
|
|
|
|
/**
|
|
* Get rate limits table name
|
|
*/
|
|
public function rate_limits_table() {
|
|
return $this->get_table_name(MLS_TABLE_RATE_LIMITS);
|
|
}
|
|
|
|
/**
|
|
* Get sync log table name
|
|
*/
|
|
public function sync_log_table() {
|
|
return $this->get_table_name(MLS_TABLE_SYNC_LOG);
|
|
}
|
|
|
|
/**
|
|
* Get media log table name
|
|
*/
|
|
public function media_log_table() {
|
|
return $this->get_table_name(MLS_TABLE_MEDIA_LOG);
|
|
}
|
|
|
|
/**
|
|
* Get geo cities table name
|
|
*/
|
|
public function geo_cities_table() {
|
|
return $this->get_table_name(MLS_TABLE_GEO_CITIES);
|
|
}
|
|
|
|
/**
|
|
* Get geo zipcodes table name
|
|
*/
|
|
public function geo_zipcodes_table() {
|
|
return $this->get_table_name(MLS_TABLE_GEO_ZIPCODES);
|
|
}
|
|
|
|
/**
|
|
* Get manual properties table name
|
|
*/
|
|
public function manual_properties_table() {
|
|
return $this->get_table_name(MLS_TABLE_MANUAL_PROPERTIES);
|
|
}
|
|
|
|
/**
|
|
* Create all database tables
|
|
*/
|
|
public static function create_tables() {
|
|
global $wpdb;
|
|
|
|
$charset_collate = $wpdb->get_charset_collate();
|
|
|
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
|
|
|
// Properties table
|
|
$table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
|
|
$sql_properties = "CREATE TABLE {$table_properties} (
|
|
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
listing_key VARCHAR(50) NOT NULL,
|
|
listing_id VARCHAR(50) NOT NULL,
|
|
originating_system VARCHAR(50) DEFAULT 'northstar',
|
|
|
|
standard_status VARCHAR(30) NOT NULL,
|
|
mls_status VARCHAR(50) DEFAULT NULL,
|
|
mlg_can_view TINYINT(1) DEFAULT 1,
|
|
|
|
list_price DECIMAL(15,2) DEFAULT NULL,
|
|
original_list_price DECIMAL(15,2) DEFAULT NULL,
|
|
close_price DECIMAL(15,2) DEFAULT NULL,
|
|
|
|
street_number VARCHAR(20) DEFAULT NULL,
|
|
street_name VARCHAR(100) DEFAULT NULL,
|
|
street_suffix VARCHAR(30) DEFAULT NULL,
|
|
unit_number VARCHAR(20) DEFAULT NULL,
|
|
city VARCHAR(100) NOT NULL,
|
|
state_or_province VARCHAR(50) DEFAULT 'MN',
|
|
postal_code VARCHAR(20) DEFAULT NULL,
|
|
county VARCHAR(100) DEFAULT NULL,
|
|
latitude DECIMAL(10,8) DEFAULT NULL,
|
|
longitude DECIMAL(11,8) DEFAULT NULL,
|
|
|
|
property_type VARCHAR(50) DEFAULT NULL,
|
|
property_sub_type VARCHAR(50) DEFAULT NULL,
|
|
bedrooms_total INT(3) DEFAULT NULL,
|
|
bathrooms_total DECIMAL(4,1) DEFAULT NULL,
|
|
bathrooms_full INT(3) DEFAULT NULL,
|
|
bathrooms_half INT(3) DEFAULT NULL,
|
|
living_area INT(10) DEFAULT NULL,
|
|
lot_size_area DECIMAL(12,4) DEFAULT NULL,
|
|
lot_size_units VARCHAR(20) DEFAULT NULL,
|
|
year_built INT(4) DEFAULT NULL,
|
|
garage_spaces INT(3) DEFAULT NULL,
|
|
|
|
public_remarks TEXT DEFAULT NULL,
|
|
directions TEXT DEFAULT NULL,
|
|
|
|
list_agent_key VARCHAR(50) DEFAULT NULL,
|
|
list_agent_mls_id VARCHAR(50) DEFAULT NULL,
|
|
list_agent_name VARCHAR(150) DEFAULT NULL,
|
|
list_office_key VARCHAR(50) DEFAULT NULL,
|
|
list_office_mls_id VARCHAR(50) DEFAULT NULL,
|
|
list_office_name VARCHAR(150) DEFAULT NULL,
|
|
is_homeproz TINYINT(1) NOT NULL DEFAULT 0,
|
|
|
|
photos_count INT(5) DEFAULT 0,
|
|
media_expires_at DATETIME DEFAULT NULL,
|
|
modification_timestamp DATETIME NOT NULL,
|
|
photos_change_timestamp DATETIME DEFAULT NULL,
|
|
listing_contract_date DATE DEFAULT NULL,
|
|
close_date DATE DEFAULT NULL,
|
|
days_on_market INT(5) DEFAULT NULL,
|
|
|
|
raw_data LONGTEXT DEFAULT NULL,
|
|
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY listing_key (listing_key),
|
|
KEY listing_id (listing_id),
|
|
KEY standard_status (standard_status),
|
|
KEY city (city),
|
|
KEY property_type (property_type),
|
|
KEY modification_timestamp (modification_timestamp),
|
|
KEY list_price (list_price),
|
|
KEY mlg_can_view (mlg_can_view),
|
|
KEY bedrooms_total (bedrooms_total),
|
|
KEY county (county)
|
|
) {$charset_collate};";
|
|
|
|
dbDelta($sql_properties);
|
|
|
|
// Media table
|
|
$table_media = $wpdb->prefix . MLS_TABLE_MEDIA;
|
|
$sql_media = "CREATE TABLE {$table_media} (
|
|
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
listing_key VARCHAR(50) NOT NULL,
|
|
media_key VARCHAR(100) NOT NULL,
|
|
|
|
media_type VARCHAR(30) DEFAULT 'Photo',
|
|
media_order INT(5) DEFAULT 0,
|
|
media_url VARCHAR(1000) DEFAULT NULL,
|
|
|
|
local_path VARCHAR(500) DEFAULT NULL,
|
|
local_url VARCHAR(500) DEFAULT NULL,
|
|
file_size INT(11) DEFAULT NULL,
|
|
mime_type VARCHAR(50) DEFAULT NULL,
|
|
image_width INT(5) DEFAULT NULL,
|
|
image_height INT(5) DEFAULT NULL,
|
|
|
|
media_modification_timestamp DATETIME DEFAULT NULL,
|
|
downloaded_at DATETIME DEFAULT NULL,
|
|
download_attempts INT(3) DEFAULT 0,
|
|
download_error TEXT DEFAULT NULL,
|
|
retry_after DATETIME DEFAULT NULL,
|
|
queued_at DATETIME DEFAULT NULL,
|
|
download_status VARCHAR(20) DEFAULT 'pending',
|
|
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY listing_media (listing_key, media_key),
|
|
KEY listing_key (listing_key),
|
|
KEY media_order (media_order),
|
|
KEY download_status (download_status),
|
|
KEY retry_after (retry_after),
|
|
KEY queued_at (queued_at)
|
|
) {$charset_collate};";
|
|
|
|
dbDelta($sql_media);
|
|
|
|
// Sync state table
|
|
$table_sync_state = $wpdb->prefix . MLS_TABLE_SYNC_STATE;
|
|
$sql_sync_state = "CREATE TABLE {$table_sync_state} (
|
|
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
sync_type VARCHAR(30) NOT NULL,
|
|
entity_type VARCHAR(30) NOT NULL DEFAULT 'Property',
|
|
|
|
status VARCHAR(20) DEFAULT 'pending',
|
|
started_at DATETIME DEFAULT NULL,
|
|
completed_at DATETIME DEFAULT NULL,
|
|
|
|
last_modification_timestamp DATETIME DEFAULT NULL,
|
|
last_next_link VARCHAR(2000) DEFAULT NULL,
|
|
records_processed INT(11) DEFAULT 0,
|
|
records_created INT(11) DEFAULT 0,
|
|
records_updated INT(11) DEFAULT 0,
|
|
records_deleted INT(11) DEFAULT 0,
|
|
|
|
error_count INT(11) DEFAULT 0,
|
|
last_error TEXT DEFAULT NULL,
|
|
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
|
|
PRIMARY KEY (id),
|
|
KEY sync_type_entity (sync_type, entity_type),
|
|
KEY status (status)
|
|
) {$charset_collate};";
|
|
|
|
dbDelta($sql_sync_state);
|
|
|
|
// Rate limits table
|
|
$table_rate_limits = $wpdb->prefix . MLS_TABLE_RATE_LIMITS;
|
|
$sql_rate_limits = "CREATE TABLE {$table_rate_limits} (
|
|
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
window_type VARCHAR(20) NOT NULL,
|
|
window_start DATETIME NOT NULL,
|
|
request_count INT(11) DEFAULT 0,
|
|
bytes_transferred BIGINT(20) DEFAULT 0,
|
|
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY window_type_start (window_type, window_start),
|
|
KEY window_start (window_start)
|
|
) {$charset_collate};";
|
|
|
|
dbDelta($sql_rate_limits);
|
|
|
|
// Sync log table
|
|
$table_sync_log = $wpdb->prefix . MLS_TABLE_SYNC_LOG;
|
|
$sql_sync_log = "CREATE TABLE {$table_sync_log} (
|
|
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
sync_state_id BIGINT(20) UNSIGNED DEFAULT NULL,
|
|
level VARCHAR(20) DEFAULT 'info',
|
|
message TEXT NOT NULL,
|
|
context LONGTEXT DEFAULT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
PRIMARY KEY (id),
|
|
KEY sync_state_id (sync_state_id),
|
|
KEY level (level),
|
|
KEY created_at (created_at)
|
|
) {$charset_collate};";
|
|
|
|
dbDelta($sql_sync_log);
|
|
|
|
// Media download log table
|
|
$table_media_log = $wpdb->prefix . MLS_TABLE_MEDIA_LOG;
|
|
$sql_media_log = "CREATE TABLE {$table_media_log} (
|
|
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
media_id BIGINT(20) UNSIGNED NOT NULL,
|
|
listing_key VARCHAR(50) NOT NULL,
|
|
media_key VARCHAR(100) NOT NULL,
|
|
action VARCHAR(30) NOT NULL,
|
|
status_code INT(5) DEFAULT NULL,
|
|
response_time_ms INT(11) DEFAULT NULL,
|
|
error_message TEXT DEFAULT NULL,
|
|
url VARCHAR(1000) DEFAULT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
PRIMARY KEY (id),
|
|
KEY media_id (media_id),
|
|
KEY listing_key (listing_key),
|
|
KEY action (action),
|
|
KEY created_at (created_at)
|
|
) {$charset_collate};";
|
|
|
|
dbDelta($sql_media_log);
|
|
|
|
// Geo cities table
|
|
$table_geo_cities = $wpdb->prefix . MLS_TABLE_GEO_CITIES;
|
|
$sql_geo_cities = "CREATE TABLE {$table_geo_cities} (
|
|
id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
state_code VARCHAR(2) NOT NULL,
|
|
state_name VARCHAR(50) NOT NULL,
|
|
city VARCHAR(100) NOT NULL,
|
|
county VARCHAR(100) DEFAULT NULL,
|
|
latitude DECIMAL(10,6) NOT NULL,
|
|
longitude DECIMAL(10,6) NOT NULL,
|
|
|
|
PRIMARY KEY (id),
|
|
KEY state_code (state_code),
|
|
KEY city (city),
|
|
KEY state_city (state_code, city),
|
|
KEY lat_lng (latitude, longitude)
|
|
) {$charset_collate};";
|
|
|
|
dbDelta($sql_geo_cities);
|
|
|
|
// Geo zipcodes table
|
|
$table_geo_zipcodes = $wpdb->prefix . MLS_TABLE_GEO_ZIPCODES;
|
|
$sql_geo_zipcodes = "CREATE TABLE {$table_geo_zipcodes} (
|
|
id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
zipcode VARCHAR(10) NOT NULL,
|
|
latitude DECIMAL(10,6) NOT NULL,
|
|
longitude DECIMAL(10,6) NOT NULL,
|
|
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY zipcode (zipcode),
|
|
KEY lat_lng (latitude, longitude)
|
|
) {$charset_collate};";
|
|
|
|
dbDelta($sql_geo_zipcodes);
|
|
|
|
// Manual properties table (for manually entered listings)
|
|
$table_manual = $wpdb->prefix . MLS_TABLE_MANUAL_PROPERTIES;
|
|
$sql_manual = "CREATE TABLE {$table_manual} (
|
|
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
wp_post_id BIGINT(20) UNSIGNED NOT NULL,
|
|
listing_key VARCHAR(50) NOT NULL,
|
|
listing_id VARCHAR(50) DEFAULT NULL,
|
|
|
|
standard_status VARCHAR(30) NOT NULL DEFAULT 'Active',
|
|
list_price DECIMAL(15,2) DEFAULT NULL,
|
|
original_list_price DECIMAL(15,2) DEFAULT NULL,
|
|
close_price DECIMAL(15,2) DEFAULT NULL,
|
|
|
|
street_number VARCHAR(20) DEFAULT NULL,
|
|
street_name VARCHAR(100) DEFAULT NULL,
|
|
street_suffix VARCHAR(30) DEFAULT NULL,
|
|
unit_number VARCHAR(20) DEFAULT NULL,
|
|
full_address VARCHAR(255) DEFAULT NULL,
|
|
city VARCHAR(100) DEFAULT NULL,
|
|
state_or_province VARCHAR(50) DEFAULT 'MN',
|
|
postal_code VARCHAR(20) DEFAULT NULL,
|
|
county VARCHAR(100) DEFAULT NULL,
|
|
latitude DECIMAL(10,8) DEFAULT NULL,
|
|
longitude DECIMAL(11,8) DEFAULT NULL,
|
|
|
|
property_type VARCHAR(50) DEFAULT NULL,
|
|
property_sub_type VARCHAR(50) DEFAULT NULL,
|
|
bedrooms_total INT(3) DEFAULT NULL,
|
|
bathrooms_total DECIMAL(4,1) DEFAULT NULL,
|
|
bathrooms_full INT(3) DEFAULT NULL,
|
|
bathrooms_half INT(3) DEFAULT NULL,
|
|
living_area INT(10) DEFAULT NULL,
|
|
lot_size_area DECIMAL(12,4) DEFAULT NULL,
|
|
lot_size_units VARCHAR(20) DEFAULT 'Acres',
|
|
year_built INT(4) DEFAULT NULL,
|
|
stories INT(3) DEFAULT NULL,
|
|
garage_spaces INT(3) DEFAULT NULL,
|
|
architectural_style VARCHAR(100) DEFAULT NULL,
|
|
|
|
public_remarks TEXT DEFAULT NULL,
|
|
private_remarks TEXT DEFAULT NULL,
|
|
directions TEXT DEFAULT NULL,
|
|
|
|
list_agent_post_id BIGINT(20) UNSIGNED DEFAULT NULL,
|
|
co_list_agent_post_id BIGINT(20) UNSIGNED DEFAULT NULL,
|
|
is_homeproz TINYINT(1) NOT NULL DEFAULT 0,
|
|
is_featured TINYINT(1) NOT NULL DEFAULT 0,
|
|
|
|
virtual_tour_url VARCHAR(500) DEFAULT NULL,
|
|
association_fee DECIMAL(10,2) DEFAULT NULL,
|
|
|
|
list_date DATE DEFAULT NULL,
|
|
contract_date DATE DEFAULT NULL,
|
|
close_date DATE DEFAULT NULL,
|
|
expiration_date DATE DEFAULT NULL,
|
|
|
|
photos_count INT(5) DEFAULT 0,
|
|
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY listing_key (listing_key),
|
|
UNIQUE KEY wp_post_id (wp_post_id),
|
|
KEY listing_id (listing_id),
|
|
KEY standard_status (standard_status),
|
|
KEY city (city),
|
|
KEY property_type (property_type),
|
|
KEY list_price (list_price),
|
|
KEY is_homeproz (is_homeproz),
|
|
KEY is_featured (is_featured),
|
|
KEY latitude (latitude),
|
|
KEY longitude (longitude)
|
|
) {$charset_collate};";
|
|
|
|
dbDelta($sql_manual);
|
|
|
|
// Run index migrations
|
|
self::run_index_migrations();
|
|
}
|
|
|
|
/**
|
|
* Run index migrations that dbDelta cannot handle
|
|
*
|
|
* dbDelta can create tables and add columns, but cannot add indexes
|
|
* to existing tables. This method handles incremental index additions.
|
|
*/
|
|
public static function run_index_migrations() {
|
|
global $wpdb;
|
|
|
|
$current_schema = (int) get_option('mls_schema_version', 1);
|
|
|
|
// Migration to schema version 2: Add search and geo indexes
|
|
if ($current_schema < 2) {
|
|
$table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
|
|
$table_media = $wpdb->prefix . MLS_TABLE_MEDIA;
|
|
|
|
// Check and add indexes only if they don't exist
|
|
$existing_indexes = self::get_existing_indexes($table_properties);
|
|
|
|
// Geospatial indexes for bounding box queries
|
|
if (!isset($existing_indexes['idx_latitude'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_latitude (latitude)");
|
|
}
|
|
if (!isset($existing_indexes['idx_longitude'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_longitude (longitude)");
|
|
}
|
|
|
|
// Composite index for common search pattern (status + city + price)
|
|
if (!isset($existing_indexes['idx_status_city_price'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_status_city_price (standard_status, city, list_price)");
|
|
}
|
|
|
|
// Composite index for status + property_type searches
|
|
if (!isset($existing_indexes['idx_status_type'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_status_type (standard_status, property_type)");
|
|
}
|
|
|
|
// Media table: composite index for listing + order (eliminates filesort)
|
|
$media_indexes = self::get_existing_indexes($table_media);
|
|
if (!isset($media_indexes['idx_listing_order'])) {
|
|
$wpdb->query("ALTER TABLE {$table_media} ADD INDEX idx_listing_order (listing_key, media_order)");
|
|
}
|
|
|
|
update_option('mls_schema_version', 2);
|
|
}
|
|
|
|
// Migration to schema version 3: Add is_homeproz column and index
|
|
if ($current_schema < 3) {
|
|
$table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
|
|
|
|
// Check if column exists
|
|
$column_exists = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'is_homeproz'",
|
|
DB_NAME,
|
|
$table_properties
|
|
));
|
|
|
|
if (!$column_exists) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD COLUMN is_homeproz TINYINT(1) NOT NULL DEFAULT 0 AFTER list_office_name");
|
|
}
|
|
|
|
// Add index if not exists
|
|
$existing_indexes = self::get_existing_indexes($table_properties);
|
|
if (!isset($existing_indexes['idx_is_homeproz'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_is_homeproz (is_homeproz)");
|
|
}
|
|
|
|
// Update existing HomeProz listings
|
|
if (defined('MLS_HOMEPROZ_OFFICE_ID')) {
|
|
$wpdb->query($wpdb->prepare(
|
|
"UPDATE {$table_properties} SET is_homeproz = 1 WHERE list_office_mls_id = %s",
|
|
MLS_HOMEPROZ_OFFICE_ID
|
|
));
|
|
}
|
|
|
|
update_option('mls_schema_version', 3);
|
|
}
|
|
|
|
// Migration to schema version 4: Add spatial POINT column and index
|
|
if ($current_schema < 4) {
|
|
$table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
|
|
|
|
// Check if location column exists
|
|
$column_exists = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'location'",
|
|
DB_NAME,
|
|
$table_properties
|
|
));
|
|
|
|
if (!$column_exists) {
|
|
// Add POINT column (nullable initially for population)
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD COLUMN location POINT SRID 4326 DEFAULT NULL AFTER longitude");
|
|
|
|
// Populate location from existing lat/lng
|
|
// Note: SRID 4326 uses axis order (latitude, longitude) in MySQL 8.0+
|
|
$wpdb->query("UPDATE {$table_properties} SET location = ST_PointFromText(CONCAT('POINT(', latitude, ' ', longitude, ')'), 4326) WHERE latitude IS NOT NULL AND longitude IS NOT NULL");
|
|
|
|
// Make column NOT NULL (required for spatial index)
|
|
$wpdb->query("ALTER TABLE {$table_properties} MODIFY location POINT NOT NULL SRID 4326");
|
|
|
|
// Add spatial index
|
|
$existing_indexes = self::get_existing_indexes($table_properties);
|
|
if (!isset($existing_indexes['idx_location_spatial'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD SPATIAL INDEX idx_location_spatial (location)");
|
|
}
|
|
}
|
|
|
|
update_option('mls_schema_version', 4);
|
|
}
|
|
|
|
// Migration to schema version 5: Add coordinates_invalid column for geo validation
|
|
if ($current_schema < 5) {
|
|
$table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
|
|
|
|
// Check if column exists
|
|
$column_exists = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'coordinates_invalid'",
|
|
DB_NAME,
|
|
$table_properties
|
|
));
|
|
|
|
if (!$column_exists) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD COLUMN coordinates_invalid TINYINT(1) NOT NULL DEFAULT 0 AFTER longitude");
|
|
}
|
|
|
|
// Add index if not exists
|
|
$existing_indexes = self::get_existing_indexes($table_properties);
|
|
if (!isset($existing_indexes['idx_coordinates_invalid'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_coordinates_invalid (coordinates_invalid)");
|
|
}
|
|
|
|
update_option('mls_schema_version', 5);
|
|
}
|
|
|
|
// Migration to schema version 6: Add media_expires_at column for proactive URL refresh
|
|
if ($current_schema < 6) {
|
|
$table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
|
|
|
|
// Check if column exists
|
|
$column_exists = $wpdb->get_var($wpdb->prepare(
|
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'media_expires_at'",
|
|
DB_NAME,
|
|
$table_properties
|
|
));
|
|
|
|
if (!$column_exists) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD COLUMN media_expires_at DATETIME DEFAULT NULL AFTER photos_count");
|
|
}
|
|
|
|
// Add index for finding properties with expiring media
|
|
$existing_indexes = self::get_existing_indexes($table_properties);
|
|
if (!isset($existing_indexes['idx_media_expires_at'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_media_expires_at (media_expires_at)");
|
|
}
|
|
|
|
update_option('mls_schema_version', 6);
|
|
}
|
|
|
|
// Migration to schema version 7: Add indexes for search query optimization
|
|
if ($current_schema < 7) {
|
|
$table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
|
|
$existing_indexes = self::get_existing_indexes($table_properties);
|
|
|
|
// Index for postal code searches
|
|
if (!isset($existing_indexes['idx_postal_code'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_postal_code (postal_code)");
|
|
}
|
|
|
|
// Index for bathroom filter
|
|
if (!isset($existing_indexes['idx_bathrooms_total'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_bathrooms_total (bathrooms_total)");
|
|
}
|
|
|
|
// Index for living area (sqft) filter
|
|
if (!isset($existing_indexes['idx_living_area'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_living_area (living_area)");
|
|
}
|
|
|
|
// Index for state filter
|
|
if (!isset($existing_indexes['idx_state'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_state (state_or_province)");
|
|
}
|
|
|
|
// Composite index for media refresh sync query
|
|
if (!isset($existing_indexes['idx_status_media_expires'])) {
|
|
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_status_media_expires (standard_status, media_expires_at)");
|
|
}
|
|
|
|
update_option('mls_schema_version', 7);
|
|
}
|
|
|
|
// Future migrations go here:
|
|
// if ($current_schema < 8) { ... }
|
|
}
|
|
|
|
/**
|
|
* Get existing indexes for a table
|
|
*
|
|
* @param string $table Full table name
|
|
* @return array Index names as keys
|
|
*/
|
|
private static function get_existing_indexes($table) {
|
|
global $wpdb;
|
|
|
|
$indexes = array();
|
|
$results = $wpdb->get_results("SHOW INDEX FROM {$table}");
|
|
|
|
foreach ($results as $row) {
|
|
$indexes[$row->Key_name] = true;
|
|
}
|
|
|
|
return $indexes;
|
|
}
|
|
|
|
/**
|
|
* Drop all tables (for uninstall)
|
|
*/
|
|
public static function drop_tables() {
|
|
global $wpdb;
|
|
|
|
$tables = array(
|
|
MLS_TABLE_PROPERTIES,
|
|
MLS_TABLE_MEDIA,
|
|
MLS_TABLE_SYNC_STATE,
|
|
MLS_TABLE_RATE_LIMITS,
|
|
MLS_TABLE_SYNC_LOG,
|
|
MLS_TABLE_MEDIA_LOG,
|
|
MLS_TABLE_GEO_CITIES,
|
|
MLS_TABLE_GEO_ZIPCODES,
|
|
MLS_TABLE_MANUAL_PROPERTIES,
|
|
);
|
|
|
|
foreach ($tables as $table) {
|
|
$table_name = $wpdb->prefix . $table;
|
|
$wpdb->query("DROP TABLE IF EXISTS {$table_name}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Truncate all data tables (keep structure)
|
|
*/
|
|
public function truncate_data() {
|
|
global $wpdb;
|
|
|
|
$wpdb->query("TRUNCATE TABLE {$this->properties_table()}");
|
|
$wpdb->query("TRUNCATE TABLE {$this->media_table()}");
|
|
$wpdb->query("TRUNCATE TABLE {$this->sync_state_table()}");
|
|
$wpdb->query("TRUNCATE TABLE {$this->sync_log_table()}");
|
|
}
|
|
|
|
/**
|
|
* Get database statistics
|
|
*/
|
|
public function get_stats() {
|
|
global $wpdb;
|
|
|
|
$stats = array(
|
|
'total_properties' => 0,
|
|
'active_properties' => 0,
|
|
'pending_properties' => 0,
|
|
'sold_properties' => 0,
|
|
'total_media' => 0,
|
|
'downloaded_media' => 0,
|
|
);
|
|
|
|
// Property counts
|
|
$stats['total_properties'] = (int) $wpdb->get_var(
|
|
"SELECT COUNT(*) FROM {$this->properties_table()} WHERE mlg_can_view = 1"
|
|
);
|
|
|
|
$status_counts = $wpdb->get_results(
|
|
"SELECT standard_status, COUNT(*) as count
|
|
FROM {$this->properties_table()}
|
|
WHERE mlg_can_view = 1
|
|
GROUP BY standard_status",
|
|
OBJECT_K
|
|
);
|
|
|
|
if (isset($status_counts['Active'])) {
|
|
$stats['active_properties'] = (int) $status_counts['Active']->count;
|
|
}
|
|
if (isset($status_counts['Pending'])) {
|
|
$stats['pending_properties'] = (int) $status_counts['Pending']->count;
|
|
}
|
|
if (isset($status_counts['Closed']) || isset($status_counts['Sold'])) {
|
|
$stats['sold_properties'] = (int) ($status_counts['Closed']->count ?? 0)
|
|
+ (int) ($status_counts['Sold']->count ?? 0);
|
|
}
|
|
|
|
// Media counts
|
|
$stats['total_media'] = (int) $wpdb->get_var(
|
|
"SELECT COUNT(*) FROM {$this->media_table()}"
|
|
);
|
|
|
|
$stats['downloaded_media'] = (int) $wpdb->get_var(
|
|
"SELECT COUNT(*) FROM {$this->media_table()} WHERE local_path IS NOT NULL"
|
|
);
|
|
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* Import geo data from CSV files
|
|
* Only imports if tables are empty
|
|
*
|
|
* @return array Import results with counts
|
|
*/
|
|
public static function import_geo_data() {
|
|
global $wpdb;
|
|
|
|
$results = array(
|
|
'cities_imported' => 0,
|
|
'zipcodes_imported' => 0,
|
|
'cities_skipped' => false,
|
|
'zipcodes_skipped' => false,
|
|
);
|
|
|
|
$cities_table = $wpdb->prefix . MLS_TABLE_GEO_CITIES;
|
|
$zipcodes_table = $wpdb->prefix . MLS_TABLE_GEO_ZIPCODES;
|
|
|
|
// Check if tables already have data
|
|
$cities_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$cities_table}");
|
|
$zipcodes_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$zipcodes_table}");
|
|
|
|
// Import cities if table is empty
|
|
if ($cities_count === 0) {
|
|
$cities_file = MLS_PLUGIN_DIR . 'data/us_cities.csv';
|
|
if (file_exists($cities_file)) {
|
|
$results['cities_imported'] = self::import_cities_csv($cities_file, $cities_table);
|
|
}
|
|
} else {
|
|
$results['cities_skipped'] = true;
|
|
}
|
|
|
|
// Import zipcodes if table is empty
|
|
if ($zipcodes_count === 0) {
|
|
$zipcodes_file = MLS_PLUGIN_DIR . 'data/us_zipcodes.csv';
|
|
if (file_exists($zipcodes_file)) {
|
|
$results['zipcodes_imported'] = self::import_zipcodes_csv($zipcodes_file, $zipcodes_table);
|
|
}
|
|
} else {
|
|
$results['zipcodes_skipped'] = true;
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* Import cities CSV file
|
|
* CSV format: ID,STATE_CODE,STATE_NAME,CITY,COUNTY,LATITUDE,LONGITUDE
|
|
*
|
|
* @param string $file Path to CSV file
|
|
* @param string $table Table name
|
|
* @return int Number of rows imported
|
|
*/
|
|
private static function import_cities_csv($file, $table) {
|
|
global $wpdb;
|
|
|
|
$handle = fopen($file, 'r');
|
|
if (!$handle) {
|
|
return 0;
|
|
}
|
|
|
|
// Skip header row
|
|
fgetcsv($handle);
|
|
|
|
$count = 0;
|
|
$batch = array();
|
|
$batch_size = 500;
|
|
|
|
while (($row = fgetcsv($handle)) !== false) {
|
|
if (count($row) < 7) continue;
|
|
|
|
// ID,STATE_CODE,STATE_NAME,CITY,COUNTY,LATITUDE,LONGITUDE
|
|
$batch[] = $wpdb->prepare(
|
|
"(%s, %s, %s, %s, %f, %f)",
|
|
$row[1], // state_code
|
|
$row[2], // state_name
|
|
$row[3], // city
|
|
$row[4], // county
|
|
(float) $row[5], // latitude
|
|
(float) $row[6] // longitude
|
|
);
|
|
|
|
if (count($batch) >= $batch_size) {
|
|
$values = implode(',', $batch);
|
|
$wpdb->query("INSERT INTO {$table} (state_code, state_name, city, county, latitude, longitude) VALUES {$values}");
|
|
$count += count($batch);
|
|
$batch = array();
|
|
}
|
|
}
|
|
|
|
// Insert remaining rows
|
|
if (!empty($batch)) {
|
|
$values = implode(',', $batch);
|
|
$wpdb->query("INSERT INTO {$table} (state_code, state_name, city, county, latitude, longitude) VALUES {$values}");
|
|
$count += count($batch);
|
|
}
|
|
|
|
fclose($handle);
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Import zipcodes CSV file
|
|
* CSV format: ZIP,LAT,LNG
|
|
*
|
|
* @param string $file Path to CSV file
|
|
* @param string $table Table name
|
|
* @return int Number of rows imported
|
|
*/
|
|
private static function import_zipcodes_csv($file, $table) {
|
|
global $wpdb;
|
|
|
|
$handle = fopen($file, 'r');
|
|
if (!$handle) {
|
|
return 0;
|
|
}
|
|
|
|
// Skip header row
|
|
fgetcsv($handle);
|
|
|
|
$count = 0;
|
|
$batch = array();
|
|
$batch_size = 500;
|
|
|
|
while (($row = fgetcsv($handle)) !== false) {
|
|
if (count($row) < 3) continue;
|
|
|
|
// ZIP,LAT,LNG
|
|
$batch[] = $wpdb->prepare(
|
|
"(%s, %f, %f)",
|
|
trim($row[0]), // zipcode
|
|
(float) trim($row[1]), // latitude
|
|
(float) trim($row[2]) // longitude
|
|
);
|
|
|
|
if (count($batch) >= $batch_size) {
|
|
$values = implode(',', $batch);
|
|
$wpdb->query("INSERT INTO {$table} (zipcode, latitude, longitude) VALUES {$values}");
|
|
$count += count($batch);
|
|
$batch = array();
|
|
}
|
|
}
|
|
|
|
// Insert remaining rows
|
|
if (!empty($batch)) {
|
|
$values = implode(',', $batch);
|
|
$wpdb->query("INSERT INTO {$table} (zipcode, latitude, longitude) VALUES {$values}");
|
|
$count += count($batch);
|
|
}
|
|
|
|
fclose($handle);
|
|
return $count;
|
|
}
|
|
}
|