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-add.php

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

281 lines 12.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Add-MLS drawer — server side (issue #281, decision #271, spec #273).
4 *
5 * WHY THIS FILE EXISTS
6 * --------------------
7 * Adding MLS #2+ happens ONLY on the Connections screen, through the right
8 * slide-over drawer: pick MLS -> provider credentials -> connection test.
9 * This file owns everything the drawer needs server-side:
10 *
11 * - pure helpers (no WordPress): the refusal rule (no MLS picked / already
12 * registered / plan cap reached) and building the candidate registry
13 * record from the drawer's posted flat provider credentials,
14 * - the add flow core: test the posted credentials against the SaaS and,
15 * ONLY on a passed test, register the connection (at the lowest priority)
16 * and answer at once,
17 * - the single AJAX handler the drawer posts to.
18 *
19 * The field-mapping seed that follows a saved connection is its OWN request
20 * (#325) and lives in includes/mlsimport-connections-seed.php.
21 *
22 * A failed test saves NOTHING — no registry record, no options — so the
23 * drawer can stay open with the error and the user can retry. A saved
24 * connection is always reported as saved, whatever happens to the seed.
25 *
26 * The drawer markup lives in admin/partials/mlsimport-connections-drawer.php
27 * and its behavior in admin/js/mlsimport-connections-drawer.js.
28 *
29 * @since 7.2.0
30 * @package Mlsimport
31 */
32
33 if ( ! defined( 'ABSPATH' ) ) {
34 exit;
35 }
36
37 /**
38 * Decide whether an add request must be refused before any network call. Pure.
39 *
40 * Step by step:
41 * 1. No MLS picked (id <= 0) => 'no_mls'.
42 * 2. Already a registered connection => 'already_registered' (checked
43 * before the cap: "you already have this one" is the useful message even
44 * on a full plan).
45 * 3. Every entitled slot used (>= cap) => 'at_cap' — the drawer is
46 * unreachable at the cap in the UI, but the server enforces it too.
47 * 4. Otherwise the add may proceed => null.
48 *
49 * @param int $mls_id The MLS the user picked.
50 * @param array $registered_ids mls_ids already in the registry.
51 * @param int $used Number of registered connections.
52 * @param int $cap The plan's connection cap (#276).
53 * @return string|null Refusal code, or null to proceed.
54 */
55 function mlsimport_connections_add_refusal( int $mls_id, array $registered_ids, int $used, int $cap ): ?string {
56 // Step 1: an add without an identity is meaningless.
57 if ( $mls_id <= 0 ) {
58 return 'no_mls';
59 }
60 // Step 2: one record per MLS — the registry is keyed by mls_id.
61 if ( in_array( $mls_id, array_map( 'intval', $registered_ids ), true ) ) {
62 return 'already_registered';
63 }
64 // Step 3: the entitlement cap is a hard limit, UI and server alike.
65 if ( $used >= $cap ) {
66 return 'at_cap';
67 }
68 // Step 4: proceed.
69 return null;
70 }
71
72 /**
73 * Build the candidate registry record from the drawer's posted values. Pure
74 * (uses only the migration module's suffix rule).
75 *
76 * Step by step:
77 * 1. Map each flat adapter credential field to its generic record name
78 * (client_id, client_secret, username, password, mls_token) with the same
79 * suffix rule the #274 migration used — values verbatim apart from trim,
80 * credentials are never sanitized (#204).
81 * 2. Assemble the record: status 'yes' because the caller only ever saves it
82 * AFTER a passed connection test, and priority 0 so the registry appends
83 * it at the lowest priority ("new connections join at the end", #271).
84 *
85 * @param int $mls_id The new connection's MLS id.
86 * @param string $mls_name Human-readable MLS name (autocomplete label).
87 * @param string $provider_type Resolved provider adapter type.
88 * @param array $flat_creds Posted flat credential values, keyed by field.
89 * @param array $credential_fields Flat option keys the adapter declares.
90 * @return array Registry record ready for Mlsimport_Connections::save().
91 */
92 function mlsimport_connections_add_record( int $mls_id, string $mls_name, string $provider_type, array $flat_creds, array $credential_fields ): array {
93 // Step 1: flat slot-names => generic names, trim-only values.
94 $creds = array();
95 foreach ( $credential_fields as $field ) {
96 $generic = mlsimport_multimls_generic_credential_name( (string) $field );
97 if ( '' !== $generic ) {
98 $creds[ $generic ] = trim( (string) ( $flat_creds[ $field ] ?? '' ) );
99 }
100 }
101
102 // Step 2: tested-OK record, appended at the lowest priority on save.
103 return array(
104 'mls_id' => $mls_id,
105 'mls_name' => $mls_name,
106 'provider_type' => $provider_type,
107 'creds' => $creds,
108 'status' => 'yes',
109 'priority' => 0,
110 );
111 }
112
113 /**
114 * Run the full add flow: refuse / test / register / seed. Never terminates.
115 *
116 * Step by step:
117 * 1. Refuse without a network call: no MLS, already registered, at cap.
118 * 2. Resolve the provider adapter for the picked MLS. A brand-new MLS has no
119 * saved type, so the Provider Family module's numeric compatibility map
120 * decides (same resolution the migration used); the SaaS's authoritative
121 * type can refresh the record later (#276).
122 * 3. Whitelist + trim the posted credentials to exactly the adapter's
123 * declared fields.
124 * 4. Test through the shared mlsimport_connections_patch_test() sequence
125 * (mlsimport-connections-ajax.php) — blank required fields refuse there
126 * before the network; otherwise the SaaS validates the credentials
127 * against the live MLS.
128 * 5. A failed or not-entitled test returns the error and saves NOTHING.
129 * 6. Passed: register the record (appends at the lowest priority), apply the
130 * echoed mls_data block when the SaaS sends one (#276 — re-applied HERE
131 * because the shared sequence ran before the record existed, when the
132 * echo guard had nothing to refresh), and stamp tested_at through the
133 * shared recorder (#277).
134 * 7. Return success. The field-mapping seed is deliberately NOT part of this
135 * flow any more (#325): it is a second SaaS round trip (GET clients with
136 * the full metadata + enum payload) that, run after the save inside the
137 * same request, let a host time limit or a fatal end the request AFTER the
138 * record was written — the drawer then reported a failure for a connection
139 * that existed, and the retry hit 'already_registered'. The drawer now
140 * acknowledges the save from this response and asks for the seed
141 * separately (mlsimport_connections_seed_execute(), its own file).
142 *
143 * @param int $mls_id The MLS the user picked.
144 * @param string $mls_name Human-readable MLS name.
145 * @param array $posted_creds Posted flat credential values (unslashed).
146 * @return array{success:bool, code:string, message:string}
147 */
148 function mlsimport_connections_add_execute( int $mls_id, string $mls_name, array $posted_creds ): array {
149 $refused = static function ( string $code, string $message ): array {
150 return array(
151 'success' => false,
152 'code' => $code,
153 'message' => $message,
154 );
155 };
156
157 // Step 1: cheap refusals first — nothing leaves the site.
158 $refusal_messages = array(
159 'no_mls' => esc_html__( 'Pick your MLS from the list first.', 'mlsimport' ),
160 'already_registered' => esc_html__( 'This MLS is already one of your connections.', 'mlsimport' ),
161 'at_cap' => esc_html__( 'All your plan\'s MLS connections are in use — upgrade your plan to add another.', 'mlsimport' ),
162 );
163 $refusal = mlsimport_connections_add_refusal(
164 $mls_id,
165 array_keys( Mlsimport_Connections::all() ),
166 count( Mlsimport_Connections::all() ),
167 mlsimport_entitlement_cap()
168 );
169 if ( null !== $refusal ) {
170 return $refused( $refusal, $refusal_messages[ $refusal ] );
171 }
172
173 // Step 2: resolve the adapter (no saved type yet => numeric map decides).
174 $adapter = Mlsimport_Provider_Family::adapter( '', $mls_id );
175
176 // Step 3: only declared fields enter, values trim-only (#204).
177 $flat_creds = array();
178 foreach ( $adapter->credential_fields() as $field ) {
179 $flat_creds[ $field ] = trim( (string) ( $posted_creds[ $field ] ?? '' ) );
180 }
181
182 // Step 4: the shared record-scoped test sequence (payload guard + PATCH
183 // + #276 contract). The record does not exist yet, so its echo-guarded
184 // registry writes are no-ops here — the block is re-applied after save.
185 $result = mlsimport_connections_patch_test( $adapter, $flat_creds, $mls_id );
186
187 // Step 5: anything but a confirmed test saves nothing.
188 if ( $result['refused'] ) {
189 return 'missing_credentials' === ( $result['error']['code'] ?? '' )
190 ? $refused( 'missing_credentials', esc_html__( 'Please fill in every credential field for this MLS.', 'mlsimport' ) )
191 : $refused( 'unsupported', (string) ( $result['error']['message'] ?? '' ) );
192 }
193 $answer = $result['answer'];
194 if ( mlsimport_response_not_entitled( $answer ) ) {
195 return $refused( 'not_entitled', esc_html__( 'Your mlsimport.com plan does not include this MLS — upgrade your plan or contact support.', 'mlsimport' ) );
196 }
197 if ( ! $result['tested_ok'] ) {
198 $api_message = is_array( $answer ) ? trim( (string) ( $answer['error_message'] ?? '' ) ) : '';
199 return $refused(
200 'test_failed',
201 '' !== $api_message
202 ? $api_message
203 : esc_html__( 'The connection test failed. Please check the credentials your MLS provided and try again.', 'mlsimport' )
204 );
205 }
206
207 // Step 6: the test passed — the connection becomes real now.
208 $record = mlsimport_connections_add_record( $mls_id, $mls_name, $adapter->type(), $flat_creds, $adapter->credential_fields() );
209 Mlsimport_Connections::save( $record );
210 if ( isset( $answer['mls_data'] ) && is_array( $answer['mls_data'] ) ) {
211 mlsimport_apply_client_block( $answer['mls_data'], $mls_id );
212 }
213 mlsimport_connection_record_test_result( $mls_id, true );
214
215 // Step 6b: an install with no current connection yet (first MLS added
216 // through the drawer — since the tab consolidation the drawer is the
217 // settings page's only add path) promotes this connection to current by
218 // mirroring its identity + credentials into the flat options, which every
219 // legacy read path (imports, cron, token refresh) resolves current from.
220 // The just-passed test also confirms the global connection flag.
221 if ( 0 === mlsimport_current_mls_id() ) {
222 $options = get_option( 'mlsimport_admin_options', array() );
223 update_option(
224 'mlsimport_admin_options',
225 mlsimport_connections_flat_sync( is_array( $options ) ? $options : array(), $record, $flat_creds )
226 );
227 update_option( 'mlsimport_connection_test', 'yes' );
228 }
229
230 // Step 7: the connection is saved — say so now. Seeding is a separate
231 // request (mlsimport-connections-seed.php).
232 return array(
233 'success' => true,
234 'code' => '',
235 'message' => '',
236 );
237 }
238
239 /**
240 * AJAX: the Add-MLS drawer's single submit ("Test & save connection").
241 *
242 * Step by step:
243 * 1. Nonce (the Connections screen nonce) + administrator capability.
244 * 2. Read the posted MLS id, display name, and flat credential values —
245 * credential values are unslashed + trimmed ONLY, never sanitized (#204);
246 * the flow core whitelists them against the adapter's declared fields.
247 * 3. Run the flow core and translate its outcome to the JSON response the
248 * drawer JS repaints from.
249 *
250 * @return void
251 */
252 function mlsimport_ajax_connections_add() {
253 // Step 1: security.
254 check_ajax_referer( 'mlsimport_connections_screen', 'security' );
255 if ( ! current_user_can( 'administrator' ) ) {
256 wp_send_json_error( array( 'message' => 'Unauthorized' ) );
257 }
258
259 // Step 2: posted drawer values.
260 $mls_id = isset( $_POST['mls_id'] ) ? (int) $_POST['mls_id'] : 0;
261 $mls_name = isset( $_POST['mls_name'] ) ? sanitize_text_field( wp_unslash( $_POST['mls_name'] ) ) : '';
262 $posted_creds = isset( $_POST['creds'] ) && is_array( $_POST['creds'] )
263 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- credential values must reach the SaaS verbatim (#204); the flow core whitelists the keys.
264 ? wp_unslash( $_POST['creds'] )
265 : array();
266
267 // Step 3: run the flow and answer the drawer.
268 $outcome = mlsimport_connections_add_execute( $mls_id, $mls_name, $posted_creds );
269 if ( ! $outcome['success'] ) {
270 wp_send_json_error(
271 array(
272 'code' => $outcome['code'],
273 'message' => $outcome['message'],
274 )
275 );
276 }
277 wp_send_json_success( array( 'mls_id' => $mls_id ) );
278 }
279
280 add_action( 'wp_ajax_mlsimport_connections_add', 'mlsimport_ajax_connections_add' );
281