PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.1.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.1.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 / class-mlsimport-reconciliation-wordpress-environment.php

class-mlsimport-reconciliation-wordpress-environment.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.1.1, at includes/class-mlsimport-reconciliation-wordpress-environment.php

311 lines 11.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WordPress infrastructure adapter for the deep reconciliation module.
4 *
5 * The policy and run sequence live in Mlsimport_Reconciliation. This adapter
6 * contains the system-boundary work that policy needs: the singleton option
7 * lock, import/sync activity detection, SaaS snapshot callback, batched SQL
8 * reads of Managed Listings, raw-SQL deletion cleanup, activity recording, and
9 * deduplicated WordPress retry scheduling.
10 *
11 * Keeping these operations behind the module's environment contract lets tests
12 * exercise the one public reconciliation seam while production retains the raw
13 * SQL performance required for large listing inventories.
14 *
15 * @package MLSImport
16 */
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 require_once __DIR__ . '/class-mlsimport-reconciliation.php';
23
24 /**
25 * Connects the reconciliation policy to WordPress and MLSImport infrastructure.
26 */
27 final class Mlsimport_Reconciliation_WordPress_Environment implements Mlsimport_Reconciliation_Environment {
28 private const LOCK_OPTION = 'mlsimport_reconciliation_running';
29 private const RETRY_HOOK = 'mlsimport_reconciliation_retry_event';
30 private const READ_BATCH = 1000;
31
32 /**
33 * Callback that performs the external SaaS request.
34 *
35 * @var callable
36 */
37 private $snapshot_fetcher;
38
39 /**
40 * Unique token owned by this environment after a successful lock claim.
41 *
42 * @var string
43 */
44 private $lock_token = '';
45
46 /**
47 * Receive the SaaS fetch at the external API boundary.
48 *
49 * @param callable $snapshot_fetcher Returns the raw reconciliation response.
50 */
51 public function __construct( callable $snapshot_fetcher ) {
52 $this->snapshot_fetcher = $snapshot_fetcher;
53 }
54
55 /**
56 * Atomically claim the singleton run lock with add_option().
57 *
58 * @return bool True only for the process that created the option.
59 */
60 public function acquire_lock(): bool {
61 $this->lock_token = wp_generate_uuid4();
62 if ( add_option( self::LOCK_OPTION, $this->lock_token, '', false ) ) {
63 return true;
64 }
65
66 $this->lock_token = '';
67 return false;
68 }
69
70 /**
71 * Release only the lock token owned by this environment instance.
72 *
73 * @return void
74 */
75 public function release_lock(): void {
76 if ( '' !== $this->lock_token && get_option( self::LOCK_OPTION ) === $this->lock_token ) {
77 delete_option( self::LOCK_OPTION );
78 }
79 $this->lock_token = '';
80 }
81
82 /**
83 * Detect the existing hourly-sync lock or any manual import still started.
84 *
85 * @return bool True when reconciliation must postpone.
86 */
87 public function import_or_sync_is_active(): bool {
88 if ( get_transient( 'mlsimport_cron_running' ) ) {
89 return true;
90 }
91
92 global $wpdb;
93 // Intentional uncached lock-state read at the infrastructure boundary.
94 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
95 $active_import = $wpdb->get_var(
96 $wpdb->prepare(
97 "SELECT posts.ID
98 FROM {$wpdb->posts} posts
99 INNER JOIN {$wpdb->postmeta} state
100 ON posts.ID = state.post_id AND state.meta_key = %s
101 WHERE posts.post_type = 'mlsimport_item'
102 AND posts.post_status != 'trash'
103 AND state.meta_value = %s
104 LIMIT 1",
105 'mlsimport_spawn_status',
106 'started'
107 )
108 );
109
110 return ! empty( $active_import );
111 }
112
113 /**
114 * Fetch the raw response from the configured SaaS boundary.
115 *
116 * @return array<string, mixed> Raw snapshot response.
117 * @throws RuntimeException When the boundary does not return an array.
118 */
119 public function fetch_snapshot(): array {
120 $result = call_user_func( $this->snapshot_fetcher );
121 if ( ! is_array( $result ) ) {
122 throw new RuntimeException( 'Reconciliation snapshot response was not an array.' );
123 }
124 return $result;
125 }
126
127 /**
128 * Read all active Managed Listings in stable primary-key batches.
129 *
130 * Ownership requires the creating `MLSimport_item_inserted` marker. Draft and
131 * trash posts are excluded in SQL. The creating task and its Protected
132 * Statuses are joined into each row; status is read only when protection
133 * exists because unprotected absence needs no status lookup.
134 *
135 * @return array<int, array<string, mixed>> Complete Managed Listing inventory.
136 * @throws RuntimeException When a database batch cannot be read completely.
137 */
138 public function read_managed_listings(): array {
139 global $wpdb;
140
141 $listings = array();
142 $last_id = 0;
143 $fields = mlsimport_active_field_configuration();
144 $tax_map = isset( $fields['mls-fields-map-taxonomy'] ) ? $fields['mls-fields-map-taxonomy'] : array();
145
146 do {
147 $wpdb->last_error = '';
148 // Intentional batched raw read; ADR-0010 keeps this path SQL-first.
149 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
150 $rows = $wpdb->get_results(
151 $wpdb->prepare(
152 "SELECT posts.ID,
153 listing_key.meta_value AS listing_key,
154 owner.meta_value AS import_task_id,
155 task.ID AS readable_task_id,
156 MAX(protection.meta_value) AS protected_statuses
157 FROM {$wpdb->posts} posts
158 INNER JOIN {$wpdb->postmeta} listing_key
159 ON posts.ID = listing_key.post_id AND listing_key.meta_key = %s
160 INNER JOIN {$wpdb->postmeta} owner
161 ON posts.ID = owner.post_id AND owner.meta_key = %s AND owner.meta_value != ''
162 LEFT JOIN {$wpdb->posts} task
163 ON task.ID = CAST(owner.meta_value AS UNSIGNED)
164 AND task.post_type = 'mlsimport_item' AND task.post_status != 'trash'
165 LEFT JOIN {$wpdb->postmeta} protection
166 ON protection.post_id = task.ID AND protection.meta_key = %s
167 WHERE posts.ID > %d
168 AND posts.post_status NOT IN ('draft', 'trash')
169 GROUP BY posts.ID, listing_key.meta_value, owner.meta_value, task.ID
170 ORDER BY posts.ID ASC
171 LIMIT %d",
172 '_mlsimport_listing_key',
173 'MLSimport_item_inserted',
174 'mlsimport_item_standardstatusprotect',
175 $last_id,
176 self::READ_BATCH
177 ),
178 ARRAY_A
179 );
180
181 if ( '' !== $wpdb->last_error || ! is_array( $rows ) ) {
182 throw new RuntimeException( 'Unable to read the complete Managed Listing inventory.' );
183 }
184
185 foreach ( $rows as $row ) {
186 $last_id = (int) $row['ID'];
187 $protected = maybe_unserialize( $row['protected_statuses'] );
188 $protected = is_array( $protected ) ? $protected : ( '' === (string) $protected ? array() : array( $protected ) );
189 $protected = array_values( array_filter( array_map( 'mlsimport_normalize_status_enum', $protected ) ) );
190
191 $status = '';
192 if ( ! empty( $protected ) ) {
193 $status = mlsimport_read_property_status( $last_id, $tax_map );
194 }
195
196 $listings[] = array(
197 'id' => $last_id,
198 'listing_key' => (string) $row['listing_key'],
199 'import_task_id' => (int) $row['import_task_id'],
200 'import_task_exists' => ! empty( $row['readable_task_id'] ),
201 'protected_statuses' => $protected,
202 'status_readable' => empty( $protected ) || '' !== $status,
203 'status' => $status,
204 );
205 }
206 $row_count = count( $rows );
207 } while ( self::READ_BATCH === $row_count );
208
209 return $listings;
210 }
211
212 /**
213 * Delete one validated Managed Listing with the intentional raw-SQL path.
214 *
215 * Attachments and term relationships use WordPress cleanup APIs. Plugin-owned
216 * standalone rows, comments, postmeta, child posts, and the property post are
217 * then removed before a successful Import History row is recorded.
218 *
219 * @param int $listing_id Property post ID.
220 * @param string $listing_key Expected ListingKey from the completed plan.
221 * @param string $reason Stable successful-deletion reason code.
222 * @return bool True only when the property post was removed.
223 */
224 public function delete_managed_listing( int $listing_id, string $listing_key, string $reason ): bool {
225 global $mlsimport, $wpdb;
226
227 $post_type = get_post_type( $listing_id );
228 $owner_id = (int) get_post_meta( $listing_id, 'MLSimport_item_inserted', true );
229 $live_key = (string) get_post_meta( $listing_id, '_mlsimport_listing_key', true );
230 if ( ! $post_type || $owner_id <= 0 || '' === $live_key || $live_key !== $listing_key ) {
231 return false;
232 }
233
234 $allowed_types = array( 'estate_property', 'property', 'mlsimport_property' );
235 if ( isset( $mlsimport->admin->env_data ) && method_exists( $mlsimport->admin->env_data, 'get_property_post_type' ) ) {
236 $allowed_types[] = $mlsimport->admin->env_data->get_property_post_type();
237 }
238 if ( ! in_array( $post_type, array_unique( $allowed_types ), true ) ) {
239 return false;
240 }
241
242 // GitHub issue #287: capture the attachment IDs BEFORE any deletion —
243 // they are found by post_parent, which is gone once the post row is —
244 // but do NOT delete them yet. File deletion is the only irreversible
245 // step, so it must come last: after the post row is confirmed gone.
246 $attachments = get_posts(
247 array(
248 'numberposts' => -1,
249 'post_type' => 'attachment',
250 'post_parent' => $listing_id,
251 'post_status' => 'any',
252 'fields' => 'ids',
253 )
254 );
255
256 if ( class_exists( 'Mlsimport_Standalone_Row' ) ) {
257 Mlsimport_Standalone_Row::purge_post_relations( $listing_id );
258 }
259 wp_delete_object_term_relationships( $listing_id, get_object_taxonomies( $post_type ) );
260
261 // Intentional raw cleanup sequence required by ADR-0008. The child-row
262 // deletes exclude attachments: their rows and meta must survive this
263 // step so wp_delete_attachment() below can still remove their files.
264 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
265 $comments_deleted = $wpdb->query(
266 $wpdb->prepare(
267 "DELETE commentmeta FROM {$wpdb->commentmeta} commentmeta
268 INNER JOIN {$wpdb->comments} comments ON commentmeta.comment_id = comments.comment_ID
269 WHERE comments.comment_post_ID = %d",
270 $listing_id
271 )
272 );
273 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
274 $comments = $wpdb->delete( $wpdb->comments, array( 'comment_post_ID' => $listing_id ), array( '%d' ) );
275 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
276 $meta = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE post_id = %d OR post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_parent = %d AND post_type != 'attachment')", $listing_id, $listing_id ) );
277 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
278 $posts = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->posts} WHERE (post_parent = %d AND post_type != 'attachment') OR ID = %d", $listing_id, $listing_id ) );
279
280 if ( false === $comments_deleted || false === $comments || false === $meta || false === $posts || $posts < 1 ) {
281 // Nothing irreversible has happened: the listing (and every
282 // attachment file) is fully intact and the next run retries it.
283 return false;
284 }
285
286 // The post row is durably gone; deleting the now-orphaned attachments
287 // (rows, meta, and files) can no longer strand a visible listing. A
288 // failure here leaves only an invisible orphaned attachment.
289 foreach ( $attachments as $attachment_id ) {
290 wp_delete_attachment( (int) $attachment_id, true );
291 }
292
293 clean_post_cache( $listing_id );
294 mlsimport_record_activity( 'deleted', $listing_id, $listing_key, $owner_id, 'reconciliation', '', '', $reason );
295 mlsimport_telemetry_bump( 'deleted' );
296 return true;
297 }
298
299 /**
300 * Schedule one deduplicated single retry after the requested delay.
301 *
302 * @param int $delay_seconds Delay from current time.
303 * @return void
304 */
305 public function schedule_retry( int $delay_seconds ): void {
306 if ( false === wp_next_scheduled( self::RETRY_HOOK ) ) {
307 wp_schedule_single_event( time() + $delay_seconds, self::RETRY_HOOK );
308 }
309 }
310 }
311