| @@ -1,11 +1,25 @@ | ||
| 1 | 1 | <?php |
| 2 | +/** | |
| 3 | + * Activity log: the custom wp_mlsimport_activity table plus its admin banner. | |
| 4 | + * | |
| 5 | + * Records one row per listing add / edit / delete (with a snapshot of the | |
| 6 | + * listing title, URL, MLS #, status and the owning import task), aggregates the | |
| 7 | + * last 24 hours into a cached summary, renders a dismissible admin banner from | |
| 8 | + * it, and prunes rows older than 30 days on the daily reconciliation cron. The | |
| 9 | + * table is created / migrated via dbDelta() keyed on MLSIMPORT_ACTIVITY_DB_VERSION. | |
| 10 | + * | |
| 11 | + * @package Mlsimport | |
| 12 | + */ | |
| 13 | + | |
| 2 | 14 | if ( ! defined( 'ABSPATH' ) ) { |
| 3 | 15 | exit; |
| 4 | 16 | } |
| 5 | 17 | |
| 6 | 18 | // B1 — Schema version. Bump to trigger dbDelta re-run via mlsimport_maybe_upgrade_activity_table(). |
| 7 | -define( 'MLSIMPORT_ACTIVITY_DB_VERSION', '1.0' ); | |
| 19 | +// 1.1 — added listing_mls_id + listing_status columns (issue #160). | |
| 20 | +// 1.2 adds reason_code for successful reconciliation deletions. | |
| 21 | +define( 'MLSIMPORT_ACTIVITY_DB_VERSION', '1.2' ); | |
| 8 | 22 | |
| 9 | 23 | /** |
| 10 | 24 | * Returns the prefixed activity table name. |
| 11 | 25 | * The ONLY source of the table name — never accept a table name from request input, |
| @@ -34,13 +48,16 @@ | ||
| 34 | 48 | id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, |
| 35 | 49 | action VARCHAR(10) NOT NULL, |
| 36 | 50 | listing_id BIGINT UNSIGNED NOT NULL DEFAULT 0, |
| 37 | 51 | listing_key VARCHAR(191) NOT NULL DEFAULT '', |
| 52 | + listing_mls_id VARCHAR(191) NOT NULL DEFAULT '', | |
| 53 | + listing_status VARCHAR(50) NOT NULL DEFAULT '', | |
| 38 | 54 | listing_title VARCHAR(255) NOT NULL DEFAULT '', |
| 39 | 55 | listing_url VARCHAR(255) NOT NULL DEFAULT '', |
| 40 | 56 | import_item_id BIGINT UNSIGNED NOT NULL DEFAULT 0, |
| 41 | 57 | import_item_title VARCHAR(255) NOT NULL DEFAULT '', |
| 42 | 58 | source VARCHAR(20) NOT NULL DEFAULT '', |
| 59 | + reason_code VARCHAR(64) NOT NULL DEFAULT '', | |
| 43 | 60 | created_at DATETIME NOT NULL, |
| 44 | 61 | PRIMARY KEY (id), |
| 45 | 62 | KEY created_at (created_at), |
| 46 | 63 | KEY import_item_id (import_item_id), |
| @@ -98,11 +115,15 @@ | ||
| 98 | 115 | * @param int $listing_id WP post ID of the property |
| 99 | 116 | * @param string $listing_key MLS ListingKey |
| 100 | 117 | * @param int $import_item_id mlsimport_item post ID (0 if unknown) |
| 101 | 118 | * @param string $source raw label, e.g. 'normal' | 'cron' | 'import' | 'reconciliation' |
| 119 | + * @param string $mls_id MLS # (RESO ListingId). For 'deleted' rows, an empty value is | |
| 120 | + * back-filled from the listing's most recent prior row. | |
| 121 | + * @param string $status Listing status. Same delete back-fill behaviour as $mls_id. | |
| 122 | + * @param string $reason_code Stable reconciliation reason; blank for other actions. | |
| 102 | 123 | * @return void |
| 103 | 124 | */ |
| 104 | -function mlsimport_record_activity( string $action, int $listing_id, string $listing_key, int $import_item_id, string $source = '' ): void { | |
| 125 | +function mlsimport_record_activity( string $action, int $listing_id, string $listing_key, int $import_item_id, string $source = '', string $mls_id = '', string $status = '', string $reason_code = '' ): void { | |
| 105 | 126 | $valid_actions = [ 'added', 'edited', 'deleted' ]; |
| 106 | 127 | |
| 107 | 128 | if ( ! in_array( $action, $valid_actions, true ) ) { |
| 108 | 129 | return; |
| @@ -129,17 +150,49 @@ | ||
| 129 | 150 | |
| 130 | 151 | // Cap listing_key to its column width. |
| 131 | 152 | $listing_key_capped = mb_substr( $listing_key, 0, 191 ); |
| 132 | 153 | |
| 154 | + // A delete fires for a listing no longer in the feed, so the caller has only the | |
| 155 | + // post ID — not a fresh MLS # / status. Back-fill each empty value from this | |
| 156 | + // listing's most recent prior row so the deleted row stays identifiable by MLS # | |
| 157 | + // and last-known status. Explicitly-passed values are never overwritten. | |
| 158 | + if ( 'deleted' === $action && '' !== $listing_key_capped && ( '' === $mls_id || '' === $status ) ) { | |
| 159 | + $prior = $wpdb->get_row( | |
| 160 | + $wpdb->prepare( | |
| 161 | + 'SELECT listing_mls_id, listing_status FROM ' . mlsimport_activity_table_name() . ' WHERE listing_key = %s ORDER BY id DESC LIMIT 1', | |
| 162 | + $listing_key_capped | |
| 163 | + ) | |
| 164 | + ); | |
| 165 | + if ( $prior ) { | |
| 166 | + if ( '' === $mls_id ) { | |
| 167 | + $mls_id = (string) $prior->listing_mls_id; | |
| 168 | + } | |
| 169 | + if ( '' === $status ) { | |
| 170 | + $status = (string) $prior->listing_status; | |
| 171 | + } | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + // Cap MLS # and status to their column widths. | |
| 176 | + $mls_id_capped = mb_substr( $mls_id, 0, 191 ); | |
| 177 | + $status_capped = mb_substr( $status, 0, 50 ); | |
| 178 | + $allowed_reasons = array( 'absent_unprotected', 'absent_import_task_missing' ); | |
| 179 | + $reason_capped = 'deleted' === $action && 'reconciliation' === $normalized_source && in_array( $reason_code, $allowed_reasons, true ) | |
| 180 | + ? $reason_code | |
| 181 | + : ''; | |
| 182 | + | |
| 133 | 183 | $data = [ |
| 134 | 184 | 'action' => $action, |
| 135 | 185 | 'listing_id' => $listing_id, |
| 136 | 186 | 'listing_key' => $listing_key_capped, |
| 187 | + 'listing_mls_id' => $mls_id_capped, | |
| 188 | + 'listing_status' => $status_capped, | |
| 137 | 189 | 'listing_title' => $listing_title, |
| 138 | 190 | 'listing_url' => $listing_url, |
| 139 | 191 | 'import_item_id' => $import_item_id, |
| 140 | 192 | 'import_item_title' => $import_item_title, |
| 141 | 193 | 'source' => $normalized_source, |
| 194 | + 'reason_code' => $reason_capped, | |
| 142 | 195 | 'created_at' => current_time( 'mysql' ), |
| 143 | 196 | ]; |
| 144 | 197 | |
| 145 | 198 | $format = [ |
| @@ -145,13 +198,16 @@ | ||
| 145 | 198 | $format = [ |
| 146 | 199 | '%s', // action |
| 147 | 200 | '%d', // listing_id |
| 148 | 201 | '%s', // listing_key |
| 202 | + '%s', // listing_mls_id | |
| 203 | + '%s', // listing_status | |
| 149 | 204 | '%s', // listing_title |
| 150 | 205 | '%s', // listing_url |
| 151 | 206 | '%d', // import_item_id |
| 152 | 207 | '%s', // import_item_title |
| 153 | 208 | '%s', // source |
| 209 | + '%s', // reason_code | |
| 154 | 210 | '%s', // created_at |
| 155 | 211 | ]; |
| 156 | 212 | |
| 157 | 213 | $wpdb->insert( mlsimport_activity_table_name(), $data, $format ); |
| @@ -179,10 +235,14 @@ | ||
| 179 | 235 | $cutoff = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - $hours * HOUR_IN_SECONDS ); |
| 180 | 236 | |
| 181 | 237 | $table = mlsimport_activity_table_name(); |
| 182 | 238 | |
| 239 | + // COUNT(DISTINCT listing_key), not COUNT(*): the same MLS listing re-imported | |
| 240 | + // (added→deleted→re-added over test runs) writes one activity row per event but | |
| 241 | + // keeps a stable listing_key, so the banner must count unique properties — not | |
| 242 | + // events — or it reports "50 added" for the same 10 listings. | |
| 183 | 243 | $sql = $wpdb->prepare( |
| 184 | - "SELECT import_item_id, action, COUNT(*) AS cnt, MAX(import_item_title) AS import_item_title FROM {$table} WHERE created_at >= %s GROUP BY import_item_id, action", | |
| 244 | + "SELECT import_item_id, action, COUNT(DISTINCT listing_key) AS cnt, MAX(import_item_title) AS import_item_title FROM {$table} WHERE created_at >= %s GROUP BY import_item_id, action", | |
| 185 | 245 | $cutoff |
| 186 | 246 | ); |
| 187 | 247 | $rows = $wpdb->get_results( $sql ); |
| 188 | 248 | |
| @@ -273,10 +333,13 @@ | ||
| 273 | 333 | * 1. current user is an administrator, |
| 274 | 334 | * 2. the last-24h activity totals are non-zero, |
| 275 | 335 | * 3. the per-user 'mlsimport_activity_banner_dismissed' meta is NOT today's date. |
| 276 | 336 | * |
| 277 | - * Mirrors the inline-script pattern from mlsimport_handle_dismiss_protected_notice() | |
| 278 | - * in mlsimport.php. | |
| 337 | + * The inline script delegates from the banner because WordPress creates the | |
| 338 | + * `.notice-dismiss` button after `admin_notices` has rendered. The sequence is: | |
| 339 | + * 1. render the banner and its per-user dismissal nonce; | |
| 340 | + * 2. listen on the already-present banner for a future dismiss-button click; | |
| 341 | + * 3. persist today's dismissal date through the authenticated AJAX action. | |
| 279 | 342 | * |
| 280 | 343 | * @return void |
| 281 | 344 | */ |
| 282 | 345 | function mlsimport_render_activity_banner(): void { |
| @@ -359,12 +422,22 @@ | ||
| 359 | 422 | <script> |
| 360 | 423 | (function() { |
| 361 | 424 | var banner = document.querySelector('.mlsimport-activity-banner'); |
| 362 | 425 | if ( ! banner ) { return; } |
| 363 | - var dismissBtn = banner.querySelector('.notice-dismiss'); | |
| 364 | - if ( ! dismissBtn ) { return; } | |
| 365 | - var nonce = banner.querySelector('.mlsimport-activity-banner-nonce').getAttribute('data-nonce'); | |
| 366 | - dismissBtn.addEventListener('click', function() { | |
| 426 | + var nonceNode = banner.querySelector('.mlsimport-activity-banner-nonce'); | |
| 427 | + if ( ! nonceNode ) { return; } | |
| 428 | + var nonce = nonceNode.getAttribute('data-nonce'); | |
| 429 | + | |
| 430 | + /* | |
| 431 | + * WordPress injects the dismiss button after this inline script runs. | |
| 432 | + * Delegating from the existing banner catches that later-created control | |
| 433 | + * before the core document handler removes the notice from the page. | |
| 434 | + */ | |
| 435 | + banner.addEventListener('click', function(event) { | |
| 436 | + var target = event.target; | |
| 437 | + var dismissBtn = target && target.closest ? target.closest('.notice-dismiss') : null; | |
| 438 | + if ( ! dismissBtn || ! banner.contains(dismissBtn) ) { return; } | |
| 439 | + | |
| 367 | 440 | var xhr = new XMLHttpRequest(); |
| 368 | 441 | xhr.open('POST', ajaxurl); |
| 369 | 442 | xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); |
| 370 | 443 | xhr.send('action=mlsimport_dismiss_activity_banner&_ajax_nonce=' + encodeURIComponent(nonce)); |