| 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 |
|