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

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

332 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 * 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.
114 $listing_key = (string) $property['ListingKey'];
115 $existing = $this->environment->find_listing(
116 $listing_key,
117 $this->adapter->property_post_type()
118 );
119
120 // Compare raw and PrettyEnums status forms through the project's shared
121 // normalizer. A missing status is not selected unless explicitly saved as
122 // an empty status, which the settings normalizer does not produce.
123 $raw_status = $property['StandardStatus'] ?? ( $property['extra_meta']['MlsStatus'] ?? '' );
124 $status = mlsimport_normalize_status_enum( $raw_status );
125 $statuses = array_map(
126 'mlsimport_normalize_status_enum',
127 is_array( $settings['statuses'] ?? null ) ? $settings['statuses'] : array()
128 );
129 if ( ! in_array( $status, $statuses, true ) ) {
130 // Excluded data never creates a local post. When a Managed Listing
131 // already exists, deletion is the complete write: returning here keeps
132 // meta, taxonomy, media, title, and agent stages unreachable.
133 if ( null === $existing ) {
134 return array(
135 'outcome' => 'skipped',
136 'listing_id' => 0,
137 'warnings' => array(),
138 'error' => '',
139 );
140 }
141
142 $listing_id = (int) ( $existing['id'] ?? 0 );
143 if ( $this->environment->delete_listing( $existing ) ) {
144 return array(
145 'outcome' => 'deleted',
146 'listing_id' => $listing_id,
147 'warnings' => array(),
148 'error' => '',
149 '_activity' => 'deleted',
150 );
151 }
152
153 return array(
154 'outcome' => 'failed',
155 'listing_id' => $listing_id,
156 'warnings' => array(),
157 'error' => 'Managed Listing could not be deleted.',
158 );
159 }
160
161 // Avoid the expensive field/media rewrite only when both independent
162 // change signals agree. A missing or unparsable MLS timestamp deliberately
163 // falls through to an update because freshness cannot be proven.
164 if ( null !== $existing ) {
165 $incoming_mod_raw = (string) ( $property['extra_meta']['ModificationTimestamp'] ?? '' );
166 $stored_mod_raw = (string) ( $existing['modification_timestamp'] ?? '' );
167 $config_version = (string) ( $settings['config_version'] ?? '' );
168 $stored_config = (string) ( $existing['config_version'] ?? '' );
169 $incoming_mod = '' !== $incoming_mod_raw ? strtotime( $incoming_mod_raw ) : false;
170 $stored_mod = '' !== $stored_mod_raw ? strtotime( $stored_mod_raw ) : false;
171 if (
172 false !== $incoming_mod &&
173 false !== $stored_mod &&
174 $incoming_mod <= $stored_mod &&
175 '' !== $config_version &&
176 hash_equals( $stored_config, $config_version )
177 ) {
178 return array(
179 'outcome' => 'unchanged',
180 'listing_id' => (int) ( $existing['id'] ?? 0 ),
181 'warnings' => array(),
182 'error' => '',
183 );
184 }
185 }
186
187 return $this->persist_required_write( $property, $settings, $existing, $listing_key );
188 }
189
190 /**
191 * Persist the create/update transition as one required unit of work.
192 *
193 * The environment starts a database transaction before the base post changes.
194 * Common data and the real theme projection must both succeed before commit;
195 * otherwise rollback removes a new partial post or restores the old version.
196 *
197 * @param array<string, mixed> $property Incoming raw MLS property.
198 * @param array<string, mixed> $settings Normalized Import Task choices.
199 * @param array<string, mixed>|null $existing Previous listing, or null for create.
200 * @param string $listing_key Stable MLS identity.
201 * @return array<string, mixed> Created, updated, or failed result.
202 */
203 private function persist_required_write( array $property, array $settings, ?array $existing, string $listing_key ): array {
204 $is_new = null === $existing;
205 $listing_id = $is_new ? 0 : (int) ( $existing['id'] ?? 0 );
206 $warnings = array();
207 $media_stage = null;
208 $gallery_switched = false;
209 $this->environment->begin_write();
210
211 try {
212 if ( $is_new ) {
213 // New listings receive all creation-time defaults from the task.
214 $listing_id = $this->environment->create_listing(
215 array(
216 'listing_key' => $listing_key,
217 'post_type' => $this->adapter->property_post_type(),
218 'post_status' => (string) ( $settings['post_status'] ?? 'publish' ),
219 'user_id' => (int) ( $settings['user_id'] ?? 0 ),
220 'assigned_agent_id' => (int) ( $settings['assigned_agent_id'] ?? 0 ),
221 'content' => (string) ( $property['content'] ?? '' ),
222 'task_id' => (int) ( $settings['task_id'] ?? 0 ),
223 )
224 );
225 } else {
226 // Existing listings preserve post_status and Assigned Agent while the
227 // author, content, and display-source choice remain live task data.
228 $updated = $this->environment->update_listing(
229 $existing,
230 array(
231 'user_id' => (int) ( $settings['user_id'] ?? 0 ),
232 'use_mls_agent' => ! empty( $settings['use_mls_agent'] ),
233 'content' => (string) ( $property['content'] ?? '' ),
234 )
235 );
236 if ( ! $updated ) {
237 throw new RuntimeException( 'Managed Listing could not be updated.' );
238 }
239 }
240
241 if ( $listing_id <= 0 ) {
242 throw new RuntimeException( 'Managed Listing could not be created.' );
243 }
244 $field_projection = $this->fields->prepare(
245 $property,
246 is_array( $settings['field_configuration'] ?? null )
247 ? $settings['field_configuration']
248 : array()
249 );
250 if ( ! $this->environment->write_required_data( $listing_id, $property, $field_projection, $settings ) ) {
251 throw new RuntimeException( 'Required listing data could not be saved.' );
252 }
253
254 $context = array(
255 'is_new' => $is_new,
256 'assigned_agent_id' => (int) ( $settings['assigned_agent_id'] ?? 0 ),
257 'use_mls_agent' => ! empty( $settings['use_mls_agent'] ),
258 'fields' => $field_projection['theme_fields'],
259 'field_configuration' => is_array( $settings['field_configuration'] ?? null )
260 ? $settings['field_configuration']
261 : array(),
262 );
263 if ( ! $this->adapter->write_theme_projection( $listing_id, $property, $context ) ) {
264 throw new RuntimeException( 'Theme listing data could not be saved.' );
265 }
266
267 // Stage every replacement before changing the adapter's gallery. The
268 // boundary returns only successful attachment IDs in feed order plus
269 // photo-specific warnings; those warnings never invalidate core data.
270 if ( isset( $property['Media'] ) && is_array( $property['Media'] ) ) {
271 $ordered_media = $property['Media'];
272 usort(
273 $ordered_media,
274 static function ( array $left, array $right ): int {
275 return (int) ( $left['Order'] ?? PHP_INT_MAX ) <=> (int) ( $right['Order'] ?? PHP_INT_MAX );
276 }
277 );
278 $media_stage = $this->environment->stage_media( $listing_id, $ordered_media );
279 $warnings = is_array( $media_stage['warnings'] ?? null )
280 ? array_values( $media_stage['warnings'] )
281 : array();
282 $attachment_ids = is_array( $media_stage['attachment_ids'] ?? null )
283 ? array_map( 'intval', array_values( $media_stage['attachment_ids'] ) )
284 : array();
285
286 // An empty successful set never clears the old gallery. At least one
287 // replacement is required before the adapter sees a new final list.
288 if ( ! empty( $media_stage['changed'] ) && ! empty( $attachment_ids ) ) {
289 if ( ! $this->adapter->write_gallery( $listing_id, $attachment_ids ) ) {
290 throw new RuntimeException( 'Theme gallery could not be saved.' );
291 }
292 $this->environment->activate_media( $listing_id, $media_stage );
293 $gallery_switched = true;
294 }
295 }
296
297 $this->environment->commit_write();
298 if ( $gallery_switched && is_array( $media_stage ) ) {
299 // Old MLS attachments are removed only after the new gallery is durable.
300 // Cleanup can no longer invalidate that committed listing, so surface a
301 // warning and leave the replacement gallery in its successful state.
302 try {
303 $this->environment->finish_media( $listing_id, $media_stage );
304 } catch ( Throwable $cleanup_exception ) {
305 $warnings[] = $cleanup_exception->getMessage();
306 }
307 }
308 return array(
309 'outcome' => empty( $warnings ) ? ( $is_new ? 'created' : 'updated' ) : 'saved-with-warnings',
310 'listing_id' => $listing_id,
311 'warnings' => $warnings,
312 'error' => '',
313 '_activity' => $is_new ? 'created' : 'updated',
314 );
315 } catch ( Throwable $exception ) {
316 // Restore required database state before removing staged resources.
317 // Performing attachment deletion inside the failed transaction would
318 // itself be undone by rollback and could leave staged files orphaned.
319 $this->environment->rollback_write();
320 if ( is_array( $media_stage ) ) {
321 $this->environment->discard_media( $media_stage );
322 }
323 return array(
324 'outcome' => 'failed',
325 'listing_id' => $listing_id,
326 'warnings' => array(),
327 'error' => $exception->getMessage(),
328 );
329 }
330 }
331 }
332