| 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 |
|