PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 6.3.6
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v6.3.6
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
mlsimport / includes / mlsimport-activity-log.php

mlsimport-activity-log.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 6.3.6, at includes/mlsimport-activity-log.php

395 lines 14.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 exit;
4 }
5
6 // B1 — Schema version. Bump to trigger dbDelta re-run via mlsimport_maybe_upgrade_activity_table().
7 define( 'MLSIMPORT_ACTIVITY_DB_VERSION', '1.0' );
8
9 /**
10 * Returns the prefixed activity table name.
11 * The ONLY source of the table name — never accept a table name from request input,
12 * never use as a $wpdb->prepare placeholder.
13 *
14 * @return string
15 */
16 function mlsimport_activity_table_name(): string {
17 global $wpdb;
18 return $wpdb->prefix . 'mlsimport_activity';
19 }
20
21 /**
22 * Creates the activity table via dbDelta().
23 * Safe to call multiple times — dbDelta() is idempotent.
24 *
25 * @return void
26 */
27 function mlsimport_create_activity_table(): void {
28 global $wpdb;
29
30 $table_name = mlsimport_activity_table_name();
31 $charset_collate = $wpdb->get_charset_collate();
32
33 $sql = "CREATE TABLE {$table_name} (
34 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
35 action VARCHAR(10) NOT NULL,
36 listing_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
37 listing_key VARCHAR(191) NOT NULL DEFAULT '',
38 listing_title VARCHAR(255) NOT NULL DEFAULT '',
39 listing_url VARCHAR(255) NOT NULL DEFAULT '',
40 import_item_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
41 import_item_title VARCHAR(255) NOT NULL DEFAULT '',
42 source VARCHAR(20) NOT NULL DEFAULT '',
43 created_at DATETIME NOT NULL,
44 PRIMARY KEY (id),
45 KEY created_at (created_at),
46 KEY import_item_id (import_item_id),
47 KEY action (action)
48 ) {$charset_collate};";
49
50 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
51 dbDelta( $sql );
52 }
53
54 /**
55 * Runs the table migration if the installed schema version is outdated.
56 * Hooked to 'init' priority 1 so it runs on wp-cron requests too.
57 *
58 * @return void
59 */
60 function mlsimport_maybe_upgrade_activity_table(): void {
61 if ( get_option( 'mlsimport_activity_db_version' ) !== MLSIMPORT_ACTIVITY_DB_VERSION ) {
62 mlsimport_create_activity_table();
63 update_option( 'mlsimport_activity_db_version', MLSIMPORT_ACTIVITY_DB_VERSION );
64 }
65 }
66 add_action( 'init', 'mlsimport_maybe_upgrade_activity_table', 1 );
67
68 /**
69 * Normalizes a raw source label to one of the canonical values.
70 * PURE PHP — no WordPress functions (unit-testable).
71 *
72 * Maps: normal->manual, cron->cron, manual->manual, import->import,
73 * reconciliation->reconciliation, anything else->other.
74 *
75 * @param string $raw
76 * @return string
77 */
78 function mlsimport_normalize_activity_source( string $raw ): string {
79 $normalized = strtolower( trim( $raw ) );
80
81 $map = [
82 'normal' => 'manual',
83 'cron' => 'cron',
84 'manual' => 'manual',
85 'import' => 'import',
86 'reconciliation' => 'reconciliation',
87 ];
88
89 return isset( $map[ $normalized ] ) ? $map[ $normalized ] : 'other';
90 }
91
92 /**
93 * Records ONE activity row for a listing add / edit / delete.
94 * Guards $action first — returns before any WP/DB call on invalid input.
95 * Snapshots title/URL/import-task title and caps every string to its column width.
96 *
97 * @param string $action 'added' | 'edited' | 'deleted'
98 * @param int $listing_id WP post ID of the property
99 * @param string $listing_key MLS ListingKey
100 * @param int $import_item_id mlsimport_item post ID (0 if unknown)
101 * @param string $source raw label, e.g. 'normal' | 'cron' | 'import' | 'reconciliation'
102 * @return void
103 */
104 function mlsimport_record_activity( string $action, int $listing_id, string $listing_key, int $import_item_id, string $source = '' ): void {
105 $valid_actions = [ 'added', 'edited', 'deleted' ];
106
107 if ( ! in_array( $action, $valid_actions, true ) ) {
108 return;
109 }
110
111 global $wpdb;
112
113 // Snapshot listing title — fall back to listing_key if empty.
114 $raw_title = get_the_title( $listing_id );
115 if ( '' === $raw_title ) {
116 $raw_title = $listing_key;
117 }
118 $listing_title = mb_substr( wp_strip_all_tags( $raw_title ), 0, 255 );
119
120 // Snapshot listing URL.
121 $permalink = get_permalink( $listing_id );
122 $listing_url = ( false !== $permalink ) ? mb_substr( $permalink, 0, 255 ) : '';
123
124 // Snapshot import task title.
125 $import_item_title = mb_substr( wp_strip_all_tags( get_the_title( $import_item_id ) ), 0, 255 );
126
127 // Normalize source.
128 $normalized_source = mlsimport_normalize_activity_source( $source );
129
130 // Cap listing_key to its column width.
131 $listing_key_capped = mb_substr( $listing_key, 0, 191 );
132
133 $data = [
134 'action' => $action,
135 'listing_id' => $listing_id,
136 'listing_key' => $listing_key_capped,
137 'listing_title' => $listing_title,
138 'listing_url' => $listing_url,
139 'import_item_id' => $import_item_id,
140 'import_item_title' => $import_item_title,
141 'source' => $normalized_source,
142 'created_at' => current_time( 'mysql' ),
143 ];
144
145 $format = [
146 '%s', // action
147 '%d', // listing_id
148 '%s', // listing_key
149 '%s', // listing_title
150 '%s', // listing_url
151 '%d', // import_item_id
152 '%s', // import_item_title
153 '%s', // source
154 '%s', // created_at
155 ];
156
157 $wpdb->insert( mlsimport_activity_table_name(), $data, $format );
158
159 // A new activity row makes the cached 24h banner summary stale. Drop the
160 // transient so the next banner view recomputes — keeps the banner current
161 // after every import without waiting out the 15-minute TTL.
162 delete_transient( 'mlsimport_activity_banner_summary' );
163 }
164
165 /**
166 * Aggregates activity rows from the last $hours.
167 * Query uses GROUP BY import_item_id, action — safe under ONLY_FULL_GROUP_BY.
168 * Table name is interpolated directly (never as a prepare placeholder).
169 *
170 * @param int $hours Window in hours. Default 24.
171 * @return array {
172 * 'totals' => ['added'=>int, 'edited'=>int, 'deleted'=>int],
173 * 'by_item' => [ import_item_id => ['title'=>string, 'added'=>int, 'edited'=>int, 'deleted'=>int] ]
174 * }
175 */
176 function mlsimport_get_activity_summary( int $hours = 24 ): array {
177 global $wpdb;
178
179 $cutoff = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - $hours * HOUR_IN_SECONDS );
180
181 $table = mlsimport_activity_table_name();
182
183 $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",
185 $cutoff
186 );
187 $rows = $wpdb->get_results( $sql );
188
189 $totals = [ 'added' => 0, 'edited' => 0, 'deleted' => 0 ];
190 $by_item = [];
191
192 if ( ! empty( $rows ) ) {
193 foreach ( $rows as $row ) {
194 $item_id = (int) $row->import_item_id;
195 $act = $row->action;
196 $cnt = (int) $row->cnt;
197 $title = (string) $row->import_item_title;
198
199 if ( isset( $totals[ $act ] ) ) {
200 $totals[ $act ] += $cnt;
201 }
202
203 if ( ! isset( $by_item[ $item_id ] ) ) {
204 $by_item[ $item_id ] = [
205 'title' => $title,
206 'added' => 0,
207 'edited' => 0,
208 'deleted' => 0,
209 ];
210 }
211
212 if ( isset( $by_item[ $item_id ][ $act ] ) ) {
213 $by_item[ $item_id ][ $act ] += $cnt;
214 }
215
216 // Keep most recent title if available.
217 if ( '' !== $title ) {
218 $by_item[ $item_id ]['title'] = $title;
219 }
220 }
221 }
222
223 return [
224 'totals' => $totals,
225 'by_item' => $by_item,
226 ];
227 }
228
229 /**
230 * Returns a cached (15-minute transient) version of the 24-hour activity summary.
231 * The recorder does NOT bust this transient.
232 *
233 * @return array Same structure as mlsimport_get_activity_summary().
234 */
235 function mlsimport_get_activity_banner_data(): array {
236 $cached = get_transient( 'mlsimport_activity_banner_summary' );
237 if ( false !== $cached ) {
238 return $cached;
239 }
240
241 $data = mlsimport_get_activity_summary( 24 );
242 set_transient( 'mlsimport_activity_banner_summary', $data, 15 * MINUTE_IN_SECONDS );
243
244 return $data;
245 }
246
247 /**
248 * Deletes activity rows older than 30 days.
249 * Hooked to 'mlsimport_reconciliation_event' (existing daily cron).
250 *
251 * @return void
252 */
253 function mlsimport_prune_activity_log(): void {
254 global $wpdb;
255
256 $cutoff = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - 30 * DAY_IN_SECONDS );
257
258 $table = mlsimport_activity_table_name();
259
260 $wpdb->query(
261 $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", $cutoff )
262 );
263 }
264 add_action( 'mlsimport_reconciliation_event', 'mlsimport_prune_activity_log' );
265
266 // ---------------------------------------------------------------------------
267 // B2 — Banner render + AJAX dismissal handler
268 // ---------------------------------------------------------------------------
269
270 /**
271 * Renders the dismissible sync-status banner on wp-admin pages.
272 * Shows only when:
273 * 1. current user is an administrator,
274 * 2. the last-24h activity totals are non-zero,
275 * 3. the per-user 'mlsimport_activity_banner_dismissed' meta is NOT today's date.
276 *
277 * Mirrors the inline-script pattern from mlsimport_handle_dismiss_protected_notice()
278 * in mlsimport.php.
279 *
280 * @return void
281 */
282 function mlsimport_render_activity_banner(): void {
283
284 if ( ! current_user_can( 'administrator' ) ) {
285 return;
286 }
287
288 $data = mlsimport_get_activity_banner_data();
289 $totals = $data['totals'];
290 $by_item = $data['by_item'];
291
292 $today = current_time( 'Y-m-d' );
293 $dismissed = get_user_meta( get_current_user_id(), 'mlsimport_activity_banner_dismissed', true );
294 if ( $dismissed === $today ) {
295 return;
296 }
297
298 $nonce = wp_create_nonce( 'mlsimport_activity_banner' );
299 $history_url = esc_url( admin_url( 'admin.php?page=mlsimport_history' ) );
300
301 ?>
302 <div class="notice notice-info is-dismissible mlsimport-activity-banner">
303 <div class="mlsimport-activity-banner__counts">
304 <span class="mlsimport-activity-banner__title">
305 <?php echo esc_html__( 'MLSImport activity', 'mlsimport' ); ?>
306 <span class="mlsimport-activity-banner__period"><?php echo esc_html__( 'last 24 hours', 'mlsimport' ); ?></span>
307 </span>
308 <span class="mlsimport-activity-banner__totals">
309 <?php
310 $total_added = (int) $totals['added'];
311 $total_edited = (int) $totals['edited'];
312 $total_deleted = (int) $totals['deleted'];
313 ?>
314 <span class="mlsimport-activity-stat mlsimport-activity-stat--added"><strong><?php echo esc_html( number_format_i18n( $total_added ) ); ?></strong> <?php echo esc_html( _n( 'property', 'properties', $total_added, 'mlsimport' ) ); ?> <?php echo esc_html__( 'added', 'mlsimport' ); ?></span>
315 <span class="mlsimport-activity-stat mlsimport-activity-stat--edited"><strong><?php echo esc_html( number_format_i18n( $total_edited ) ); ?></strong> <?php echo esc_html( _n( 'property', 'properties', $total_edited, 'mlsimport' ) ); ?> <?php echo esc_html__( 'edited', 'mlsimport' ); ?></span>
316 <span class="mlsimport-activity-stat mlsimport-activity-stat--deleted"><strong><?php echo esc_html( number_format_i18n( $total_deleted ) ); ?></strong> <?php echo esc_html( _n( 'property', 'properties', $total_deleted, 'mlsimport' ) ); ?> <?php echo esc_html__( 'deleted', 'mlsimport' ); ?></span>
317 </span>
318 </div>
319 <?php
320 if ( ! empty( $by_item ) ) :
321 // Show only the 5 most active tasks (by total added + edited + deleted).
322 usort(
323 $by_item,
324 function ( $a, $b ) {
325 $a_total = (int) $a['added'] + (int) $a['edited'] + (int) $a['deleted'];
326 $b_total = (int) $b['added'] + (int) $b['edited'] + (int) $b['deleted'];
327 return $b_total <=> $a_total;
328 }
329 );
330 $by_item = array_slice( $by_item, 0, 5 );
331 ?>
332 <ul class="mlsimport-activity-banner__breakdown">
333 <?php
334 foreach ( $by_item as $item ) :
335 $task_title = trim( (string) $item['title'] );
336 if ( '' === $task_title ) {
337 $task_title = __( 'Unknown import task', 'mlsimport' );
338 }
339 $added = (int) $item['added'];
340 $edited = (int) $item['edited'];
341 $deleted = (int) $item['deleted'];
342 ?>
343 <li>
344 <span class="mlsimport-activity-banner__task"><?php echo esc_html__( 'Task Name:', 'mlsimport' ); ?> <?php echo esc_html( $task_title ); ?></span>
345 <span class="mlsimport-activity-banner__chips">
346 <span class="mlsimport-activity-chip mlsimport-activity-chip--added<?php echo 0 === $added ? ' is-zero' : ''; ?>"><?php echo esc_html( number_format_i18n( $added ) ); ?> <?php echo esc_html( _n( 'property', 'properties', $added, 'mlsimport' ) ); ?> <?php echo esc_html__( 'added', 'mlsimport' ); ?></span>
347 <span class="mlsimport-activity-chip mlsimport-activity-chip--edited<?php echo 0 === $edited ? ' is-zero' : ''; ?>"><?php echo esc_html( number_format_i18n( $edited ) ); ?> <?php echo esc_html( _n( 'property', 'properties', $edited, 'mlsimport' ) ); ?> <?php echo esc_html__( 'edited', 'mlsimport' ); ?></span>
348 <span class="mlsimport-activity-chip mlsimport-activity-chip--deleted<?php echo 0 === $deleted ? ' is-zero' : ''; ?>"><?php echo esc_html( number_format_i18n( $deleted ) ); ?> <?php echo esc_html( _n( 'property', 'properties', $deleted, 'mlsimport' ) ); ?> <?php echo esc_html__( 'deleted', 'mlsimport' ); ?></span>
349 </span>
350 </li>
351 <?php endforeach; ?>
352 </ul>
353 <?php endif; ?>
354 <p class="mlsimport-activity-banner__actions">
355 <a class="mlsimport-activity-banner__link" href="<?php echo $history_url; ?>"><?php echo esc_html__( 'View full 30-day history', 'mlsimport' ); ?> <span aria-hidden="true">&rarr;</span></a>
356 </p>
357 <span class="mlsimport-activity-banner-nonce" style="display:none;" data-nonce="<?php echo esc_attr( $nonce ); ?>"></span>
358 </div>
359 <script>
360 (function() {
361 var banner = document.querySelector('.mlsimport-activity-banner');
362 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() {
367 var xhr = new XMLHttpRequest();
368 xhr.open('POST', ajaxurl);
369 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
370 xhr.send('action=mlsimport_dismiss_activity_banner&_ajax_nonce=' + encodeURIComponent(nonce));
371 });
372 })();
373 </script>
374 <?php
375 }
376 add_action( 'admin_notices', 'mlsimport_render_activity_banner' );
377
378 /**
379 * AJAX handler: persists banner dismissal for the current user for today.
380 *
381 * @return void
382 */
383 function mlsimport_ajax_dismiss_activity_banner(): void {
384 check_ajax_referer( 'mlsimport_activity_banner' );
385
386 if ( ! current_user_can( 'administrator' ) ) {
387 wp_send_json_error();
388 }
389
390 update_user_meta( get_current_user_id(), 'mlsimport_activity_banner_dismissed', current_time( 'Y-m-d' ) );
391
392 wp_send_json_success();
393 }
394 add_action( 'wp_ajax_mlsimport_dismiss_activity_banner', 'mlsimport_ajax_dismiss_activity_banner' );
395