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.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
← All changes | includes/ThemeImport.php +72 -20 7.17.2.1 View file →
@@ -334,9 +334,10 @@
334 334 * Record the SaaS connection-health state (#208).
335 335 *
336 336 * Stores array{status, since} in the mlsimport_connection_health option:
337 337 * 'healthy', 'credentials_invalid' (server rejected the stored account),
338 - * or 'credentials_missing' (nothing configured). Transient failures such
338 + * 'no_subscription' (password accepted, account not active — #322) or
339 + * 'credentials_missing' (nothing configured). Transient failures such
339 340 * as network timeouts never call this, so a working state is not lost to
340 341 * a hiccup. Re-recording an unchanged status is skipped so 'since' keeps
341 342 * pointing at when the state actually began.
342 343 *
@@ -372,9 +373,12 @@
372 373 * Request a fresh JWT from the SaaS 'token' endpoint and cache it.
373 374 *
374 375 * Reads the stored username/password, POSTs them, and on success stores the
375 376 * token in a transient plus the expiry timestamp in an option. Bumps the
376 - * 'token_failures' telemetry counter on every failure path.
377 + * 'token_failures' telemetry counter on every failure path — WITHOUT a
378 + * connection id (#283): the SaaS JWT is account-level, shared by every
379 + * connection, so its failures belong to no single MLS and count only in
380 + * the global bucket.
377 381 *
378 382 * @return bool True on successful refresh, false otherwise.
379 383 */
380 384 private static function refreshToken() {
@@ -434,21 +438,29 @@
434 438
435 439 // Decode the JSON token response.
436 440 $body = wp_remote_retrieve_body($response);
437 441 $data = json_decode($body, true);
442 + $code = intval( $response['response']['code'] ?? 0 );
438 443
439 444 // Reject any response missing success/token/expires.
440 445 if (!isset($data['success']) || !$data['success'] || !isset($data['token']) || !isset($data['expires'])) {
441 446 mlsimport_telemetry_bump( 'token_failures' );
442 447 delete_option( 'mlsimport_token_refresh_lock' );
443 - // The server answered and said no → the credentials themselves are
444 - // bad (terminal until the user fixes them). A malformed/partial
445 - // body is a server hiccup instead and leaves health untouched.
448 + // The server answered and said no → terminal until the user acts.
449 + // HTTP 403 means the password was right but the account has no
450 + // active subscription (#322); anything else is bad credentials.
451 + // A malformed/partial body is a server hiccup instead and leaves
452 + // health untouched.
446 453 if ( is_array( $data ) && array_key_exists( 'success', $data ) && ! $data['success'] ) {
447 - self::setConnectionHealth( 'credentials_invalid' );
454 + self::setConnectionHealth( 403 === $code ? 'no_subscription' : 'credentials_invalid' );
455 + // Same verdict, remembered for the "not connected" screens.
456 + mlsimport_account_status_record( array( 'success' => false, 'error_code' => $code ) );
448 457 }
449 458 return false;
450 459 }
460 +
461 + // A working login wipes any remembered failure reason (#322).
462 + mlsimport_account_status_record( $data );
451 463
452 464 // Store new token and expiry
453 465 //$mlsimport->admin->mlsimport_saas_store_mls_api_token_transient($data['token']);
454 466
@@ -638,9 +650,12 @@
638 650 }
639 651
640 652 // Only delete when the post is actually a property post type.
641 653 if ($postType === $propertyPostType || in_array($postType, ['estate_property', 'property'])) {
642 - // Delete attachments using WordPress functions so the files are removed as well
654 + // GitHub issue #287: capture the attachment IDs BEFORE any deletion
655 + // (they are found by post_parent, gone once the post row is), but do
656 + // NOT delete them yet. File deletion is the only irreversible step,
657 + // so it runs last — only after the post row is confirmed gone.
643 658 $attachments = get_posts([
644 659 'numberposts' => -1,
645 660 'post_type' => 'attachment',
646 661 'post_parent' => $deleteId,
@@ -647,19 +662,15 @@
647 662 'post_status' => null,
648 663 'fields' => 'ids',
649 664 ]);
650 665
651 - // Remove each attachment (and its underlying file).
652 - foreach ($attachments as $attachmentId) {
653 - wp_delete_attachment($attachmentId, true);
654 - }
655 -
656 666 // Capture the current status term names for the delete log.
657 667 $termObjList = get_the_terms($deleteId, 'property_status');
658 - $deleteIdStatus = join(', ', wp_list_pluck($termObjList, 'name'));
668 + $deleteIdStatus = is_array($termObjList) ? join(', ', wp_list_pluck($termObjList, 'name')) : '';
659 669
660 - // Re-read ListingKey from meta; an empty key means a manually added listing.
661 - $ListingKey = get_post_meta($deleteId, 'ListingKey', true);
670 + // Re-read the identity from protected meta (issue #286); an empty key
671 + // means a manually added listing.
672 + $ListingKey = get_post_meta($deleteId, '_mlsimport_listing_key', true);
662 673 if ('' === $ListingKey) { // manually added listing
663 674 // Never delete user-created listings; log and bail.
664 675 $logEntry = 'User added listing with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL;
665 676 $this->writeImportLogs($logEntry, 'delete');
@@ -665,11 +676,22 @@
665 676 $this->writeImportLogs($logEntry, 'delete');
666 677 return;
667 678 }
668 679
669 - // Log the reconciliation-driven deletion in the activity feed.
670 - mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, intval(get_post_meta($deleteId,'MLSimport_item_inserted',true)), 'reconciliation' );
680 + // Capture the owning task before its meta row is deleted below; the
681 + // success activity entry still needs it afterward.
682 + $ownerTaskId = intval(get_post_meta($deleteId, 'MLSimport_item_inserted', true));
671 683
684 + // Dedupe (issue #282): this raw-SQL path bypasses the WP delete
685 + // hooks, so capture the address group now (meta is gone after the
686 + // raw delete) and re-evaluate it after success — deleting a flagged
687 + // winner must promote its hidden loser.
688 + $dedupeAddressKey = (string) get_post_meta($deleteId, 'mlsimport_address_key', true);
689 + // Telemetry (#283): the deletion counts against the
690 + // listing's OWN connection — read the provenance stamp
691 + // (#278) before the raw delete wipes its meta.
692 + $provenanceMlsId = (int) get_post_meta($deleteId, 'mlsimport_mls_id', true);
693 +
672 694 global $wpdb;
673 695 // Raw SQL delete skips wp_delete_post (too slow), so nothing cleans the
674 696 // property's term relationships, term counts or listings row. Do that
675 697 // cleanup explicitly (SQL-first) before removing the post itself.
@@ -676,13 +698,38 @@
676 698 // Standalone mode: purge the plugin's own term/listings relations first.
677 699 if ( class_exists( 'Mlsimport_Standalone_Row' ) ) {
678 700 Mlsimport_Standalone_Row::purge_post_relations( $deleteId );
679 701 }
680 - // Raw delete of the post's meta, then the post and any remaining children.
702 + // Raw delete of the post's meta, then the post and any remaining
703 + // non-attachment children. Attachment rows and meta must survive this
704 + // step so wp_delete_attachment() below can still remove their files.
681 705 $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->postmeta WHERE `post_id` = %d", $deleteId));
682 - $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE `post_parent` = %d OR `ID` = %d", $deleteId, $deleteId));
683 - mlsimport_telemetry_bump( 'deleted' );
706 + $postsDeleted = $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE (`post_parent` = %d AND `post_type` != 'attachment') OR `ID` = %d", $deleteId, $deleteId));
684 707
708 + // GitHub issue #287: a failed post delete must leave the listing fully
709 + // intact — no attachment deletion, no "deleted" history, no telemetry.
710 + if (false === $postsDeleted || $postsDeleted < 1) {
711 + $logEntry = 'MYSQL DELETE FAILED -> Property with id ' . $deleteId . ' (' . $postType . ') and ' . $ListingKey . ' was NOT deleted; attachments left untouched' . PHP_EOL;
712 + $this->writeImportLogs($logEntry, 'delete');
713 + return;
714 + }
715 +
716 + // The post row is durably gone; removing the now-orphaned attachments
717 + // (rows, meta, and files) can no longer strand a visible listing.
718 + foreach ($attachments as $attachmentId) {
719 + wp_delete_attachment($attachmentId, true);
720 + }
721 +
722 + // Dedupe (issue #282): the post row is durably gone — settle the
723 + // surviving copies of its address group (promote a hidden loser).
724 + if ('' !== $dedupeAddressKey && function_exists('mlsimport_dedupe_evaluate')) {
725 + mlsimport_dedupe_evaluate($dedupeAddressKey, (string) $postType);
726 + }
727 +
728 + // Record the deletion in the activity feed only after it happened.
729 + mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, $ownerTaskId, 'reconciliation' );
730 + mlsimport_telemetry_bump( 'deleted', 1, $provenanceMlsId );
731 +
685 732 $logEntry = 'MYSQL DELETE -> Property with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
686 733 $this->writeImportLogs($logEntry, 'delete');
687 734 }
688 735 }
@@ -722,10 +769,15 @@
722 769 return false;
723 770 }
724 771
725 772 // Translate the shallow legacy option array into the stable module settings.
773 + // The listing's provenance (issue #278) is the task's OWN connection binding
774 + // (#277) read straight from post meta — deliberately NO current-connection
775 + // fallback on the write path (decision #266): an unbound task stamps 0
776 + // rather than silently adopting whichever connection is globally selected.
726 777 $settings = array(
727 778 'task_id' => (int) ( $itemIdArray['item_id'] ?? 0 ),
779 + 'mls_id' => (int) get_post_meta( (int) ( $itemIdArray['item_id'] ?? 0 ), 'mlsimport_item_mls_id', true ),
728 780 'source' => (string) $tipImport,
729 781 'statuses' => is_array( $mlsimportItemOptionData['mlsimport_item_standardstatus'] ?? null )
730 782 ? $mlsimportItemOptionData['mlsimport_item_standardstatus']
731 783 : array(),