| @@ -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(), |