PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / trunk
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings vtrunk
7.2.2 7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 All 37 releases
mlsimport / includes / mlsimport-connections-ajax.php

mlsimport-connections-ajax.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings trunk, at includes/mlsimport-connections-ajax.php

268 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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