| 1 |
<?php |
| 2 |
/** |
| 3 |
* Connection-scoped MLS metadata gathering (issues #275/#281). |
| 4 |
* |
| 5 |
* WHY THIS FILE EXISTS |
| 6 |
* -------------------- |
| 7 |
* Gathering MLS metadata (theme schema + field data + enums from GET clients) |
| 8 |
* and reconciling the Field Configuration from it used to live only inside |
| 9 |
* the admin AJAX handler, hard-bound to the CURRENT connection. The Add-MLS |
| 10 |
* drawer (#281) must run the exact same gather for the connection it just |
| 11 |
* created — that is how the new connection's field mapping ends up |
| 12 |
* pre-seeded from theme defaults (decision #264: reconcile auto-enables |
| 13 |
* theme-schema fields it has never seen on the first metadata gather). |
| 14 |
* |
| 15 |
* This file holds that single shared gather core, scoped by mls_id: |
| 16 |
* |
| 17 |
* - mls_id 0 / the current connection => exactly the historic behavior, |
| 18 |
* including remembering the authoritative provider type globally; |
| 19 |
* - any other registered connection => the same gather, but every write |
| 20 |
* lands in that connection's "_{mls_id}"-suffixed options and NO global |
| 21 |
* current-connection state (saved provider type) is touched. |
| 22 |
* |
| 23 |
* The admin AJAX handler (mlsimport_saas_get_metadata_function) is now a |
| 24 |
* thin nonce/capability wrapper around this function. |
| 25 |
* |
| 26 |
* @since 7.2.0 |
| 27 |
* @package Mlsimport |
| 28 |
*/ |
| 29 |
|
| 30 |
if ( ! defined( 'ABSPATH' ) ) { |
| 31 |
exit; |
| 32 |
} |
| 33 |
|
| 34 |
/** |
| 35 |
* Gather MLS metadata from the SaaS and reconcile one connection's mapping. |
| 36 |
* |
| 37 |
* Step by step: |
| 38 |
* 1. GET clients?theme_id=<configured theme> from the SaaS. |
| 39 |
* 2. Shape guard: a failed request returns none of the metadata keys — STOP |
| 40 |
* before touching anything, so a bad response can never overwrite good |
| 41 |
* cached metadata or mark the site populated with an empty field list. |
| 42 |
* 3. Refresh the connection registry from mls_entitlements when the SaaS |
| 43 |
* sends it (#276); a legacy response without it changes nothing. |
| 44 |
* 4. ONLY for the current connection: remember the authoritative provider |
| 45 |
* type globally (the saved-type pair belongs to the current MLS — writing |
| 46 |
* it for another connection would orphan the current one's saved type). |
| 47 |
* 5. Persist: the theme schema stays GLOBAL (decision #263); the two MLS |
| 48 |
* metadata blobs are stored per-connection (#275). |
| 49 |
* 6. Reconcile the target connection's Field Configuration against the fresh |
| 50 |
* metadata + theme schema. New metadata fields that exist in the theme |
| 51 |
* schema are auto-enabled — this IS the "pre-seeded from theme defaults" |
| 52 |
* behavior (#264). A reconcile failure clears the populated flag so the |
| 53 |
* next page load retries. |
| 54 |
* 7. Mark the connection's metadata as populated. |
| 55 |
* |
| 56 |
* @param int $mls_id Connection to gather for; 0 = the current connection. |
| 57 |
* @return array{success:bool, code:string, message:string, detail:string, |
| 58 |
* result:array, revision:int} Stable outcome for callers: |
| 59 |
* code is '' on success, 'request_failed' (step 2) or |
| 60 |
* 'reconcile_failed' (step 6) otherwise; result carries the raw |
| 61 |
* reconcile result for the reconcile_failed case. |
| 62 |
*/ |
| 63 |
function mlsimport_gather_connection_metadata( int $mls_id = 0 ): array { |
| 64 |
$theme_start = new ThemeImport(); |
| 65 |
|
| 66 |
// Step 1: GET the theme schema + MLS metadata for the configured theme, |
| 67 |
// NAMING the MLS the gather is for (#293, closing the #268 gap): the |
| 68 |
// legacy unscoped GET answered for whatever MLS the account record |
| 69 |
// happened to sit on, which could seed this connection's slot with |
| 70 |
// another MLS's metadata. mls_id 0 resolves to the current connection; |
| 71 |
// only a site with no connection at all still sends the legacy URL. |
| 72 |
$options = get_option( 'mlsimport_admin_options' ); |
| 73 |
$options = is_array( $options ) ? $options : array(); |
| 74 |
$target = $mls_id > 0 ? $mls_id : mlsimport_current_mls_id(); |
| 75 |
$url = 'clients?theme_id=' . intval( $options['mlsimport_theme_used'] ?? 0 ); |
| 76 |
if ( $target > 0 ) { |
| 77 |
$url .= '&mls_id=' . $target; |
| 78 |
} |
| 79 |
$answer = $theme_start::globalApiRequestSaas( $url, array(), 'GET' ); |
| 80 |
|
| 81 |
// Step 2: refuse a response without the full metadata shape, untouched. |
| 82 |
if ( ! is_array( $answer ) || ! isset( $answer['theme_schema'], $answer['mls_data']['mls_meta_data'], $answer['mls_data']['mls_meta_enums'] ) ) { |
| 83 |
return array( |
| 84 |
'success' => false, |
| 85 |
'code' => 'request_failed', |
| 86 |
'message' => esc_html__( 'Gathering MLS metadata failed. Nothing was changed - it will retry on the next page load.', 'mlsimport' ), |
| 87 |
'detail' => is_array( $answer ) && isset( $answer['error_message'] ) ? $answer['error_message'] : '', |
| 88 |
'result' => array(), |
| 89 |
'revision' => 0, |
| 90 |
); |
| 91 |
} |
| 92 |
|
| 93 |
// Step 2b (#293): a scoped request must get a CONFIRMED answer. The API |
| 94 |
// echoes mls_id on every scoped reply (#292), so anything else — a |
| 95 |
// mismatched echo, or no echo at all — means the answer cannot be |
| 96 |
// attributed to this connection. Saving it would poison this connection's |
| 97 |
// metadata slot, so refuse before ANY write (fail-closed, #279/#285). |
| 98 |
// A request that named no MLS (a site with no connection yet) has nothing |
| 99 |
// to confirm and keeps the legacy unscoped behavior. |
| 100 |
if ( $target > 0 && ! mlsimport_mls_scoped_echo_ok( $answer, $target ) ) { |
| 101 |
return array( |
| 102 |
'success' => false, |
| 103 |
'code' => 'wrong_mls', |
| 104 |
'message' => esc_html__( 'The metadata service answered for a different MLS. Nothing was saved - please try again.', 'mlsimport' ), |
| 105 |
'detail' => 'asked ' . $target . ', got ' . ( isset( $answer['mls_id'] ) ? intval( $answer['mls_id'] ) : 'no mls_id' ), |
| 106 |
'result' => array(), |
| 107 |
'revision' => 0, |
| 108 |
); |
| 109 |
} |
| 110 |
|
| 111 |
// Step 3: refresh the registry (cap + matching records) from the response. |
| 112 |
mlsimport_apply_entitlements( $answer ); |
| 113 |
|
| 114 |
// Step 4: the global saved-type pair belongs to the CURRENT connection |
| 115 |
// only. A scoped gather for another connection must not overwrite it. |
| 116 |
$is_current = 0 === $mls_id || mlsimport_current_mls_id() === $mls_id; |
| 117 |
if ( $is_current && isset( $answer['mls_data']['type'], $options['mlsimport_mls_name'] ) ) { |
| 118 |
Mlsimport_Provider_Family::remember_type( |
| 119 |
$answer['mls_data']['type'], |
| 120 |
$options['mlsimport_mls_name'] |
| 121 |
); |
| 122 |
} |
| 123 |
|
| 124 |
// Step 5: cache metadata — theme schema global, MLS blobs per-connection. |
| 125 |
update_option( 'mlsimport_mls_metadata_theme_schema', $answer['theme_schema'] ); |
| 126 |
mlsimport_update_connection_option( 'mlsimport_mls_metadata_mls_data', $answer['mls_data']['mls_meta_data'], $mls_id ); |
| 127 |
mlsimport_update_connection_option( 'mlsimport_mls_metadata_mls_enums', $answer['mls_data']['mls_meta_enums'], $mls_id ); |
| 128 |
|
| 129 |
// Step 6: one server-side reconcile seeds/updates this connection's |
| 130 |
// Field Configuration (theme-schema defaults auto-enable, #264). |
| 131 |
$metadata = is_string( $answer['mls_data']['mls_meta_data'] ) |
| 132 |
? json_decode( $answer['mls_data']['mls_meta_data'], true ) |
| 133 |
: $answer['mls_data']['mls_meta_data']; |
| 134 |
$metadata = is_array( $metadata ) ? $metadata : array(); |
| 135 |
$result = mlsimport_reconcile_field_configuration( $metadata, mlsimport_hardocde_theme_schema(), $mls_id ); |
| 136 |
if ( ! $result['success'] ) { |
| 137 |
mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated', $mls_id ); |
| 138 |
return array( |
| 139 |
'success' => false, |
| 140 |
'code' => 'reconcile_failed', |
| 141 |
'message' => (string) ( $result['message'] ?? '' ), |
| 142 |
'detail' => '', |
| 143 |
'result' => $result, |
| 144 |
'revision' => 0, |
| 145 |
); |
| 146 |
} |
| 147 |
|
| 148 |
// Step 7: this connection's metadata is now gathered and reconciled. |
| 149 |
mlsimport_update_connection_option( 'mlsimport_mls_metadata_populated', 'yes', $mls_id ); |
| 150 |
|
| 151 |
return array( |
| 152 |
'success' => true, |
| 153 |
'code' => '', |
| 154 |
'message' => '', |
| 155 |
'detail' => '', |
| 156 |
'result' => $result, |
| 157 |
'revision' => (int) $result['revision'], |
| 158 |
); |
| 159 |
} |
| 160 |
|