PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2
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 6.0.7 All 35 releases
mlsimport / includes / mlsimport-task-binding.php

mlsimport-task-binding.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.2, at includes/mlsimport-task-binding.php

236 lines 9.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Import task connection binding + cron isolation (issue #277, spec #273/#265).
4 *
5 * WHY THIS FILE EXISTS
6 * --------------------
7 * With multi-MLS support every Import Task (mlsimport_item) belongs to exactly
8 * ONE connection for its whole life, stamped at creation in the post meta
9 * 'mlsimport_item_mls_id' (decision #265 — a task's filters are enum values
10 * from one specific MLS, so re-binding would produce garbage by construction;
11 * targeting another MLS means creating a new task).
12 *
13 * This module owns everything about that binding:
14 * - resolving a task's mls_id (with the legacy current-connection fallback
15 * for unstamped tasks, so pre-multi-MLS installs behave exactly as before),
16 * - stamping the binding once at creation (picker choice, single-connection
17 * auto-set, or current-connection fallback),
18 * - the per-task connection gate the hourly cron uses for failure isolation:
19 * tasks on a broken connection skip WITH a recorded reason while tasks on
20 * healthy connections keep importing in the same run,
21 * - the per-connection sync-failure record (telemetry scoped by mls_id),
22 * - mirroring the connection-test result into the registry record's status,
23 * which is what the gate reads for non-current connections.
24 *
25 * The task-scoped Field Configuration read lives where the projection cache
26 * already lives: mlsimport_active_field_configuration( $refresh, $mls_id ).
27 *
28 * @since 7.2.0
29 * @package Mlsimport
30 */
31
32 if ( ! defined( 'ABSPATH' ) ) {
33 exit;
34 }
35
36 /**
37 * Resolve the mls_id an Import Task is bound to.
38 *
39 * Step by step:
40 * 1. A stamped task returns its own binding — never the global selection.
41 * 2. An unstamped task (created before multi-MLS, or programmatically) falls
42 * back to the current connection, which is exactly what such a task has
43 * always imported from. The #274 migration stamps existing tasks, so this
44 * fallback only carries genuinely legacy cases.
45 *
46 * @param int $task_id Import Task post id.
47 * @return int The bound mls_id, or 0 when the install is unconfigured.
48 */
49 function mlsimport_task_mls_id( int $task_id ): int {
50 // Step 1: the task's own stamp wins.
51 $stamped = (int) get_post_meta( $task_id, 'mlsimport_item_mls_id', true );
52 if ( $stamped > 0 ) {
53 return $stamped;
54 }
55
56 // Step 2: legacy fallback — the current connection.
57 return mlsimport_current_mls_id();
58 }
59
60 /**
61 * Bind a task to one connection, exactly once (immutable afterwards).
62 *
63 * Step by step:
64 * 1. A task that is already bound keeps its binding — the posted value is
65 * ignored, so no form manipulation can re-bind a task.
66 * 2. A requested id is honored only when it names a REGISTERED connection
67 * (the metabox picker only offers registered ones).
68 * 3. Without a valid request: exactly one registered connection auto-binds
69 * (no picker was shown); otherwise the current connection is stamped —
70 * the pre-#271 case where the registry does not mirror the configured MLS.
71 * 4. Stamp only a positive id; an unconfigured install stays unstamped and
72 * resolves through the fallback in mlsimport_task_mls_id() until an MLS
73 * is configured.
74 *
75 * @param int $task_id Import Task post id.
76 * @param int $requested_mls_id Connection chosen in the metabox picker (0 = none).
77 * @return int The task's binding after the call (0 only when unconfigured).
78 */
79 function mlsimport_bind_task_connection( int $task_id, int $requested_mls_id = 0 ): int {
80 // Step 1: bound is bound — for life.
81 $existing = (int) get_post_meta( $task_id, 'mlsimport_item_mls_id', true );
82 if ( $existing > 0 ) {
83 return $existing;
84 }
85
86 // Step 2: a picker choice must be a registered connection.
87 $connections = Mlsimport_Connections::all();
88 if ( $requested_mls_id > 0 && isset( $connections[ $requested_mls_id ] ) ) {
89 $mls_id = $requested_mls_id;
90 } elseif ( 1 === count( $connections ) ) {
91 // Step 3a: exactly one connection — auto-set, no picker existed.
92 $mls_id = (int) array_key_first( $connections );
93 } else {
94 // Step 3b: no usable request — the current connection.
95 $mls_id = mlsimport_current_mls_id();
96 }
97
98 // Step 4: persist only a real binding.
99 if ( $mls_id > 0 ) {
100 update_post_meta( $task_id, 'mlsimport_item_mls_id', $mls_id );
101 }
102
103 return $mls_id;
104 }
105
106 /**
107 * Decide, for ONE task's connection, whether the hourly cron may import it.
108 *
109 * Pure function (no WordPress reads) so the isolation rule is unit-testable.
110 *
111 * The rule, one case per connection kind:
112 * - The CURRENT connection keeps today's exact behavior: the global
113 * 'mlsimport_connection_test' flag decides (the admin screens maintain it,
114 * and the registry record may not even exist before #271 populates it).
115 * - Any OTHER connection is gated by its own registry record:
116 * 'yes' (tested OK) imports; '' (untested) or 'not_entitled' (rejected by
117 * the SaaS, #276) skips with the matching failure code.
118 * - A connection that no longer exists in the registry is a health-incident
119 * skip ('skip_missing') — a task is NEVER silently re-defaulted to another
120 * connection (decision #265).
121 *
122 * @param array|null $record The connection's registry record, or null.
123 * @param bool $is_current_mls Whether the task's MLS is the currently selected one.
124 * @param string $global_connection_test The global mlsimport_connection_test flag.
125 * @return array{action: string, code: string} action ∈ import|skip_disconnected|skip_missing.
126 */
127 function mlsimport_task_connection_gate( ?array $record, bool $is_current_mls, string $global_connection_test ): array {
128 // The current connection: unchanged single-MLS rule.
129 if ( $is_current_mls ) {
130 return 'yes' === $global_connection_test
131 ? array( 'action' => 'import', 'code' => '' )
132 : array( 'action' => 'skip_disconnected', 'code' => 'mls_not_connected' );
133 }
134
135 // A deleted/unregistered connection: health incident, never a re-default.
136 if ( null === $record ) {
137 return array( 'action' => 'skip_missing', 'code' => 'connection_missing' );
138 }
139
140 // Any other connection: its own record status decides.
141 if ( 'yes' === (string) ( $record['status'] ?? '' ) ) {
142 return array( 'action' => 'import', 'code' => '' );
143 }
144
145 return array(
146 'action' => 'skip_disconnected',
147 'code' => 'not_entitled' === (string) ( $record['status'] ?? '' ) ? 'not_entitled' : 'mls_not_connected',
148 );
149 }
150
151 /**
152 * Gate one cron task: resolve its connection and apply the pure rule above.
153 *
154 * @param int $task_id Import Task post id.
155 * @return array{action: string, code: string, mls_id: int} Gate result plus the resolved mls_id.
156 */
157 function mlsimport_cron_task_gate( int $task_id ): array {
158 $mls_id = mlsimport_task_mls_id( $task_id );
159
160 $gate = mlsimport_task_connection_gate(
161 Mlsimport_Connections::get( $mls_id ),
162 $mls_id === mlsimport_current_mls_id(),
163 (string) get_option( 'mlsimport_connection_test', '' )
164 );
165
166 $gate['mls_id'] = $mls_id;
167 return $gate;
168 }
169
170 /**
171 * Record one connection's sync failure — scoped by mls_id (decision #265).
172 *
173 * Step by step:
174 * 1. Stamp the legacy GLOBAL failure fields exactly as the old pre-loop
175 * connection bail-out did, so single-MLS heartbeat reporting is unchanged.
176 * 2. Add/overwrite this connection's entry in the 'connection_sync_failures'
177 * telemetry map (mls_id => {at, code}), so a multi-connection site can see
178 * WHICH MLS is failing while the others keep syncing.
179 *
180 * @param int $mls_id The failing connection.
181 * @param string $code Failure class ('mls_not_connected', 'not_entitled', ...).
182 * @return void
183 */
184 function mlsimport_record_connection_sync_failure( int $mls_id, string $code ): void {
185 // Step 1: unchanged global stamps.
186 mlsimport_telemetry_set( 'last_sync_failed', time() );
187 mlsimport_telemetry_set( 'last_sync_failed_code', $code );
188
189 // Step 2: the per-connection record — written through the single map
190 // writer (#283) so this gate and the request choke point stamp the same
191 // 'connection_sync_failures' map the same one way.
192 mlsimport_telemetry_record_connection_sync( $mls_id, false, $code );
193 }
194
195 /**
196 * Mirror a connection-test outcome into the registry record's status field.
197 *
198 * The gate above reads record status for every non-current connection, so the
199 * test that today only writes the global flag must also keep the tested
200 * connection's record truthful.
201 *
202 * Step by step:
203 * 1. No record => no-op. Records are created by migration/#271, never here.
204 * 2. Success => 'yes'. The SaaS validated the credentials against the live
205 * MLS — that also supersedes an earlier not_entitled rejection.
206 * 3. Failure => '' (untested), EXCEPT a standing 'not_entitled' mark: that
207 * rejection is lifted only by re-entitlement (#276 rule), and softening it
208 * to plain "untested" would lose why the connection is skipped.
209 * 4. Every test — pass or fail — stamps tested_at, so the Connections screen
210 * (#280) can show WHEN the status was last checked and tell "never tested"
211 * ('' with no stamp) apart from "the last test failed" ('' with a stamp).
212 *
213 * @param int $mls_id The tested connection.
214 * @param bool $tested_ok Whether the SaaS confirmed the MLS connection works.
215 * @return void
216 */
217 function mlsimport_connection_record_test_result( int $mls_id, bool $tested_ok ): void {
218 // Step 1: only registered connections carry status.
219 $record = Mlsimport_Connections::get( $mls_id );
220 if ( null === $record ) {
221 return;
222 }
223
224 // Step 2+3: decide the new status; keep a not_entitled mark on failure.
225 if ( $tested_ok ) {
226 $record['status'] = 'yes';
227 } elseif ( 'not_entitled' !== $record['status'] ) {
228 $record['status'] = '';
229 }
230
231 // Step 4: a test just ran — stamp the time regardless of the outcome.
232 $record['tested_at'] = time();
233 Mlsimport_Connections::save( $record );
234 }
235
236