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

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

317 lines 12.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Connections screen — data layer (issue #280, decision #271, spec #273).
4 *
5 * WHY THIS FILE EXISTS
6 * --------------------
7 * The Connections tab (settings page, ?tab=connections) shows every registered
8 * MLS connection as one table row: priority, name/provider, status pill with
9 * last-test time, activity counts, and a failure banner under a failing row.
10 * This file owns everything the tab needs that is NOT markup:
11 *
12 * - pure helpers (no WordPress): status derivation for a row, validation +
13 * application of a drag-reorder, and mapping a record's generic-named
14 * credentials back onto the flat option keys a provider adapter declares,
15 * - the assembled screen data (registry rows + activity counts + plan cap +
16 * account state) the partial renders from.
17 *
18 * The AJAX handlers that mutate state (reorder, per-row test, disconnect)
19 * live in mlsimport-connections-ajax.php beside this file. The markup lives in
20 * admin/partials/mlsimport-connections.php.
21 *
22 * @since 7.2.0
23 * @package Mlsimport
24 */
25
26 if ( ! defined( 'ABSPATH' ) ) {
27 exit;
28 }
29
30 /**
31 * Derive one row's status view from its registry record. Pure.
32 *
33 * Step by step:
34 * 1. status 'yes' => connected (green pill, not failing).
35 * 2. status 'not_entitled' => the SaaS rejected this MLS for the account —
36 * failing, shows the amber banner under the row.
37 * 3. status '' with a recorded test time => the last test FAILED (the test
38 * recorder resets a failed connection to '' but stamps tested_at) —
39 * failing, shows the banner.
40 * 4. status '' never tested => neutral "untested": a fresh record that simply
41 * has not been checked yet is not an incident, no banner.
42 *
43 * @param array $record Normalized registry record.
44 * @return array{key:string, class:string, failing:bool} Stable status key
45 * ('connected'|'not_entitled'|'failed'|'untested'), pill class
46 * ('ok'|'bad'|'warn'), and whether the failure banner shows.
47 */
48 function mlsimport_connections_row_status( array $record ): array {
49 // Step 1: a confirmed test is the only green state.
50 if ( 'yes' === ( $record['status'] ?? '' ) ) {
51 return array( 'key' => 'connected', 'class' => 'ok', 'failing' => false );
52 }
53
54 // Step 2: a standing entitlement rejection is always a failure.
55 if ( 'not_entitled' === ( $record['status'] ?? '' ) ) {
56 return array( 'key' => 'not_entitled', 'class' => 'bad', 'failing' => true );
57 }
58
59 // Step 3+4: '' means untested — but a tested_at stamp proves a test ran
60 // and came back negative, which is a real failure, not a blank slate.
61 if ( (int) ( $record['tested_at'] ?? 0 ) > 0 ) {
62 return array( 'key' => 'failed', 'class' => 'bad', 'failing' => true );
63 }
64 return array( 'key' => 'untested', 'class' => 'warn', 'failing' => false );
65 }
66
67 /**
68 * Apply a drag-reorder to the registry records. Pure.
69 *
70 * Step by step:
71 * 1. Validate the posted order: it must name every registered connection
72 * exactly once — an unknown id, a missing id, or a duplicate refuses the
73 * whole reorder (null) so a stale/foreign POST can never scramble
74 * priorities.
75 * 2. Assign priorities 1..N in the posted order; every other record field is
76 * left untouched.
77 *
78 * @param array $ordered_ids mls_ids in the new priority order (1 first).
79 * @param array $connections Registry records keyed by mls_id.
80 * @return array|null Updated records keyed by mls_id, or null when refused.
81 */
82 function mlsimport_connections_apply_order( array $ordered_ids, array $connections ): ?array {
83 // Step 1: exact one-to-one match between posted ids and registered ids.
84 $ordered_ids = array_map( 'intval', $ordered_ids );
85 $posted = $ordered_ids;
86 $registered = array_map( 'intval', array_keys( $connections ) );
87 sort( $posted );
88 sort( $registered );
89 if ( $posted !== $registered ) {
90 return null;
91 }
92
93 // Step 2: position in the posted order IS the new priority.
94 foreach ( $ordered_ids as $position => $mls_id ) {
95 $connections[ $mls_id ]['priority'] = $position + 1;
96 }
97 return $connections;
98 }
99
100 /**
101 * Map a record's generic-named creds onto flat adapter option keys. Pure
102 * (uses only the migration module's suffix rule).
103 *
104 * Provider adapters build their connection-test payload from the FLAT
105 * slot-named options ('mlsimport_tresle_client_id', ...). A non-current
106 * connection's credentials live in its registry record under generic names
107 * ({client_id, client_secret, username, password, mls_token} — decision #263).
108 * This reverses the migration's suffix mapping so the adapter can test any
109 * registered connection without touching the flat options.
110 *
111 * Step by step:
112 * 1. For each flat field the adapter declares, resolve its generic name via
113 * the same suffix rule the migration used to store it.
114 * 2. Take that generic cred's value from the record — trimmed ONLY, never
115 * sanitized (credential values must reach the SaaS verbatim).
116 *
117 * @param array $record Normalized registry record.
118 * @param array $credential_fields Flat option keys the adapter declares.
119 * @return array Flat-option-shaped credentials ('' when the record lacks one).
120 */
121 function mlsimport_connections_credential_options( array $record, array $credential_fields ): array {
122 $creds = is_array( $record['creds'] ?? null ) ? $record['creds'] : array();
123 $options = array();
124
125 foreach ( $credential_fields as $field ) {
126 // Step 1: flat slot-name => generic name (suffix rule, shared helper).
127 $generic = mlsimport_multimls_generic_credential_name( (string) $field );
128 // Step 2: verbatim value, trimmed only.
129 $options[ $field ] = trim( (string) ( $creds[ $generic ] ?? '' ) );
130 }
131
132 return $options;
133 }
134
135 /**
136 * Translate a row-status key into its pill label.
137 *
138 * One map, used by both the partial (initial render) and the test AJAX
139 * response (live pill update) so the two can never drift apart.
140 *
141 * @param string $key Status key from mlsimport_connections_row_status().
142 * @return string Translated pill label.
143 */
144 function mlsimport_connections_status_label( string $key ): string {
145 switch ( $key ) {
146 case 'connected':
147 return __( 'Connected', 'mlsimport' );
148 case 'not_entitled':
149 return __( 'Not entitled', 'mlsimport' );
150 case 'failed':
151 return __( 'Connection failed', 'mlsimport' );
152 default:
153 return __( 'Not tested', 'mlsimport' );
154 }
155 }
156
157 /**
158 * Resolve the settings page's active tab from the requested ?tab= value. Pure.
159 *
160 * The Connections tab is the settings page's single connection surface AND its
161 * default (tab consolidation: the old "MLS Connection" credentials tab was
162 * retired in favor of the Connections screen + Edit drawer). One rule:
163 *
164 * 1. A known live tab (connections, field_options, administrative_options)
165 * passes through.
166 * 2. Everything else — no tab, the retired 'display_options' value (old
167 * bookmarks/links), or junk — resolves to 'connections'.
168 *
169 * @param string $requested The raw (sanitized) ?tab= value, '' when absent.
170 * @return string The tab to render.
171 */
172 function mlsimport_settings_active_tab( string $requested ): string {
173 $live_tabs = array( 'connections', 'field_options', 'administrative_options' );
174 return in_array( $requested, $live_tabs, true ) ? $requested : 'connections';
175 }
176
177 /**
178 * Per-connection activity counts read straight from the database.
179 *
180 * Step by step:
181 * 1. Task count: mlsimport_item posts carrying the #277 binding meta
182 * 'mlsimport_item_mls_id', grouped by the bound mls_id.
183 * 2. Listing count: posts carrying the #278 provenance stamp
184 * 'mlsimport_mls_id', grouped by the stamp (draft/trash excluded — same
185 * visibility rule reconciliation uses).
186 * 3. Last import: the newest 'mlsimport_last_date' task meta (ISO timestamp,
187 * so MAX() on the string is chronological) per bound connection.
188 *
189 * @return array<int, array{tasks:int, listings:int, last_import:string}>
190 * Activity keyed by mls_id (only ids with any activity appear).
191 */
192 function mlsimport_connections_activity(): array {
193 global $wpdb;
194
195 $activity = array();
196
197 // Step 1: bound task counts. Uncached direct reads are intentional here —
198 // this is an admin screen assembling live counts.
199 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
200 $tasks = $wpdb->get_results(
201 "SELECT binding.meta_value AS mls_id, COUNT(*) AS total
202 FROM {$wpdb->postmeta} binding
203 INNER JOIN {$wpdb->posts} posts ON posts.ID = binding.post_id
204 WHERE binding.meta_key = 'mlsimport_item_mls_id'
205 AND posts.post_type = 'mlsimport_item'
206 AND posts.post_status NOT IN ('trash', 'auto-draft')
207 GROUP BY binding.meta_value"
208 );
209 foreach ( $tasks as $row ) {
210 $activity[ (int) $row->mls_id ]['tasks'] = (int) $row->total;
211 }
212
213 // Step 2: stamped listing counts.
214 $listings = $wpdb->get_results(
215 "SELECT provenance.meta_value AS mls_id, COUNT(*) AS total
216 FROM {$wpdb->postmeta} provenance
217 INNER JOIN {$wpdb->posts} posts ON posts.ID = provenance.post_id
218 WHERE provenance.meta_key = 'mlsimport_mls_id'
219 AND posts.post_status NOT IN ('draft', 'trash', 'auto-draft')
220 GROUP BY provenance.meta_value"
221 );
222 foreach ( $listings as $row ) {
223 $activity[ (int) $row->mls_id ]['listings'] = (int) $row->total;
224 }
225
226 // Step 3: newest successful sync per bound connection — over the SAME task
227 // population Step 1 counts, so "2 tasks" and "imported X ago" never come
228 // from different sets (a trashed task must not contribute its last_date).
229 $last = $wpdb->get_results(
230 "SELECT binding.meta_value AS mls_id, MAX(last_date.meta_value) AS latest
231 FROM {$wpdb->postmeta} binding
232 INNER JOIN {$wpdb->posts} posts ON posts.ID = binding.post_id
233 INNER JOIN {$wpdb->postmeta} last_date
234 ON last_date.post_id = binding.post_id AND last_date.meta_key = 'mlsimport_last_date'
235 WHERE binding.meta_key = 'mlsimport_item_mls_id'
236 AND posts.post_type = 'mlsimport_item'
237 AND posts.post_status NOT IN ('trash', 'auto-draft')
238 GROUP BY binding.meta_value"
239 );
240 // phpcs:enable
241 foreach ( $last as $row ) {
242 $activity[ (int) $row->mls_id ]['last_import'] = (string) $row->latest;
243 }
244
245 // Fill defaults so every present id has the full shape.
246 foreach ( $activity as $mls_id => $entry ) {
247 $activity[ $mls_id ] = $entry + array(
248 'tasks' => 0,
249 'listings' => 0,
250 'last_import' => '',
251 );
252 }
253
254 return $activity;
255 }
256
257 /**
258 * Assemble everything the Connections partial renders.
259 *
260 * Step by step:
261 * 1. Registry rows in priority order, each with its derived status view and
262 * activity counts (zeros when a connection has no activity yet).
263 * 2. Account state: connected = a SaaS token exists for the saved account
264 * credentials; the username identifies the account in the summary bar.
265 * 3. Plan slots: used = registered connections, cap = the #276 entitlement
266 * cap (minimum 1), re-read from the SaaS first when the account is
267 * connected. at_cap switches "+ Add MLS" to "Upgrade plan".
268 *
269 * @return array{rows:array, used:int, cap:int, at_cap:bool,
270 * account:array{connected:bool, username:string}}
271 */
272 function mlsimport_connections_screen_data(): array {
273 global $mlsimport;
274
275 // Step 1: one row per registered connection, priority order.
276 $activity = mlsimport_connections_activity();
277 $rows = array();
278 foreach ( Mlsimport_Connections::all() as $mls_id => $record ) {
279 $rows[ $mls_id ] = $record;
280 $rows[ $mls_id ]['status_view'] = mlsimport_connections_row_status( $record );
281 $rows[ $mls_id ]['activity'] = $activity[ $mls_id ] ?? array(
282 'tasks' => 0,
283 'listings' => 0,
284 'last_import' => '',
285 );
286 }
287
288 // Step 2: account state — a non-empty token proves the saved account
289 // credentials authenticate (the getter refreshes from them when needed).
290 $options = get_option( 'mlsimport_admin_options', array() );
291 $options = is_array( $options ) ? $options : array();
292 $token = trim( (string) $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient() );
293
294 // Step 3: plan slot strip numbers. The cap belongs to the mlsimport.com
295 // account and changes without this site doing anything (a plan upgrade
296 // on the portal, a cap granted after the site signed in), so a connected
297 // account re-reads it from the SaaS on every render of this screen
298 // instead of showing whatever was cached at sign-in. A failed or legacy
299 // answer changes nothing (mlsimport_apply_entitlements() degrade rules).
300 if ( '' !== $token ) {
301 mlsimport_refresh_entitlements();
302 }
303 $used = count( $rows );
304 $cap = mlsimport_entitlement_cap();
305
306 return array(
307 'rows' => $rows,
308 'used' => $used,
309 'cap' => $cap,
310 'at_cap' => $used >= $cap,
311 'account' => array(
312 'connected' => '' !== $token,
313 'username' => (string) ( $options['mlsimport_username'] ?? '' ),
314 ),
315 );
316 }
317