PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.1
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 6.0.5 All 36 releases
mlsimport / includes / mlsimport-entitlements.php

mlsimport-entitlements.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.2.1, at includes/mlsimport-entitlements.php

310 lines 12.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Multi-MLS entitlements + MLS-scoped SaaS response handling (issue #276).
4 *
5 * WHY THIS FILE EXISTS
6 * --------------------
7 * Decision #268 made the SaaS contract additive for N MLS entitlements:
8 *
9 * - GET clients keeps returning 'mls_data' (the primary MLS, for legacy
10 * plugins) and ADDS 'mls_entitlements' — an array of per-MLS config blocks,
11 * each shaped like today's mls_data — plus, for subscription-managed
12 * accounts, a numeric 'mls_entitlement_cap'. The subscription grants a
13 * NUMBER of connections (the customer picks which MLSes in the plugin), so
14 * the number is the cap when present; the array length is the fallback cap
15 * for responses that predate the count field. There is no separate endpoint.
16 * - PATCH clients carries 'mls_id' and returns only that MLS's mls_data block.
17 * - Every MLS-scoped response echoes 'mls_id' back, and one stable rejection
18 * code — 'not_entitled' — is shared by clients, reconciliation and listings.
19 *
20 * This module is the plugin-side consumer of that contract:
21 *
22 * 1. mlsimport_apply_entitlements() — parse mls_entitlements into the
23 * Mlsimport_Connections registry (matching records only) + store the cap.
24 * 2. mlsimport_apply_client_block() — apply a PATCH clients response block
25 * to exactly the requested connection, echo-guarded.
26 * 3. mlsimport_mls_scoped_echo_ok() — the reusable echo guard: any
27 * mls_id-scoped response must echo the requested id back before it may be
28 * used destructively (spirit of "empty status never deletes").
29 * 4. mlsimport_response_not_entitled()/mark/check — detect the stable
30 * rejection, mark that one connection, and let import paths skip it
31 * without affecting any other connection.
32 * 5. mlsimport_refresh_entitlements() — re-ask the SaaS for the account view
33 * and apply it (1). Runs when the install (re)connects to its
34 * mlsimport.com account, so a plan change is picked up by a plain
35 * disconnect + reconnect instead of waiting for a metadata gather.
36 *
37 * A legacy response without 'mls_entitlements' (old API, new plugin) changes
38 * NOTHING here — the plugin degrades to today's single-MLS behavior.
39 *
40 * The 'not_entitled' mark lives in the registry record's existing 'status'
41 * field (values: '' untested, 'yes' tested OK, 'not_entitled' rejected) —
42 * a rejected connection is definitionally not tested-OK, and clearing the
43 * mark back to '' simply requires the normal re-test.
44 *
45 * @since 7.2.0
46 * @package Mlsimport
47 */
48
49 if ( ! defined( 'ABSPATH' ) ) {
50 exit;
51 }
52
53 /**
54 * Echo guard: may an MLS-scoped SaaS response be trusted for the requested MLS?
55 *
56 * Step by step:
57 * 1. The caller must actually have requested a concrete MLS (id > 0).
58 * 2. The response must be an array that carries an 'mls_id' echo at its top
59 * level — a missing echo means a legacy/misrouted full-account response.
60 * 3. The echoed id must equal the requested id exactly (numeric compare, so
61 * "103" echoes match the int 103 request).
62 *
63 * Refusing here is the safety boundary that keeps a misrouted or legacy
64 * account-wide payload from ever being applied to (or deleting from) another
65 * connection.
66 *
67 * @param mixed $answer Decoded SaaS response.
68 * @param int $requested_mls_id The mls_id the request was scoped to.
69 * @return bool True only when the response echoes the requested mls_id.
70 */
71 function mlsimport_mls_scoped_echo_ok( $answer, int $requested_mls_id ): bool {
72 // Step 1: without a concrete requested id there is nothing to verify against.
73 if ( $requested_mls_id <= 0 ) {
74 return false;
75 }
76 // Step 2: a non-array or echo-less response is refused, never assumed.
77 if ( ! is_array( $answer ) || ! isset( $answer['mls_id'] ) ) {
78 return false;
79 }
80 // Step 3: the echo must name exactly the MLS we asked about.
81 return (int) $answer['mls_id'] === $requested_mls_id;
82 }
83
84 /**
85 * Whether a SaaS response is the stable 'not_entitled' rejection.
86 *
87 * The two response shapes the API clients produce/pass through are checked:
88 * globalApiRequestSaas() normalizes errors to a top-level 'error_code', and
89 * globalApiRequestCurlSaas() returns the raw body, whose error convention is
90 * { error: { code, message } }.
91 *
92 * @param mixed $answer Decoded SaaS response.
93 * @return bool True when the response carries the stable rejection code.
94 */
95 function mlsimport_response_not_entitled( $answer ): bool {
96 if ( ! is_array( $answer ) ) {
97 return false;
98 }
99 // Normalized shape from globalApiRequestSaas().
100 if ( 'not_entitled' === ( $answer['error_code'] ?? '' ) ) {
101 return true;
102 }
103 // Raw body shape passed through by globalApiRequestCurlSaas().
104 return 'not_entitled' === ( $answer['error']['code'] ?? '' );
105 }
106
107 /**
108 * The connection cap: how many MLS connections this account is entitled to.
109 *
110 * The cap is what the SaaS last granted: the numeric mls_entitlement_cap the
111 * subscription carries, or — for responses without the count field — the
112 * length of the mls_entitlements array (decision #268: no hard-coded tier
113 * constant lives in the plugin). Before the API ever sends either (old API /
114 * never refreshed) the cap is 1: single-MLS behavior.
115 *
116 * @return int Connection cap, minimum 1.
117 */
118 function mlsimport_entitlement_cap(): int {
119 return max( 1, (int) get_option( 'mlsimport_entitlement_cap', 1 ) );
120 }
121
122 /**
123 * Parse the mls_entitlements array out of a GET clients response and refresh
124 * the connection registry from it.
125 *
126 * Step by step:
127 * 1. Resolve the cap source. The subscription grants a NUMBER of connections
128 * ("2 MLS connections"), not a list of specific MLS ids — nobody knows the
129 * ids at purchase time, the customer picks them in the plugin. So a usable
130 * numeric 'mls_entitlement_cap' (int >= 1) in the response is the cap;
131 * without it the cap falls back to the mls_entitlements array length
132 * (the pre-count contract, kept so an older API stays fully supported).
133 * 2. Legacy degrade: neither a usable cap number nor an array → return
134 * without touching anything (old API, new plugin: single-MLS behavior).
135 * 3. Store the cap.
136 * 4. For each entitlement block with a usable mls_id, refresh ONLY an already
137 * registered matching connection: provider type + per-MLS config, and clear
138 * a previous 'not_entitled' mark (presence in the array proves entitlement).
139 * Unregistered ids are never auto-created — creating records is the
140 * Connections UI's job (#271).
141 *
142 * @param mixed $answer Decoded GET clients response.
143 * @return void
144 */
145 function mlsimport_apply_entitlements( $answer ): void {
146 if ( ! is_array( $answer ) ) {
147 return;
148 }
149
150 // Step 1: prefer the subscription's numeric cap; fall back to array length.
151 $has_list = isset( $answer['mls_entitlements'] ) && is_array( $answer['mls_entitlements'] );
152 $raw_cap = $answer['mls_entitlement_cap'] ?? null;
153 $has_cap = is_numeric( $raw_cap ) && (int) $raw_cap >= 1;
154
155 // Step 2: legacy response (neither source) → single-MLS behavior, nothing changes.
156 if ( ! $has_cap && ! $has_list ) {
157 return;
158 }
159
160 // Step 3: store the cap. Non-autoloaded: only admin/import paths ask for it.
161 $cap = $has_cap ? (int) $raw_cap : count( $answer['mls_entitlements'] );
162 if ( false === get_option( 'mlsimport_entitlement_cap', false ) ) {
163 add_option( 'mlsimport_entitlement_cap', $cap, '', 'no' );
164 } else {
165 update_option( 'mlsimport_entitlement_cap', $cap, false );
166 }
167
168 // Step 4: refresh each matching registered connection from its block.
169 if ( $has_list ) {
170 foreach ( $answer['mls_entitlements'] as $block ) {
171 if ( is_array( $block ) && isset( $block['mls_id'] ) ) {
172 mlsimport_entitlement_refresh_record( (int) $block['mls_id'], $block );
173 }
174 }
175 }
176 }
177
178 /**
179 * Re-read the account's entitlements from the SaaS and apply them.
180 *
181 * The cap belongs to the mlsimport.com ACCOUNT, not to any MLS connection,
182 * so the moment the install signs in to that account is the moment to ask
183 * for it again. Without this, an upgraded plan stays invisible on the
184 * Connections screen until Field Mapping happens to gather metadata.
185 *
186 * Step by step:
187 * 1. GET clients?theme_id=<configured theme> — the unscoped account view,
188 * the same call the metadata gather makes (minus the mls_id scope).
189 * 2. Hand the answer to mlsimport_apply_entitlements(): a failed or legacy
190 * answer changes nothing; a usable cap / blocks array refreshes the cap
191 * and the already registered connections.
192 *
193 * @return void
194 */
195 function mlsimport_refresh_entitlements(): void {
196 // Step 1: the account-wide GET clients.
197 $options = get_option( 'mlsimport_admin_options', array() );
198 $options = is_array( $options ) ? $options : array();
199 $answer = ThemeImport::globalApiRequestSaas(
200 'clients?theme_id=' . intval( $options['mlsimport_theme_used'] ?? 0 ),
201 array(),
202 'GET'
203 );
204
205 // Step 2: apply — same degrade rules as every other consumer.
206 mlsimport_apply_entitlements( $answer );
207 }
208
209 /**
210 * Apply a PATCH clients response's mls_data block to one connection record.
211 *
212 * The server scopes the PATCH by the mls_id in the payload and returns that
213 * MLS's block. Before writing anything, the echo guard verifies the block
214 * names the requested MLS — a mismatched or missing echo is refused so a
215 * misrouted/legacy response can never overwrite another connection's config.
216 *
217 * @param mixed $mls_data The response's mls_data block.
218 * @param int $requested_mls_id The mls_id the PATCH was scoped to.
219 * @return bool True when the block was applied to the requested record.
220 */
221 function mlsimport_apply_client_block( $mls_data, int $requested_mls_id ): bool {
222 // Refuse anything that does not echo the requested MLS back.
223 if ( ! mlsimport_mls_scoped_echo_ok( $mls_data, $requested_mls_id ) ) {
224 return false;
225 }
226 // Echo verified: refresh exactly that record (and only if it is registered).
227 return mlsimport_entitlement_refresh_record( $requested_mls_id, $mls_data );
228 }
229
230 /**
231 * Refresh one REGISTERED connection record from a SaaS config block.
232 *
233 * Step by step:
234 * 1. Only an existing registry record is updated — never created here.
235 * 2. The block's provider 'type' becomes the record's provider_type.
236 * 3. The whitelisted scalar per-MLS config keys (same set live mode stores)
237 * become the record's live_config.
238 * 4. A previous 'not_entitled' status mark is cleared: the SaaS returning a
239 * config block for this MLS proves the entitlement again. A 'yes'
240 * (tested OK) status is kept as-is.
241 *
242 * @param int $mls_id MLS whose record to refresh.
243 * @param array $block SaaS config block (mls_data / entitlement entry shape).
244 * @return bool True when a matching record existed and was saved.
245 */
246 function mlsimport_entitlement_refresh_record( int $mls_id, array $block ): bool {
247 // Step 1: unmatched ids are ignored — registry creation belongs to #271.
248 $record = Mlsimport_Connections::get( $mls_id );
249 if ( null === $record ) {
250 return false;
251 }
252
253 // Step 2: the block's saved provider type is authoritative when present.
254 if ( isset( $block['type'] ) && is_scalar( $block['type'] ) && '' !== trim( (string) $block['type'] ) ) {
255 $record['provider_type'] = strtolower( trim( (string) $block['type'] ) );
256 }
257
258 // Step 3: copy only the whitelisted scalar config keys into live_config.
259 $config = array();
260 foreach ( array( 'api_import_url', 'api_token_url', 'api_media_url', 'type', 'expand', 'field_corellation', 'mls_filter_params', 'mls_id' ) as $key ) {
261 if ( isset( $block[ $key ] ) && is_scalar( $block[ $key ] ) ) {
262 $config[ $key ] = (string) $block[ $key ];
263 }
264 }
265 if ( array() !== $config ) {
266 $record['live_config'] = $config;
267 }
268
269 // Step 4: a returned block proves entitlement — lift a not_entitled mark.
270 if ( 'not_entitled' === $record['status'] ) {
271 $record['status'] = '';
272 }
273
274 return Mlsimport_Connections::save( $record );
275 }
276
277 /**
278 * Mark one connection as rejected by the SaaS with the stable 'not_entitled'
279 * code. Only that record changes; every other connection is untouched.
280 *
281 * @param int $mls_id MLS the SaaS rejected.
282 * @return void
283 */
284 function mlsimport_mark_connection_not_entitled( int $mls_id ): void {
285 $record = Mlsimport_Connections::get( $mls_id );
286 // Without a registry record there is nothing to mark; the caller still
287 // surfaces the API error itself, so the rejection is never silent.
288 if ( null === $record ) {
289 return;
290 }
291 $record['status'] = 'not_entitled';
292 Mlsimport_Connections::save( $record );
293 }
294
295 /**
296 * Whether a connection is currently marked 'not_entitled'.
297 *
298 * Import paths call this before contacting the SaaS so a rejected connection
299 * skips its work (and stops hammering the API) while the others run normally.
300 * An unregistered mls_id is NOT considered rejected — fail-open, matching the
301 * live entitlement gate's semantics.
302 *
303 * @param int $mls_id MLS to check.
304 * @return bool True when the registry marks this connection not entitled.
305 */
306 function mlsimport_connection_not_entitled( int $mls_id ): bool {
307 $record = Mlsimport_Connections::get( $mls_id );
308 return null !== $record && 'not_entitled' === $record['status'];
309 }
310