| 1 |
<?php |
| 2 |
/** |
| 3 |
* ThemeImport — SaaS API client and Stored Listing Write compatibility edge. |
| 4 |
* |
| 5 |
* The class retains the SaaS request helpers, batch compatibility entry points, |
| 6 |
* and reconciliation utilities used by older callers. Per-listing Stored mode |
| 7 |
* persistence is deliberately narrow: mlsimportSaasPrepareToImportPerItem() |
| 8 |
* translates legacy task option names and delegates once to the injected |
| 9 |
* Mlsimport_Stored_Listing_Write module. |
| 10 |
* |
| 11 |
* Listing status, post/meta/taxonomy writes, field normalization, title, media, |
| 12 |
* activity, and error outcomes no longer live in this compatibility class. |
| 13 |
* |
| 14 |
* @package MLSImport |
| 15 |
*/ |
| 16 |
if ( ! defined( 'ABSPATH' ) ) { |
| 17 |
exit; // Exit if accessed directly |
| 18 |
} |
| 19 |
|
| 20 |
/** |
| 21 |
* Expose legacy API/batch methods around the explicit listing-write module. |
| 22 |
*/ |
| 23 |
class ThemeImport { |
| 24 |
|
| 25 |
|
| 26 |
// Active theme adapter / identifier (set by callers). |
| 27 |
public $theme; |
| 28 |
// Plugin slug/name carried for logging and context. |
| 29 |
public $plugin_name; |
| 30 |
// Environment adapter instance (theme-specific meta mapping). |
| 31 |
public $enviroment; |
| 32 |
// Cached encoded credential/config values. |
| 33 |
public $encoded_values; |
| 34 |
|
| 35 |
/** @var object|null Injected Stored Listing Write module. */ |
| 36 |
private $stored_listing_write; |
| 37 |
|
| 38 |
/** |
| 39 |
* Configure the API client and optional Stored mode write boundary. |
| 40 |
* |
| 41 |
* Most ThemeImport instances only call the SaaS API and therefore need no |
| 42 |
* writer. Admin composition injects the writer once; listing calls then |
| 43 |
* delegate without reading the global admin object or theme adapter. |
| 44 |
* |
| 45 |
* @param string $plugin_name Plugin slug used by legacy callers. |
| 46 |
* @param object|null $stored_listing_write Single listing write module. |
| 47 |
*/ |
| 48 |
public function __construct( $plugin_name = '', $stored_listing_write = null ) { |
| 49 |
$this->plugin_name = (string) $plugin_name; |
| 50 |
$this->stored_listing_write = $stored_listing_write; |
| 51 |
} |
| 52 |
|
| 53 |
|
| 54 |
/** |
| 55 |
* Api Request to MLSimport API using CURL |
| 56 |
* |
| 57 |
* @param string $method The API method to call. |
| 58 |
* @param array $values_array The values to pass to the API. |
| 59 |
* @param string $type The request type (default is 'GET'). |
| 60 |
* @return mixed The API response or error message. |
| 61 |
*/ |
| 62 |
|
| 63 |
public function globalApiRequestCurlSaas($method, $valuesArray, $type = 'GET') { |
| 64 |
|
| 65 |
|
| 66 |
global $mlsimport; |
| 67 |
|
| 68 |
// Skip validation for token requests |
| 69 |
// (the token call is what mints the credential, so it can't require one). |
| 70 |
if ($method !== 'token') { |
| 71 |
// Ensure a live JWT before any non-token call; bail out with a message on failure. |
| 72 |
if (!self::validateAndRefreshToken()) { |
| 73 |
return 'Token validation failed'; |
| 74 |
} |
| 75 |
} |
| 76 |
|
| 77 |
// Build the full endpoint URL from the SaaS base + method path. |
| 78 |
$url = MLSIMPORT_API_URL . $method; |
| 79 |
// Default headers for the token request (plain text body). |
| 80 |
$headers = ['Content-Type' => 'text/plain']; |
| 81 |
|
| 82 |
// For authenticated calls, swap to JSON + Bearer token headers. |
| 83 |
if ($method !== 'token') { |
| 84 |
$token = self::getApiToken(); |
| 85 |
$headers = [ |
| 86 |
'Content-Type' => 'application/json', |
| 87 |
'Authorization' => 'Bearer '.$token, |
| 88 |
]; |
| 89 |
} |
| 90 |
|
| 91 |
// Assemble the wp_remote_* argument array (long timeout for large payloads). |
| 92 |
$args = [ |
| 93 |
'method' => $type, |
| 94 |
'headers' => $headers, |
| 95 |
'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null, |
| 96 |
'timeout' => 120, |
| 97 |
'redirection' => 10, |
| 98 |
'httpversion' => '1.1', |
| 99 |
'blocking' => true, |
| 100 |
'user-agent' => $_SERVER['HTTP_USER_AGENT'], |
| 101 |
]; |
| 102 |
|
| 103 |
|
| 104 |
// Dispatch as GET or POST depending on $type. |
| 105 |
$response = $type === 'GET' ? wp_remote_get($url, $args) : wp_remote_post($url, $args); |
| 106 |
|
| 107 |
// #208 recovery: same rule as globalApiRequestSaas() — one refresh and |
| 108 |
// one retry when the server rejects the Bearer token mid-flight. |
| 109 |
if ( 'token' !== $method |
| 110 |
&& ! is_wp_error( $response ) |
| 111 |
&& 401 === intval( $response['response']['code'] ?? 0 ) |
| 112 |
&& self::refreshToken() ) { |
| 113 |
$args['headers']['Authorization'] = 'Bearer ' . self::getApiToken(); |
| 114 |
$response = $type === 'GET' ? wp_remote_get( $url, $args ) : wp_remote_post( $url, $args ); |
| 115 |
} |
| 116 |
|
| 117 |
// Transport-level failure: return the WP_Error message string. |
| 118 |
if (is_wp_error($response)) { |
| 119 |
return $response->get_error_message(); |
| 120 |
} else { |
| 121 |
// Otherwise decode the JSON body and return the array (or a decode-error string). |
| 122 |
$body = wp_remote_retrieve_body($response); |
| 123 |
|
| 124 |
$toReturn = json_decode($body, true); |
| 125 |
if (json_last_error() !== JSON_ERROR_NONE) { |
| 126 |
return 'JSON decode error: ' . json_last_error_msg(); |
| 127 |
} |
| 128 |
return $toReturn; |
| 129 |
} |
| 130 |
} |
| 131 |
|
| 132 |
|
| 133 |
/** |
| 134 |
* Retrieve the API token |
| 135 |
* |
| 136 |
* @return string The API token. |
| 137 |
*/ |
| 138 |
private static function getApiToken() { |
| 139 |
global $mlsimport; |
| 140 |
return $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient(); |
| 141 |
} |
| 142 |
|
| 143 |
|
| 144 |
/** |
| 145 |
* Api Request to MLSimport API |
| 146 |
* |
| 147 |
* @param string $method The API method to call. |
| 148 |
* @param array $valuesArray The values to pass to the API. |
| 149 |
* @param string $type The request type (default is 'GET'). |
| 150 |
* @return array The API response data. |
| 151 |
*/ |
| 152 |
|
| 153 |
/** |
| 154 |
* Fire-and-forget POST to the SaaS API. Refreshes the JWT token (blocking — a |
| 155 |
* required separate request); returns false without sending if the token is |
| 156 |
* unavailable. Otherwise issues wp_remote_post() with blocking=false, timeout=0.01 |
| 157 |
* and returns true. The response is never inspected. |
| 158 |
* |
| 159 |
* @param string $method The API method/path to call. |
| 160 |
* @param array $valuesArray The request body data. |
| 161 |
* @return bool True if dispatched, false if token unavailable. |
| 162 |
*/ |
| 163 |
public static function globalApiRequestSaasFireAndForget( string $method, array $valuesArray ): bool { |
| 164 |
if ( ! self::validateAndRefreshToken() ) { |
| 165 |
return false; |
| 166 |
} |
| 167 |
|
| 168 |
$token = self::getApiToken(); |
| 169 |
|
| 170 |
wp_remote_post( |
| 171 |
MLSIMPORT_API_URL . $method, |
| 172 |
[ |
| 173 |
'method' => 'POST', |
| 174 |
'timeout' => 0.01, |
| 175 |
'blocking' => false, |
| 176 |
'headers' => [ |
| 177 |
'Authorization' => 'Bearer ' . $token, |
| 178 |
'Content-Type' => 'application/json', |
| 179 |
], |
| 180 |
'body' => wp_json_encode( $valuesArray ), |
| 181 |
] |
| 182 |
); |
| 183 |
|
| 184 |
return true; |
| 185 |
} |
| 186 |
|
| 187 |
|
| 188 |
/** |
| 189 |
* Blocking request to the SaaS API returning the decoded response. |
| 190 |
* |
| 191 |
* Validates/refreshes the JWT for anything other than the public 'token' |
| 192 |
* and 'mls' methods, always POSTs the JSON body (regardless of $type), |
| 193 |
* and normalises errors into a ['success' => false, ...] array. On HTTP 200 |
| 194 |
* the raw decoded body is returned as-is. |
| 195 |
* |
| 196 |
* @param string $method The API method/path to call. |
| 197 |
* @param array $valuesArray The request body data. |
| 198 |
* @param string $type The nominal request type (default 'GET'). |
| 199 |
* @return mixed Decoded response array, or an error descriptor array. |
| 200 |
*/ |
| 201 |
public static function globalApiRequestSaas($method, $valuesArray, $type = 'GET') { |
| 202 |
global $mlsimport; |
| 203 |
// Skip validation for token and mls requests |
| 204 |
if ($method !== 'token' && $method !== 'mls') { |
| 205 |
// Guarantee a valid token; otherwise return a failure descriptor. |
| 206 |
if (!self::validateAndRefreshToken()) { |
| 207 |
return [ |
| 208 |
'success' => false, |
| 209 |
'error_message' => 'Token validation failed' |
| 210 |
]; |
| 211 |
} |
| 212 |
} |
| 213 |
|
| 214 |
|
| 215 |
// Full endpoint URL. |
| 216 |
$url = MLSIMPORT_API_URL . $method; |
| 217 |
|
| 218 |
// Attach Bearer auth headers for authenticated methods only. |
| 219 |
$headers = []; |
| 220 |
if ($method !== 'token' && $method !== 'mls') { |
| 221 |
$token = self::getApiToken(); |
| 222 |
$headers = [ |
| 223 |
'Authorization' => 'Bearer '.$token, |
| 224 |
'Content-Type' => 'application/json', |
| 225 |
]; |
| 226 |
} |
| 227 |
|
| 228 |
|
| 229 |
// Request arguments (note: always dispatched via wp_remote_post below). |
| 230 |
$args = [ |
| 231 |
'method' => $type, |
| 232 |
'timeout' => 45, |
| 233 |
'redirection' => 5, |
| 234 |
'httpversion' => '1.0', |
| 235 |
'blocking' => true, |
| 236 |
'headers' => $headers, |
| 237 |
'cookies' => [], |
| 238 |
'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null, |
| 239 |
]; |
| 240 |
// Always POST (even for logical GETs) — the SaaS expects a JSON body. |
| 241 |
$response = wp_remote_post($url, $args); |
| 242 |
|
| 243 |
// #208 recovery: a 401 on an authenticated call means the server |
| 244 |
// rejected the Bearer token even though the stored expiry looked |
| 245 |
// valid (revoked server-side, clock skew). Refresh once and retry |
| 246 |
// the same request once; a second 401 falls through to the normal |
| 247 |
// error path below. Token/mls calls carry no Bearer, so no retry. |
| 248 |
if ( 'token' !== $method && 'mls' !== $method |
| 249 |
&& ! is_wp_error( $response ) |
| 250 |
&& 401 === intval( $response['response']['code'] ?? 0 ) |
| 251 |
&& self::refreshToken() ) { |
| 252 |
$args['headers']['Authorization'] = 'Bearer ' . self::getApiToken(); |
| 253 |
$response = wp_remote_post( $url, $args ); |
| 254 |
} |
| 255 |
|
| 256 |
// Transport error → structured failure with WP error code/message. |
| 257 |
if (is_wp_error($response)) { |
| 258 |
return [ |
| 259 |
'success' => false, |
| 260 |
'error_code' => $response->get_error_code(), |
| 261 |
'error_message' => esc_html($response->get_error_message()) |
| 262 |
]; |
| 263 |
} |
| 264 |
|
| 265 |
// Extract HTTP status code and raw body. |
| 266 |
$status_code = isset($response['response']['code']) ? intval($response['response']['code']) : 0; |
| 267 |
$body = wp_remote_retrieve_body($response); |
| 268 |
|
| 269 |
// 200 → return the decoded payload untouched. |
| 270 |
if (200 === $status_code) { |
| 271 |
$receivedData = json_decode($body, true); |
| 272 |
return $receivedData; |
| 273 |
} |
| 274 |
|
| 275 |
// Non-200: try to pull a human-readable error out of the JSON body. |
| 276 |
$error_message = 'Unknown error'; |
| 277 |
$error_code = $status_code; |
| 278 |
|
| 279 |
$decoded_body = json_decode($body, true); |
| 280 |
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded_body)) { |
| 281 |
// Preferred shape: { error: { message, code } }. |
| 282 |
if (isset($decoded_body['error']['message'])) { |
| 283 |
$error_message = $decoded_body['error']['message']; |
| 284 |
if (isset($decoded_body['error']['code'])) { |
| 285 |
$error_code = $decoded_body['error']['code']; |
| 286 |
} |
| 287 |
// Fallback shape: { message }. |
| 288 |
} elseif (isset($decoded_body['message'])) { |
| 289 |
$error_message = $decoded_body['message']; |
| 290 |
} |
| 291 |
} |
| 292 |
|
| 293 |
// Return the normalised error descriptor (the exit() below is unreachable). |
| 294 |
return [ |
| 295 |
'success' => false, |
| 296 |
'error_code' => $error_code, |
| 297 |
'error_message' => esc_html($error_message), |
| 298 |
]; |
| 299 |
|
| 300 |
exit(); |
| 301 |
} |
| 302 |
|
| 303 |
|
| 304 |
|
| 305 |
/** |
| 306 |
* Check if token is expired and refresh if needed |
| 307 |
* Call this before any external API request |
| 308 |
* |
| 309 |
* @return bool True if token is valid, false if refresh failed |
| 310 |
*/ |
| 311 |
private static function validateAndRefreshToken() { |
| 312 |
global $mlsimport; |
| 313 |
|
| 314 |
// Get stored expiry timestamp |
| 315 |
$token_expiry = get_option('mlsimport_token_expiry', 0); |
| 316 |
$current_time = time(); |
| 317 |
|
| 318 |
// Check if token is expired (now at/after the stored expiry). |
| 319 |
if ($current_time >= $token_expiry) { |
| 320 |
// Token expired, refresh it |
| 321 |
$refresh_result = self::refreshToken(); |
| 322 |
|
| 323 |
// Propagate refresh failure to the caller. |
| 324 |
if (!$refresh_result) { |
| 325 |
return false; |
| 326 |
} |
| 327 |
} |
| 328 |
|
| 329 |
// Token is present and not past expiry. |
| 330 |
return true; |
| 331 |
} |
| 332 |
|
| 333 |
/** |
| 334 |
* Record the SaaS connection-health state (#208). |
| 335 |
* |
| 336 |
* Stores array{status, since} in the mlsimport_connection_health option: |
| 337 |
* 'healthy', 'credentials_invalid' (server rejected the stored account), |
| 338 |
* 'no_subscription' (password accepted, account not active — #322) or |
| 339 |
* 'credentials_missing' (nothing configured). Transient failures such |
| 340 |
* as network timeouts never call this, so a working state is not lost to |
| 341 |
* a hiccup. Re-recording an unchanged status is skipped so 'since' keeps |
| 342 |
* pointing at when the state actually began. |
| 343 |
* |
| 344 |
* @param string $status New health status keyword. |
| 345 |
* @return void |
| 346 |
*/ |
| 347 |
private static function setConnectionHealth( $status ) { |
| 348 |
$health = get_option( 'mlsimport_connection_health', array() ); |
| 349 |
if ( is_array( $health ) && ( $health['status'] ?? '' ) === $status ) { |
| 350 |
return; |
| 351 |
} |
| 352 |
update_option( |
| 353 |
'mlsimport_connection_health', |
| 354 |
array( |
| 355 |
'status' => $status, |
| 356 |
'since' => time(), |
| 357 |
) |
| 358 |
); |
| 359 |
|
| 360 |
// #208: a state CHANGE is the incident boundary — broken credentials |
| 361 |
// open the connection incident, a working refresh resolves it. The |
| 362 |
// alerts module dedups, so this cannot spam the SaaS. |
| 363 |
if ( 'healthy' === $status ) { |
| 364 |
if ( function_exists( 'mlsimport_alert_resolve' ) ) { |
| 365 |
mlsimport_alert_resolve( 'connection:credentials' ); |
| 366 |
} |
| 367 |
} elseif ( function_exists( 'mlsimport_alert_open' ) ) { |
| 368 |
mlsimport_alert_open( 'connection:credentials', 'connection_broken', array( 'status' => $status ) ); |
| 369 |
} |
| 370 |
} |
| 371 |
|
| 372 |
/** |
| 373 |
* Request a fresh JWT from the SaaS 'token' endpoint and cache it. |
| 374 |
* |
| 375 |
* Reads the stored username/password, POSTs them, and on success stores the |
| 376 |
* token in a transient plus the expiry timestamp in an option. Bumps the |
| 377 |
* 'token_failures' telemetry counter on every failure path — WITHOUT a |
| 378 |
* connection id (#283): the SaaS JWT is account-level, shared by every |
| 379 |
* connection, so its failures belong to no single MLS and count only in |
| 380 |
* the global bucket. |
| 381 |
* |
| 382 |
* @return bool True on successful refresh, false otherwise. |
| 383 |
*/ |
| 384 |
private static function refreshToken() { |
| 385 |
global $mlsimport; |
| 386 |
|
| 387 |
// Get credentials for token request |
| 388 |
$options = get_option('mlsimport_admin_options'); |
| 389 |
// Pull the SaaS account credentials out of the plugin options. |
| 390 |
$username = isset($options['mlsimport_username']) ? $options['mlsimport_username'] : ''; |
| 391 |
$password = isset($options['mlsimport_password']) ? $options['mlsimport_password'] : ''; |
| 392 |
|
| 393 |
// No credentials configured → cannot refresh. |
| 394 |
if (empty($username) || empty($password)) { |
| 395 |
mlsimport_telemetry_bump( 'token_failures' ); |
| 396 |
self::setConnectionHealth( 'credentials_missing' ); |
| 397 |
return false; |
| 398 |
} |
| 399 |
|
| 400 |
// #208 single-flight: only one process may refresh at a time. |
| 401 |
// add_option() is a plain INSERT, so a concurrent process loses the |
| 402 |
// race and backs off without firing a second token request. A lock |
| 403 |
// older than 60 seconds belongs to a crashed owner (the token request |
| 404 |
// itself times out at 45) and is taken over instead. |
| 405 |
if ( ! add_option( 'mlsimport_token_refresh_lock', time(), '', 'no' ) ) { |
| 406 |
$lock_held_since = intval( get_option( 'mlsimport_token_refresh_lock', 0 ) ); |
| 407 |
if ( time() - $lock_held_since < 60 ) { |
| 408 |
return false; |
| 409 |
} |
| 410 |
update_option( 'mlsimport_token_refresh_lock', time() ); |
| 411 |
} |
| 412 |
|
| 413 |
// Prepare token request |
| 414 |
$url = MLSIMPORT_API_URL . 'token'; |
| 415 |
$body = wp_json_encode(array( |
| 416 |
'username' => $username, |
| 417 |
'password' => $password |
| 418 |
)); |
| 419 |
|
| 420 |
$args = array( |
| 421 |
'method' => 'POST', |
| 422 |
'headers' => array( |
| 423 |
'Content-Type' => 'application/json' |
| 424 |
), |
| 425 |
'body' => $body, |
| 426 |
'timeout' => 45 |
| 427 |
); |
| 428 |
|
| 429 |
// Make token request |
| 430 |
$response = wp_remote_post($url, $args); |
| 431 |
|
| 432 |
// Transport failure → count and abort (lock released for the next try). |
| 433 |
if (is_wp_error($response)) { |
| 434 |
mlsimport_telemetry_bump( 'token_failures' ); |
| 435 |
delete_option( 'mlsimport_token_refresh_lock' ); |
| 436 |
return false; |
| 437 |
} |
| 438 |
|
| 439 |
// Decode the JSON token response. |
| 440 |
$body = wp_remote_retrieve_body($response); |
| 441 |
$data = json_decode($body, true); |
| 442 |
$code = intval( $response['response']['code'] ?? 0 ); |
| 443 |
|
| 444 |
// Reject any response missing success/token/expires. |
| 445 |
if (!isset($data['success']) || !$data['success'] || !isset($data['token']) || !isset($data['expires'])) { |
| 446 |
mlsimport_telemetry_bump( 'token_failures' ); |
| 447 |
delete_option( 'mlsimport_token_refresh_lock' ); |
| 448 |
// The server answered and said no → terminal until the user acts. |
| 449 |
// HTTP 403 means the password was right but the account has no |
| 450 |
// active subscription (#322); anything else is bad credentials. |
| 451 |
// A malformed/partial body is a server hiccup instead and leaves |
| 452 |
// health untouched. |
| 453 |
if ( is_array( $data ) && array_key_exists( 'success', $data ) && ! $data['success'] ) { |
| 454 |
self::setConnectionHealth( 403 === $code ? 'no_subscription' : 'credentials_invalid' ); |
| 455 |
// Same verdict, remembered for the "not connected" screens. |
| 456 |
mlsimport_account_status_record( array( 'success' => false, 'error_code' => $code ) ); |
| 457 |
} |
| 458 |
return false; |
| 459 |
} |
| 460 |
|
| 461 |
// A working login wipes any remembered failure reason (#322). |
| 462 |
mlsimport_account_status_record( $data ); |
| 463 |
|
| 464 |
// Store new token and expiry |
| 465 |
//$mlsimport->admin->mlsimport_saas_store_mls_api_token_transient($data['token']); |
| 466 |
|
| 467 |
// Cache the token in a transient sized to its remaining lifetime. |
| 468 |
$expires_in = $data['expires'] - time(); |
| 469 |
set_transient('mlsimport_saas_token', $data['token'], $expires_in); |
| 470 |
|
| 471 |
// Persist the absolute expiry so validateAndRefreshToken() can compare against it. |
| 472 |
update_option('mlsimport_token_expiry', intval($data['expires'])); |
| 473 |
|
| 474 |
// First successful SaaS account connection (lifecycle telemetry). |
| 475 |
mlsimport_telemetry_set_once( 'account_connected_at', time() ); |
| 476 |
|
| 477 |
// Refresh finished — release the single-flight lock. |
| 478 |
delete_option( 'mlsimport_token_refresh_lock' ); |
| 479 |
|
| 480 |
// A minted token proves the account works → back to healthy. |
| 481 |
self::setConnectionHealth( 'healthy' ); |
| 482 |
|
| 483 |
return true; |
| 484 |
} |
| 485 |
|
| 486 |
|
| 487 |
|
| 488 |
|
| 489 |
|
| 490 |
|
| 491 |
|
| 492 |
|
| 493 |
|
| 494 |
|
| 495 |
|
| 496 |
/** |
| 497 |
* Write logs for import process |
| 498 |
* |
| 499 |
* @param string $logs The log message to write. |
| 500 |
* @param string $type The type of log. |
| 501 |
*/ |
| 502 |
private function writeImportLogs($logs, $type) { |
| 503 |
mlsimport_saas_single_write_import_custom_logs($logs, $type); |
| 504 |
} |
| 505 |
|
| 506 |
|
| 507 |
|
| 508 |
|
| 509 |
|
| 510 |
|
| 511 |
|
| 512 |
|
| 513 |
|
| 514 |
|
| 515 |
|
| 516 |
|
| 517 |
|
| 518 |
|
| 519 |
|
| 520 |
|
| 521 |
|
| 522 |
|
| 523 |
|
| 524 |
|
| 525 |
|
| 526 |
|
| 527 |
|
| 528 |
|
| 529 |
|
| 530 |
|
| 531 |
|
| 532 |
|
| 533 |
|
| 534 |
|
| 535 |
|
| 536 |
|
| 537 |
|
| 538 |
|
| 539 |
|
| 540 |
|
| 541 |
|
| 542 |
|
| 543 |
|
| 544 |
|
| 545 |
|
| 546 |
|
| 547 |
|
| 548 |
|
| 549 |
|
| 550 |
|
| 551 |
|
| 552 |
|
| 553 |
|
| 554 |
|
| 555 |
|
| 556 |
/** |
| 557 |
* Return user option |
| 558 |
* |
| 559 |
* @param int $selected The selected user ID. |
| 560 |
* @return string The HTML option elements for users. |
| 561 |
*/ |
| 562 |
public function mlsimportSaasThemeImportSelectUser($selected) { |
| 563 |
$userOptions = ''; |
| 564 |
// Fetch all users to build a <select> of possible property authors. |
| 565 |
$blogusers = get_users(['blog_id' => 1, 'orderby' => 'nicename']); |
| 566 |
foreach ($blogusers as $user) { |
| 567 |
$userOptions .= '<option value="' . esc_attr($user->ID) . '"'; |
| 568 |
// Pre-select the currently chosen user. |
| 569 |
if ($user->ID == $selected) { |
| 570 |
$userOptions .= ' selected="selected"'; |
| 571 |
} |
| 572 |
$userOptions .= '>' . esc_html($user->user_login) . '</option>'; |
| 573 |
} |
| 574 |
return $userOptions; |
| 575 |
} |
| 576 |
|
| 577 |
|
| 578 |
|
| 579 |
|
| 580 |
|
| 581 |
|
| 582 |
|
| 583 |
/** |
| 584 |
* Return agent option |
| 585 |
* |
| 586 |
* @param int $selected The selected agent ID. |
| 587 |
* @return string The HTML option elements for agents. |
| 588 |
*/ |
| 589 |
public function mlsimportSaasThemeImportSelectAgent($selected) { |
| 590 |
global $mlsimport; |
| 591 |
// Query up to 150 published agents of the theme's agent post type. |
| 592 |
$args = [ |
| 593 |
'post_type' => $mlsimport->admin->env_data->get_agent_post_type(), |
| 594 |
'post_status' => 'publish', |
| 595 |
'posts_per_page' => 150, |
| 596 |
]; |
| 597 |
|
| 598 |
$agentSelection = new WP_Query($args); |
| 599 |
// Start with a blank option (no agent). |
| 600 |
$agentOptions = '<option value=""></option>'; |
| 601 |
|
| 602 |
// Build one <option> per agent post. |
| 603 |
while ($agentSelection->have_posts()) { |
| 604 |
$agentSelection->the_post(); |
| 605 |
$agentId = get_the_ID(); |
| 606 |
|
| 607 |
$agentOptions .= '<option value="' . esc_attr($agentId) . '"'; |
| 608 |
// Pre-select the currently chosen agent. |
| 609 |
if ($agentId == $selected) { |
| 610 |
$agentOptions .= ' selected="selected"'; |
| 611 |
} |
| 612 |
$agentOptions .= '>' . esc_html(get_the_title()) . '</option>'; |
| 613 |
} |
| 614 |
wp_reset_postdata(); |
| 615 |
|
| 616 |
return $agentOptions; |
| 617 |
} |
| 618 |
|
| 619 |
|
| 620 |
|
| 621 |
|
| 622 |
|
| 623 |
|
| 624 |
|
| 625 |
|
| 626 |
|
| 627 |
|
| 628 |
|
| 629 |
|
| 630 |
|
| 631 |
|
| 632 |
|
| 633 |
|
| 634 |
|
| 635 |
|
| 636 |
/** |
| 637 |
* Delete property via SQL |
| 638 |
* |
| 639 |
* @param int $deleteId The ID of the property to delete. |
| 640 |
* @param string $ListingKey The listing key of the property. |
| 641 |
*/ |
| 642 |
public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) { |
| 643 |
global $mlsimport; |
| 644 |
|
| 645 |
// Resolve the post's type and the theme's expected property post type. |
| 646 |
$postType = get_post_type($deleteId); |
| 647 |
$propertyPostType = ''; |
| 648 |
if (isset($mlsimport->admin->env_data) && method_exists($mlsimport->admin->env_data, 'get_property_post_type')) { |
| 649 |
$propertyPostType = $mlsimport->admin->env_data->get_property_post_type(); |
| 650 |
} |
| 651 |
|
| 652 |
// Only delete when the post is actually a property post type. |
| 653 |
if ($postType === $propertyPostType || in_array($postType, ['estate_property', 'property'])) { |
| 654 |
// GitHub issue #287: capture the attachment IDs BEFORE any deletion |
| 655 |
// (they are found by post_parent, gone once the post row is), but do |
| 656 |
// NOT delete them yet. File deletion is the only irreversible step, |
| 657 |
// so it runs last — only after the post row is confirmed gone. |
| 658 |
$attachments = get_posts([ |
| 659 |
'numberposts' => -1, |
| 660 |
'post_type' => 'attachment', |
| 661 |
'post_parent' => $deleteId, |
| 662 |
'post_status' => null, |
| 663 |
'fields' => 'ids', |
| 664 |
]); |
| 665 |
|
| 666 |
// Capture the current status term names for the delete log. |
| 667 |
$termObjList = get_the_terms($deleteId, 'property_status'); |
| 668 |
$deleteIdStatus = is_array($termObjList) ? join(', ', wp_list_pluck($termObjList, 'name')) : ''; |
| 669 |
|
| 670 |
// Re-read the identity from protected meta (issue #286); an empty key |
| 671 |
// means a manually added listing. |
| 672 |
$ListingKey = get_post_meta($deleteId, '_mlsimport_listing_key', true); |
| 673 |
if ('' === $ListingKey) { // manually added listing |
| 674 |
// Never delete user-created listings; log and bail. |
| 675 |
$logEntry = 'User added listing with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL; |
| 676 |
$this->writeImportLogs($logEntry, 'delete'); |
| 677 |
return; |
| 678 |
} |
| 679 |
|
| 680 |
// Capture the owning task before its meta row is deleted below; the |
| 681 |
// success activity entry still needs it afterward. |
| 682 |
$ownerTaskId = intval(get_post_meta($deleteId, 'MLSimport_item_inserted', true)); |
| 683 |
|
| 684 |
// Dedupe (issue #282): this raw-SQL path bypasses the WP delete |
| 685 |
// hooks, so capture the address group now (meta is gone after the |
| 686 |
// raw delete) and re-evaluate it after success — deleting a flagged |
| 687 |
// winner must promote its hidden loser. |
| 688 |
$dedupeAddressKey = (string) get_post_meta($deleteId, 'mlsimport_address_key', true); |
| 689 |
// Telemetry (#283): the deletion counts against the |
| 690 |
// listing's OWN connection — read the provenance stamp |
| 691 |
// (#278) before the raw delete wipes its meta. |
| 692 |
$provenanceMlsId = (int) get_post_meta($deleteId, 'mlsimport_mls_id', true); |
| 693 |
|
| 694 |
global $wpdb; |
| 695 |
// Raw SQL delete skips wp_delete_post (too slow), so nothing cleans the |
| 696 |
// property's term relationships, term counts or listings row. Do that |
| 697 |
// cleanup explicitly (SQL-first) before removing the post itself. |
| 698 |
// Standalone mode: purge the plugin's own term/listings relations first. |
| 699 |
if ( class_exists( 'Mlsimport_Standalone_Row' ) ) { |
| 700 |
Mlsimport_Standalone_Row::purge_post_relations( $deleteId ); |
| 701 |
} |
| 702 |
// Raw delete of the post's meta, then the post and any remaining |
| 703 |
// non-attachment children. Attachment rows and meta must survive this |
| 704 |
// step so wp_delete_attachment() below can still remove their files. |
| 705 |
$wpdb->query($wpdb->prepare("DELETE FROM $wpdb->postmeta WHERE `post_id` = %d", $deleteId)); |
| 706 |
$postsDeleted = $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE (`post_parent` = %d AND `post_type` != 'attachment') OR `ID` = %d", $deleteId, $deleteId)); |
| 707 |
|
| 708 |
// GitHub issue #287: a failed post delete must leave the listing fully |
| 709 |
// intact — no attachment deletion, no "deleted" history, no telemetry. |
| 710 |
if (false === $postsDeleted || $postsDeleted < 1) { |
| 711 |
$logEntry = 'MYSQL DELETE FAILED -> Property with id ' . $deleteId . ' (' . $postType . ') and ' . $ListingKey . ' was NOT deleted; attachments left untouched' . PHP_EOL; |
| 712 |
$this->writeImportLogs($logEntry, 'delete'); |
| 713 |
return; |
| 714 |
} |
| 715 |
|
| 716 |
// The post row is durably gone; removing the now-orphaned attachments |
| 717 |
// (rows, meta, and files) can no longer strand a visible listing. |
| 718 |
foreach ($attachments as $attachmentId) { |
| 719 |
wp_delete_attachment($attachmentId, true); |
| 720 |
} |
| 721 |
|
| 722 |
// Dedupe (issue #282): the post row is durably gone — settle the |
| 723 |
// surviving copies of its address group (promote a hidden loser). |
| 724 |
if ('' !== $dedupeAddressKey && function_exists('mlsimport_dedupe_evaluate')) { |
| 725 |
mlsimport_dedupe_evaluate($dedupeAddressKey, (string) $postType); |
| 726 |
} |
| 727 |
|
| 728 |
// Record the deletion in the activity feed only after it happened. |
| 729 |
mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, $ownerTaskId, 'reconciliation' ); |
| 730 |
mlsimport_telemetry_bump( 'deleted', 1, $provenanceMlsId ); |
| 731 |
|
| 732 |
$logEntry = 'MYSQL DELETE -> Property with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL; |
| 733 |
$this->writeImportLogs($logEntry, 'delete'); |
| 734 |
} |
| 735 |
} |
| 736 |
|
| 737 |
|
| 738 |
|
| 739 |
|
| 740 |
|
| 741 |
|
| 742 |
|
| 743 |
|
| 744 |
|
| 745 |
/** |
| 746 |
* Delegate one incoming property to the explicit Stored Listing Write module. |
| 747 |
* |
| 748 |
* ThemeImport translates the legacy Import Task option names once at this |
| 749 |
* compatibility edge. Listing decisions, common normalization, ordering, |
| 750 |
* persistence, media, activity, and terminal outcomes stay behind write(). |
| 751 |
* |
| 752 |
* @param array<string, mixed> $property Raw RESO property. |
| 753 |
* @param array<string, mixed> $itemIdArray Import Task identity. |
| 754 |
* @param string $tipImport Manual or cron source. |
| 755 |
* @param array<string, mixed> $mlsimportItemOptionData Legacy task options. |
| 756 |
* @return array<string, mixed>|false Public write result, or false if unconfigured. |
| 757 |
*/ |
| 758 |
public function mlsimportSaasPrepareToImportPerItem( $property, $itemIdArray, $tipImport, $mlsimportItemOptionData ) { |
| 759 |
// A ThemeImport object used only for static SaaS/reconciliation helpers has |
| 760 |
// no writer. If a listing call reaches such an object, fail this item without |
| 761 |
// mutating WordPress; shared task execution will continue with the next one. |
| 762 |
if ( null === $this->stored_listing_write ) { |
| 763 |
$this->writeImportLogs( |
| 764 |
empty( $property['ListingKey'] ) |
| 765 |
? 'ERROR: No Listing Key ' . PHP_EOL |
| 766 |
: 'ERROR: Stored Listing Write is not configured.' . PHP_EOL, |
| 767 |
(string) $tipImport |
| 768 |
); |
| 769 |
return false; |
| 770 |
} |
| 771 |
|
| 772 |
// Translate the shallow legacy option array into the stable module settings. |
| 773 |
// The listing's provenance (issue #278) is the task's OWN connection binding |
| 774 |
// (#277) read straight from post meta — deliberately NO current-connection |
| 775 |
// fallback on the write path (decision #266): an unbound task stamps 0 |
| 776 |
// rather than silently adopting whichever connection is globally selected. |
| 777 |
$settings = array( |
| 778 |
'task_id' => (int) ( $itemIdArray['item_id'] ?? 0 ), |
| 779 |
'mls_id' => (int) get_post_meta( (int) ( $itemIdArray['item_id'] ?? 0 ), 'mlsimport_item_mls_id', true ), |
| 780 |
'source' => (string) $tipImport, |
| 781 |
'statuses' => is_array( $mlsimportItemOptionData['mlsimport_item_standardstatus'] ?? null ) |
| 782 |
? $mlsimportItemOptionData['mlsimport_item_standardstatus'] |
| 783 |
: array(), |
| 784 |
'user_id' => (int) ( $mlsimportItemOptionData['mlsimport_item_property_user'] ?? 0 ), |
| 785 |
'assigned_agent_id' => (int) ( $mlsimportItemOptionData['mlsimport_item_agent'] ?? 0 ), |
| 786 |
'use_mls_agent' => ! empty( $mlsimportItemOptionData['mlsimport_item_use_mls_agent'] ), |
| 787 |
'post_status' => (string) ( $mlsimportItemOptionData['mlsimport_item_property_status'] ?? 'publish' ), |
| 788 |
'field_configuration' => is_array( $mlsimportItemOptionData['mlsimport_field_configuration'] ?? null ) |
| 789 |
? $mlsimportItemOptionData['mlsimport_field_configuration'] |
| 790 |
: array(), |
| 791 |
'title_format' => (string) ( $mlsimportItemOptionData['mlsimport_item_title_format'] ?? '' ), |
| 792 |
'config_version' => (string) ( $mlsimportItemOptionData['mlsimport_write_config_version'] ?? '' ), |
| 793 |
); |
| 794 |
|
| 795 |
return $this->stored_listing_write->write( $property, $settings ); |
| 796 |
} |
| 797 |
|
| 798 |
|
| 799 |
|
| 800 |
|
| 801 |
|
| 802 |
|
| 803 |
|
| 804 |
|
| 805 |
/** |
| 806 |
* Check for property status against MLS item delete status to see if we keep or delete the listing. |
| 807 |
* @param int $property_id |
| 808 |
* @param string|array $mlsImportItemStatus |
| 809 |
* @return bool True to keep, false to delete |
| 810 |
*/ |
| 811 |
public function check_if_delete_when_status($property_id, $mlsImportItemStatus, $mlsImportItemStatusDelete = null, $mlsImportItemStatusProtect = null) { |
| 812 |
|
| 813 |
// Resolve the taxonomy field-map, then read the property's current status term. |
| 814 |
$mlsimport_fields_opt = mlsimport_active_field_configuration(); |
| 815 |
$mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy']) |
| 816 |
? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array(); |
| 817 |
$post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map); |
| 818 |
|
| 819 |
// Protected statuses: keep if property status matches |
| 820 |
if (!empty($mlsImportItemStatusProtect)) { |
| 821 |
// An unreadable status cannot prove the listing is NOT protected — keep and log. |
| 822 |
if ('' === $post_status) { |
| 823 |
$this->writeImportLogs('Property with id ' . $property_id . ' KEPT: status unreadable, cannot check it against Protected Statuses' . PHP_EOL, 'delete'); |
| 824 |
return true; |
| 825 |
} |
| 826 |
// Normalise the protect list to space-free enum keys. |
| 827 |
$mlsImportItemStatusProtect = is_array($mlsImportItemStatusProtect) |
| 828 |
? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatusProtect) |
| 829 |
: array(mlsimport_normalize_status_enum($mlsImportItemStatusProtect)); |
| 830 |
// Property status is protected → keep it. |
| 831 |
if (in_array($post_status, $mlsImportItemStatusProtect, true)) { |
| 832 |
return true; |
| 833 |
} |
| 834 |
} |
| 835 |
|
| 836 |
// Default: delete if not protected |
| 837 |
return false; |
| 838 |
} |
| 839 |
|
| 840 |
|
| 841 |
|
| 842 |
|
| 843 |
/** |
| 844 |
* Manual-import variant of the keep/delete status check. |
| 845 |
* |
| 846 |
* @param int $property_id The property post ID. |
| 847 |
* @param array|string $mlsImportItemStatus Task's selected statuses. |
| 848 |
* @return bool True if the property's status matches the selected set. |
| 849 |
*/ |
| 850 |
public function check_if_delete_when_status_on_manual_import($property_id, $mlsImportItemStatus) { |
| 851 |
// Normalize status arrays/strings to a space-free comparison key so |
| 852 |
// Trestle PrettyEnums labels match the raw enum config values. |
| 853 |
$mlsImportItemStatus = is_array($mlsImportItemStatus) |
| 854 |
? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatus) |
| 855 |
: mlsimport_normalize_status_enum($mlsImportItemStatus); |
| 856 |
|
| 857 |
// Resolve the taxonomy field-map, then read the property's current status term. |
| 858 |
$mlsimport_fields_opt = mlsimport_active_field_configuration(); |
| 859 |
$mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy']) |
| 860 |
? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array(); |
| 861 |
$post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map); |
| 862 |
|
| 863 |
// An unreadable status is our read failing, not proof the listing should go — keep and log. |
| 864 |
if ('' === $post_status) { |
| 865 |
$this->writeImportLogs('Property with id ' . $property_id . ' KEPT: status unreadable, deletion requires a readable status' . PHP_EOL, 'delete'); |
| 866 |
return true; |
| 867 |
} |
| 868 |
|
| 869 |
// Keep if status matches "keep" status (array membership or scalar equality). |
| 870 |
if ((is_array($mlsImportItemStatus) && in_array($post_status, $mlsImportItemStatus, true)) || |
| 871 |
(!is_array($mlsImportItemStatus) && $post_status === $mlsImportItemStatus)) { |
| 872 |
|
| 873 |
return true; |
| 874 |
} |
| 875 |
|
| 876 |
|
| 877 |
|
| 878 |
// Default: status read but doesn't match the task's selection → delete. |
| 879 |
return false; |
| 880 |
} |
| 881 |
|
| 882 |
|
| 883 |
|
| 884 |
|
| 885 |
|
| 886 |
|
| 887 |
|
| 888 |
/** |
| 889 |
* Check if we should keep or delete the listing when still in MLS. |
| 890 |
* true we keep |
| 891 |
*/ |
| 892 |
public function check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect = null) { |
| 893 |
// Resolve the taxonomy field-map, then read the property's current status term. |
| 894 |
$mlsimport_fields_opt = mlsimport_active_field_configuration(); |
| 895 |
$mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy']) |
| 896 |
? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array(); |
| 897 |
$post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map); |
| 898 |
|
| 899 |
// The listing is still in the MLS feed. An unreadable local status is |
| 900 |
// never proof it should be deleted (every past mass-deletion incident |
| 901 |
// was this read failing) — keep and log. |
| 902 |
if ('' === $post_status) { |
| 903 |
$this->writeImportLogs('Property with id ' . $property_id . ' KEPT: still in MLS feed but local status unreadable' . PHP_EOL, 'delete'); |
| 904 |
return true; |
| 905 |
} |
| 906 |
|
| 907 |
// Protected statuses: keep if property status matches |
| 908 |
if (!empty($mlsimport_item_standardstatusprotect)) { |
| 909 |
// Normalise the protect list to space-free enum keys. |
| 910 |
$mlsimport_item_standardstatusprotect = is_array($mlsimport_item_standardstatusprotect) |
| 911 |
? array_map('mlsimport_normalize_status_enum', $mlsimport_item_standardstatusprotect) |
| 912 |
: array(mlsimport_normalize_status_enum($mlsimport_item_standardstatusprotect)); |
| 913 |
// Protected → keep. |
| 914 |
if (in_array($post_status, $mlsimport_item_standardstatusprotect, true)) { |
| 915 |
return true; |
| 916 |
} |
| 917 |
} |
| 918 |
|
| 919 |
// Early return if MLS status empty |
| 920 |
if (empty($mlsimport_item_standardstatus)) { |
| 921 |
return true; // default: keep if no status set |
| 922 |
} |
| 923 |
|
| 924 |
// Normalize standard statuses to a space-free key for comparison |
| 925 |
if (is_array($mlsimport_item_standardstatus)) { |
| 926 |
// Array form → keep when the property's status is a member. |
| 927 |
$mlsimport_item_standardstatus = array_map('mlsimport_normalize_status_enum', $mlsimport_item_standardstatus); |
| 928 |
return in_array($post_status, $mlsimport_item_standardstatus, true); |
| 929 |
} |
| 930 |
// Scalar form → keep on exact (normalised) match. |
| 931 |
return $post_status === mlsimport_normalize_status_enum($mlsimport_item_standardstatus); |
| 932 |
} |
| 933 |
|
| 934 |
|
| 935 |
|
| 936 |
|
| 937 |
|
| 938 |
|
| 939 |
|
| 940 |
|
| 941 |
|
| 942 |
|
| 943 |
|
| 944 |
|
| 945 |
|
| 946 |
|
| 947 |
|
| 948 |
} |
| 949 |
|