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 / class-mlsimport-stored-listing-write.php

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

345 lines 13.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Coordinate one Stored mode listing write behind a single public operation.
4 *
5 * ThemeImport passes one raw MLS property and normalized Import Task settings
6 * to this module. The module owns the listing-level decision and returns one
7 * terminal outcome; WordPress persistence and genuine theme projection stay
8 * behind injected boundaries so the orchestration can be tested independently.
9 *
10 * @package MLSImport
11 */
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 require_once __DIR__ . '/mlsimport-status-normalize.php';
18 require_once __DIR__ . '/class-mlsimport-stored-listing-fields.php';
19
20 /**
21 * Apply one incoming MLS property to local Stored mode data.
22 */
23 final class Mlsimport_Stored_Listing_Write {
24
25 /** @var object WordPress persistence boundary. */
26 private $environment;
27
28 /** @var object Explicit adapter for the configured Stored mode theme. */
29 private $adapter;
30
31 /** @var Mlsimport_Stored_Listing_Fields Shared Field Configuration projection. */
32 private $fields;
33
34 /**
35 * Receive the two explicit dependencies needed by every listing write.
36 *
37 * The environment performs WordPress operations while the adapter contains
38 * only the post type and projection rules that genuinely differ by theme.
39 *
40 * @param object $environment WordPress persistence boundary.
41 * @param object $adapter Configured Stored mode theme adapter.
42 */
43 public function __construct( $environment, $adapter ) {
44 $this->environment = $environment;
45 $this->adapter = $adapter;
46 $this->fields = new Mlsimport_Stored_Listing_Fields();
47 }
48
49 /**
50 * Produce and publish one terminal result for an incoming MLS property.
51 *
52 * The before event wraps every attempt. The private transition returns only
53 * after its mutation commits or rolls back; successful Import History is then
54 * recorded before one success, warning, or failure extension event is emitted.
55 *
56 * @param array<string, mixed> $property Raw property returned by MLSImport SaaS.
57 * @param array<string, mixed> $settings Normalized Import Task write settings.
58 * @return array<string, mixed> One public Stored Listing Write result.
59 */
60 public function write( array $property, array $settings ): array {
61 $this->environment->before_write( $property, $settings );
62
63 try {
64 $result = $this->decide_write( $property, $settings );
65 } catch ( Throwable $exception ) {
66 $result = array(
67 'outcome' => 'failed',
68 'listing_id' => 0,
69 'warnings' => array(),
70 'error' => $exception->getMessage(),
71 );
72 }
73
74 $activity = (string) ( $result['_activity'] ?? '' );
75 unset( $result['_activity'] );
76 if ( '' !== $activity ) {
77 $this->environment->record_activity(
78 $activity,
79 (int) ( $result['listing_id'] ?? 0 ),
80 $property,
81 $settings
82 );
83 }
84
85 $event = 'failed' === $result['outcome']
86 ? 'failure'
87 : ( 'saved-with-warnings' === $result['outcome'] ? 'warning' : 'success' );
88 $this->environment->publish_result( $event, $result, $property );
89 return $result;
90 }
91
92 /**
93 * Decide and persist one listing transition without publishing side effects.
94 *
95 * @param array<string, mixed> $property Incoming raw MLS property.
96 * @param array<string, mixed> $settings Normalized Import Task settings.
97 * @return array<string, mixed> Internal result with optional activity marker.
98 */
99 private function decide_write( array $property, array $settings ): array {
100 // ListingKey is the stable identity for lookup, retry, update, and delete.
101 // Reject its absence before either injected dependency can mutate state.
102 if ( empty( $property['ListingKey'] ) ) {
103 return array(
104 'outcome' => 'failed',
105 'listing_id' => 0,
106 'warnings' => array(),
107 'error' => 'ListingKey is missing.',
108 );
109 }
110
111 // Resolve the existing Managed Listing before deciding whether an excluded
112 // status means "skip" or "delete". The adapter supplies only its genuine
113 // storage variation: the property post type. Listing identity is composite
114 // (issue #278): ListingKey is unique only WITHIN one MLS, so the lookup is
115 // qualified by the task connection's mls_id — MLS B's import can never
116 // find (and overwrite or delete) MLS A's same-key listing.
117 $listing_key = (string) $property['ListingKey'];
118 $existing = $this->environment->find_listing(
119 $listing_key,
120 $this->adapter->property_post_type(),
121 (int) ( $settings['mls_id'] ?? 0 )
122 );
123
124 // Compare raw and PrettyEnums status forms through the project's shared
125 // normalizer. A missing status is not selected unless explicitly saved as
126 // an empty status, which the settings normalizer does not produce.
127 $raw_status = $property['StandardStatus'] ?? ( $property['extra_meta']['MlsStatus'] ?? '' );
128 $status = mlsimport_normalize_status_enum( $raw_status );
129 $statuses = array_map(
130 'mlsimport_normalize_status_enum',
131 is_array( $settings['statuses'] ?? null ) ? $settings['statuses'] : array()
132 );
133 if ( ! in_array( $status, $statuses, true ) ) {
134 // Excluded data never creates a local post. When a Managed Listing
135 // already exists, deletion is the complete write: returning here keeps
136 // meta, taxonomy, media, title, and agent stages unreachable.
137 if ( null === $existing ) {
138 return array(
139 'outcome' => 'skipped',
140 'listing_id' => 0,
141 'warnings' => array(),
142 'error' => '',
143 );
144 }
145
146 $listing_id = (int) ( $existing['id'] ?? 0 );
147 if ( $this->environment->delete_listing( $existing ) ) {
148 return array(
149 'outcome' => 'deleted',
150 'listing_id' => $listing_id,
151 'warnings' => array(),
152 'error' => '',
153 '_activity' => 'deleted',
154 );
155 }
156
157 return array(
158 'outcome' => 'failed',
159 'listing_id' => $listing_id,
160 'warnings' => array(),
161 'error' => 'Managed Listing could not be deleted.',
162 );
163 }
164
165 // Avoid the expensive field/media rewrite only when both independent
166 // change signals agree. A missing or unparsable MLS timestamp deliberately
167 // falls through to an update because freshness cannot be proven.
168 //
169 // "Unchanged" skips the projection, taxonomies, title and media — NOT the
170 // small theme meta block. Issue #333: Houzez blanks fave_property_id from
171 // its own save paths (front-end edit, Auto Property ID), and with a full
172 // skip that blank stayed frozen until the MLS touched the listing, while a
173 // manual import (no config_version) healed it — the ticket's exact
174 // symptom. Re-asserting the block every sync is a handful of no-op meta
175 // reads per listing, so the guard keeps its purpose and the site self-heals.
176 if ( null !== $existing ) {
177 $incoming_mod_raw = (string) ( $property['extra_meta']['ModificationTimestamp'] ?? '' );
178 $stored_mod_raw = (string) ( $existing['modification_timestamp'] ?? '' );
179 $config_version = (string) ( $settings['config_version'] ?? '' );
180 $stored_config = (string) ( $existing['config_version'] ?? '' );
181 $incoming_mod = '' !== $incoming_mod_raw ? strtotime( $incoming_mod_raw ) : false;
182 $stored_mod = '' !== $stored_mod_raw ? strtotime( $stored_mod_raw ) : false;
183 if (
184 false !== $incoming_mod &&
185 false !== $stored_mod &&
186 $incoming_mod <= $stored_mod &&
187 '' !== $config_version &&
188 hash_equals( $stored_config, $config_version )
189 ) {
190 $this->environment->reassert_meta( (int) ( $existing['id'] ?? 0 ), $property );
191 return array(
192 'outcome' => 'unchanged',
193 'listing_id' => (int) ( $existing['id'] ?? 0 ),
194 'warnings' => array(),
195 'error' => '',
196 );
197 }
198 }
199
200 return $this->persist_required_write( $property, $settings, $existing, $listing_key );
201 }
202
203 /**
204 * Persist the create/update transition as one required unit of work.
205 *
206 * The environment starts a database transaction before the base post changes.
207 * Common data and the real theme projection must both succeed before commit;
208 * otherwise rollback removes a new partial post or restores the old version.
209 *
210 * @param array<string, mixed> $property Incoming raw MLS property.
211 * @param array<string, mixed> $settings Normalized Import Task choices.
212 * @param array<string, mixed>|null $existing Previous listing, or null for create.
213 * @param string $listing_key Stable MLS identity.
214 * @return array<string, mixed> Created, updated, or failed result.
215 */
216 private function persist_required_write( array $property, array $settings, ?array $existing, string $listing_key ): array {
217 $is_new = null === $existing;
218 $listing_id = $is_new ? 0 : (int) ( $existing['id'] ?? 0 );
219 $warnings = array();
220 $media_stage = null;
221 $gallery_switched = false;
222 $this->environment->begin_write();
223
224 try {
225 if ( $is_new ) {
226 // New listings receive all creation-time defaults from the task.
227 $listing_id = $this->environment->create_listing(
228 array(
229 'listing_key' => $listing_key,
230 'post_type' => $this->adapter->property_post_type(),
231 'post_status' => (string) ( $settings['post_status'] ?? 'publish' ),
232 'user_id' => (int) ( $settings['user_id'] ?? 0 ),
233 'assigned_agent_id' => (int) ( $settings['assigned_agent_id'] ?? 0 ),
234 'content' => (string) ( $property['content'] ?? '' ),
235 'task_id' => (int) ( $settings['task_id'] ?? 0 ),
236 )
237 );
238 } else {
239 // Existing listings preserve post_status and Assigned Agent while the
240 // author, content, and display-source choice remain live task data.
241 $updated = $this->environment->update_listing(
242 $existing,
243 array(
244 'user_id' => (int) ( $settings['user_id'] ?? 0 ),
245 'use_mls_agent' => ! empty( $settings['use_mls_agent'] ),
246 'content' => (string) ( $property['content'] ?? '' ),
247 )
248 );
249 if ( ! $updated ) {
250 throw new RuntimeException( 'Managed Listing could not be updated.' );
251 }
252 }
253
254 if ( $listing_id <= 0 ) {
255 throw new RuntimeException( 'Managed Listing could not be created.' );
256 }
257 $field_projection = $this->fields->prepare(
258 $property,
259 is_array( $settings['field_configuration'] ?? null )
260 ? $settings['field_configuration']
261 : array()
262 );
263 if ( ! $this->environment->write_required_data( $listing_id, $property, $field_projection, $settings ) ) {
264 throw new RuntimeException( 'Required listing data could not be saved.' );
265 }
266
267 $context = array(
268 'is_new' => $is_new,
269 'assigned_agent_id' => (int) ( $settings['assigned_agent_id'] ?? 0 ),
270 'use_mls_agent' => ! empty( $settings['use_mls_agent'] ),
271 'fields' => $field_projection['theme_fields'],
272 'field_configuration' => is_array( $settings['field_configuration'] ?? null )
273 ? $settings['field_configuration']
274 : array(),
275 );
276 if ( ! $this->adapter->write_theme_projection( $listing_id, $property, $context ) ) {
277 throw new RuntimeException( 'Theme listing data could not be saved.' );
278 }
279
280 // Stage every replacement before changing the adapter's gallery. The
281 // boundary returns only successful attachment IDs in feed order plus
282 // photo-specific warnings; those warnings never invalidate core data.
283 if ( isset( $property['Media'] ) && is_array( $property['Media'] ) ) {
284 $ordered_media = $property['Media'];
285 usort(
286 $ordered_media,
287 static function ( array $left, array $right ): int {
288 return (int) ( $left['Order'] ?? PHP_INT_MAX ) <=> (int) ( $right['Order'] ?? PHP_INT_MAX );
289 }
290 );
291 $media_stage = $this->environment->stage_media( $listing_id, $ordered_media );
292 $warnings = is_array( $media_stage['warnings'] ?? null )
293 ? array_values( $media_stage['warnings'] )
294 : array();
295 $attachment_ids = is_array( $media_stage['attachment_ids'] ?? null )
296 ? array_map( 'intval', array_values( $media_stage['attachment_ids'] ) )
297 : array();
298
299 // An empty successful set never clears the old gallery. At least one
300 // replacement is required before the adapter sees a new final list.
301 if ( ! empty( $media_stage['changed'] ) && ! empty( $attachment_ids ) ) {
302 if ( ! $this->adapter->write_gallery( $listing_id, $attachment_ids ) ) {
303 throw new RuntimeException( 'Theme gallery could not be saved.' );
304 }
305 $this->environment->activate_media( $listing_id, $media_stage );
306 $gallery_switched = true;
307 }
308 }
309
310 $this->environment->commit_write();
311 if ( $gallery_switched && is_array( $media_stage ) ) {
312 // Old MLS attachments are removed only after the new gallery is durable.
313 // Cleanup can no longer invalidate that committed listing, so surface a
314 // warning and leave the replacement gallery in its successful state.
315 try {
316 $this->environment->finish_media( $listing_id, $media_stage );
317 } catch ( Throwable $cleanup_exception ) {
318 $warnings[] = $cleanup_exception->getMessage();
319 }
320 }
321 return array(
322 'outcome' => empty( $warnings ) ? ( $is_new ? 'created' : 'updated' ) : 'saved-with-warnings',
323 'listing_id' => $listing_id,
324 'warnings' => $warnings,
325 'error' => '',
326 '_activity' => $is_new ? 'created' : 'updated',
327 );
328 } catch ( Throwable $exception ) {
329 // Restore required database state before removing staged resources.
330 // Performing attachment deletion inside the failed transaction would
331 // itself be undone by rollback and could leave staged files orphaned.
332 $this->environment->rollback_write();
333 if ( is_array( $media_stage ) ) {
334 $this->environment->discard_media( $media_stage );
335 }
336 return array(
337 'outcome' => 'failed',
338 'listing_id' => $listing_id,
339 'warnings' => array(),
340 'error' => $exception->getMessage(),
341 );
342 }
343 }
344 }
345