PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2
7.2.2 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 All 37 releases
← All changes | includes/mlsimport-activity-log.php +82 -9 6.3.57.2 View file →
@@ -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));