| 1 |
<?php |
| 2 |
/** |
| 3 |
* Connections screen — AJAX handlers (issue #280, decision #271, spec #273). |
| 4 |
* |
| 5 |
* WHY THIS FILE EXISTS |
| 6 |
* -------------------- |
| 7 |
* The Connections tab has three state-changing actions, all admin-only and |
| 8 |
* all nonce-checked against 'mlsimport_connections_screen': |
| 9 |
* |
| 10 |
* - reorder: persist a drag-reorder as registry priorities 1..N |
| 11 |
* (priority drives the dedupe winner, decision #267), |
| 12 |
* - test: run the SaaS connection test for ONE connection and mirror |
| 13 |
* the outcome (status + tested_at) into its registry record, |
| 14 |
* - disconnect: sign the install out of the mlsimport.com account (drop the |
| 15 |
* cached token AND the saved password — the token getter |
| 16 |
* re-authenticates from saved credentials, so a kept password |
| 17 |
* would silently sign back in on the next page load — and |
| 18 |
* the account's cached connection cap, re-read on sign-in). |
| 19 |
* |
| 20 |
* The inline connect form on the same tab needs no handler here: it posts to |
| 21 |
* the existing 'mlsimport_save_account' action (onboarding module), which |
| 22 |
* already saves credentials and verifies the login. |
| 23 |
* |
| 24 |
* This file also owns mlsimport_connections_patch_test() — the single |
| 25 |
* record-scoped credential-test sequence shared by the per-row Test below |
| 26 |
* and the Add-MLS drawer flow (#281, mlsimport-connections-add.php). |
| 27 |
* |
| 28 |
* Pure helpers + screen data live in mlsimport-connections-screen.php. |
| 29 |
* |
| 30 |
* @since 7.2.0 |
| 31 |
* @package Mlsimport |
| 32 |
*/ |
| 33 |
|
| 34 |
if ( ! defined( 'ABSPATH' ) ) { |
| 35 |
exit; |
| 36 |
} |
| 37 |
|
| 38 |
/** |
| 39 |
* PATCH one set of MLS credentials to the SaaS and apply the #276 contract. |
| 40 |
* |
| 41 |
* The single record-scoped test sequence, shared by the per-row Test (#280, |
| 42 |
* below) and the Add-MLS drawer flow (#281, |
| 43 |
* includes/mlsimport-connections-add.php). It deliberately has NO global |
| 44 |
* side effects — the global connection flags belong to the CURRENT |
| 45 |
* connection's legacy test, mlsimport_saas_check_mls_connection() (admin |
| 46 |
* class), which mirrors this same PATCH + contract sequence; a contract |
| 47 |
* change must land in that method and here. |
| 48 |
* |
| 49 |
* Step by step: |
| 50 |
* 1. Build the payload from the adapter's declared flat credential fields — |
| 51 |
* an unsupported provider or blank required fields refuse here, BEFORE |
| 52 |
* any network call. |
| 53 |
* 2. PATCH 'clients': the SaaS validates the credentials against the live MLS. |
| 54 |
* 3. Apply the echoed mls_data block to exactly this connection's record |
| 55 |
* (echo-guarded; a no-op when the record is not registered yet), and mark |
| 56 |
* a stable not_entitled rejection on this record only (also a no-op when |
| 57 |
* unregistered — the caller still sees the rejection in 'answer'). |
| 58 |
* |
| 59 |
* @param object $adapter Provider Family adapter for this MLS. |
| 60 |
* @param array $flat_credentials Flat slot-named credential values. |
| 61 |
* @param int $mls_id The MLS being tested. |
| 62 |
* @return array{refused:bool, error:?array, answer:mixed, tested_ok:bool} |
| 63 |
* refused = stopped before the network (error says why); |
| 64 |
* otherwise answer is the SaaS response and tested_ok its verdict. |
| 65 |
*/ |
| 66 |
function mlsimport_connections_patch_test( $adapter, array $flat_credentials, int $mls_id ): array { |
| 67 |
// Step 1: refuse unsupported providers / missing credentials pre-network. |
| 68 |
$payload_result = $adapter->supported() |
| 69 |
? $adapter->connection_test_payload( $flat_credentials, $mls_id ) |
| 70 |
: array( 'success' => false, 'error' => $adapter->error() ); |
| 71 |
if ( ! $payload_result['success'] ) { |
| 72 |
return array( |
| 73 |
'refused' => true, |
| 74 |
'error' => $payload_result['error'], |
| 75 |
'answer' => null, |
| 76 |
'tested_ok' => false, |
| 77 |
); |
| 78 |
} |
| 79 |
|
| 80 |
// Step 2: the SaaS validates the credentials against the live MLS. |
| 81 |
$answer = ThemeImport::globalApiRequestSaas( 'clients', $payload_result['payload'], 'PATCH' ); |
| 82 |
|
| 83 |
// Step 3: #276 contract — echoed config to this record, stable rejection |
| 84 |
// marked on this record only. |
| 85 |
if ( isset( $answer['mls_data'] ) && is_array( $answer['mls_data'] ) ) { |
| 86 |
mlsimport_apply_client_block( $answer['mls_data'], $mls_id ); |
| 87 |
} |
| 88 |
if ( mlsimport_response_not_entitled( $answer ) ) { |
| 89 |
mlsimport_mark_connection_not_entitled( $mls_id ); |
| 90 |
} |
| 91 |
|
| 92 |
return array( |
| 93 |
'refused' => false, |
| 94 |
'error' => null, |
| 95 |
'answer' => $answer, |
| 96 |
'tested_ok' => true === ( $answer['success'] ?? false ) && true === ( $answer['tested'] ?? false ), |
| 97 |
); |
| 98 |
} |
| 99 |
|
| 100 |
/** |
| 101 |
* Run the SaaS connection test for one registered connection. |
| 102 |
* |
| 103 |
* Step by step: |
| 104 |
* 1. Unregistered id => null (nothing to test, nothing to record). |
| 105 |
* 2. The CURRENT connection uses the full legacy test in the admin class — |
| 106 |
* it owns the global side effects (mlsimport_connection_test flag, |
| 107 |
* metadata re-arm) and already mirrors the outcome into the record (#277). |
| 108 |
* 3. Any OTHER connection is tested from its own record through the shared |
| 109 |
* mlsimport_connections_patch_test() sequence: the flat option keys its |
| 110 |
* provider adapter declares are synthesized from the record's generic |
| 111 |
* creds, and the outcome is recorded. The global flags are deliberately |
| 112 |
* NOT touched — they belong to the current connection only. |
| 113 |
* 4. Return the fresh record so the caller sees the new status + tested_at. |
| 114 |
* |
| 115 |
* @param int $mls_id The connection to test. |
| 116 |
* @return array|null Fresh registry record, or null when not registered. |
| 117 |
*/ |
| 118 |
function mlsimport_connections_run_test( int $mls_id ): ?array { |
| 119 |
global $mlsimport; |
| 120 |
|
| 121 |
// Step 1: only registered connections can be tested from this screen. |
| 122 |
$record = Mlsimport_Connections::get( $mls_id ); |
| 123 |
if ( null === $record ) { |
| 124 |
return null; |
| 125 |
} |
| 126 |
|
| 127 |
if ( $mls_id === mlsimport_current_mls_id() ) { |
| 128 |
// Step 2: the legacy full test (global flags + record mirror). |
| 129 |
$mlsimport->admin->mlsimport_saas_check_mls_connection(); |
| 130 |
} else { |
| 131 |
// Step 3: record-scoped test for a non-current connection. |
| 132 |
$adapter = Mlsimport_Provider_Family::adapter( $record['provider_type'], (string) $mls_id ); |
| 133 |
$result = mlsimport_connections_patch_test( |
| 134 |
$adapter, |
| 135 |
mlsimport_connections_credential_options( $record, $adapter->credential_fields() ), |
| 136 |
$mls_id |
| 137 |
); |
| 138 |
|
| 139 |
if ( $result['refused'] ) { |
| 140 |
// Keep the refusal reason discoverable (unsupported provider type |
| 141 |
// vs missing credentials need different remedies). |
| 142 |
error_log( |
| 143 |
'MLSImport connection test refused for connection ' . $mls_id . ': ' |
| 144 |
. ( $result['error']['code'] ?? 'unknown' ) |
| 145 |
); |
| 146 |
} |
| 147 |
|
| 148 |
mlsimport_connection_record_test_result( $mls_id, $result['tested_ok'] ); |
| 149 |
} |
| 150 |
|
| 151 |
// Step 4: re-read — the recorder above rewrote status and tested_at. |
| 152 |
return Mlsimport_Connections::get( $mls_id ); |
| 153 |
} |
| 154 |
|
| 155 |
/** |
| 156 |
* AJAX: persist a drag-reorder as registry priorities. |
| 157 |
* |
| 158 |
* Step by step: |
| 159 |
* 1. Nonce + administrator capability. |
| 160 |
* 2. Validate + apply the posted id order through the pure helper — an order |
| 161 |
* that does not name every registered connection exactly once is refused. |
| 162 |
* 3. Persist each record's new priority through the registry accessor. |
| 163 |
* |
| 164 |
* @return void |
| 165 |
*/ |
| 166 |
function mlsimport_ajax_connections_reorder() { |
| 167 |
// Step 1: security. |
| 168 |
check_ajax_referer( 'mlsimport_connections_screen', 'security' ); |
| 169 |
if ( ! current_user_can( 'administrator' ) ) { |
| 170 |
wp_send_json_error( array( 'message' => 'Unauthorized' ) ); |
| 171 |
} |
| 172 |
|
| 173 |
// Step 2: validate + apply (ids are cast to int inside the helper). |
| 174 |
$order = isset( $_POST['order'] ) && is_array( $_POST['order'] ) ? wp_unslash( $_POST['order'] ) : array(); |
| 175 |
$updated = mlsimport_connections_apply_order( $order, Mlsimport_Connections::all() ); |
| 176 |
if ( null === $updated ) { |
| 177 |
wp_send_json_error( array( 'message' => 'Invalid connection order' ) ); |
| 178 |
} |
| 179 |
|
| 180 |
// Step 3: persist. |
| 181 |
foreach ( $updated as $record ) { |
| 182 |
Mlsimport_Connections::save( $record ); |
| 183 |
} |
| 184 |
|
| 185 |
// Step 4: priority drives the dedupe winner (#267/#282) — re-decide every |
| 186 |
// currently flagged duplicate group under the new order, deterministically. |
| 187 |
mlsimport_dedupe_reevaluate_flagged(); |
| 188 |
wp_send_json_success(); |
| 189 |
} |
| 190 |
|
| 191 |
/** |
| 192 |
* AJAX: per-row connection test. |
| 193 |
* |
| 194 |
* Runs the test for the posted mls_id and returns the row's fresh status |
| 195 |
* view (pill key/class/failing + translated label + human last-test time) |
| 196 |
* so the JS can update the pill and banner without a reload. |
| 197 |
* |
| 198 |
* @return void |
| 199 |
*/ |
| 200 |
function mlsimport_ajax_connections_test() { |
| 201 |
check_ajax_referer( 'mlsimport_connections_screen', 'security' ); |
| 202 |
if ( ! current_user_can( 'administrator' ) ) { |
| 203 |
wp_send_json_error( array( 'message' => 'Unauthorized' ) ); |
| 204 |
} |
| 205 |
|
| 206 |
$mls_id = isset( $_POST['mls_id'] ) ? (int) $_POST['mls_id'] : 0; |
| 207 |
$record = mlsimport_connections_run_test( $mls_id ); |
| 208 |
if ( null === $record ) { |
| 209 |
wp_send_json_error( array( 'message' => 'Unknown connection' ) ); |
| 210 |
} |
| 211 |
|
| 212 |
$status = mlsimport_connections_row_status( $record ); |
| 213 |
wp_send_json_success( |
| 214 |
array( |
| 215 |
'status' => $status, |
| 216 |
'label' => mlsimport_connections_status_label( $status['key'] ), |
| 217 |
'tested' => $record['tested_at'] > 0 |
| 218 |
/* translators: %s: human time difference since the last connection test. */ |
| 219 |
? sprintf( __( 'tested %s ago', 'mlsimport' ), human_time_diff( $record['tested_at'] ) ) |
| 220 |
: __( 'never tested', 'mlsimport' ), |
| 221 |
) |
| 222 |
); |
| 223 |
} |
| 224 |
|
| 225 |
/** |
| 226 |
* AJAX: disconnect the install from the mlsimport.com account. |
| 227 |
* |
| 228 |
* Step by step: |
| 229 |
* 1. Nonce + administrator capability. |
| 230 |
* 2. Remove the saved account password — with it present, the token getter |
| 231 |
* would just re-authenticate and undo the disconnect on the next load. |
| 232 |
* The username is kept so reconnecting only asks for the password. |
| 233 |
* 3. Drop the cached token + its expiry stamp so the session ends now. |
| 234 |
* 4. Forget the account's connection cap — it belongs to the account that |
| 235 |
* was just signed out. Until the next sign-in re-reads it the screen |
| 236 |
* shows the legacy single-MLS cap. |
| 237 |
* |
| 238 |
* @return void |
| 239 |
*/ |
| 240 |
function mlsimport_ajax_connections_disconnect() { |
| 241 |
// Step 1: security. |
| 242 |
check_ajax_referer( 'mlsimport_connections_screen', 'security' ); |
| 243 |
if ( ! current_user_can( 'administrator' ) ) { |
| 244 |
wp_send_json_error( array( 'message' => 'Unauthorized' ) ); |
| 245 |
} |
| 246 |
|
| 247 |
// Step 2: forget the password (credentials are never sanitized — this |
| 248 |
// only removes the key). |
| 249 |
$options = get_option( 'mlsimport_admin_options', array() ); |
| 250 |
if ( is_array( $options ) ) { |
| 251 |
unset( $options['mlsimport_password'] ); |
| 252 |
update_option( 'mlsimport_admin_options', $options ); |
| 253 |
} |
| 254 |
|
| 255 |
// Step 3: end the current session. |
| 256 |
delete_transient( 'mlsimport_saas_token' ); |
| 257 |
delete_option( 'mlsimport_token_expiry' ); |
| 258 |
|
| 259 |
// Step 4: the cap was this account's grant — forget it with the account. |
| 260 |
delete_option( 'mlsimport_entitlement_cap' ); |
| 261 |
|
| 262 |
wp_send_json_success(); |
| 263 |
} |
| 264 |
|
| 265 |
add_action( 'wp_ajax_mlsimport_connections_reorder', 'mlsimport_ajax_connections_reorder' ); |
| 266 |
add_action( 'wp_ajax_mlsimport_connections_test', 'mlsimport_ajax_connections_test' ); |
| 267 |
add_action( 'wp_ajax_mlsimport_connections_disconnect', 'mlsimport_ajax_connections_disconnect' ); |
| 268 |
|