PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
← All changes | includes/class-mxchat-session-store.php +166 -4 3.2.19 → 3.2.23 View file →
@@ -39,11 +39,11 @@
39 39 /**
40 40 * Bump when create_table()'s schema changes. ensure_table() compares the
41 41 * READY option against this, so existing installs re-run dbDelta once and
42 42 * pick up new columns ('1' = b64b77 original, '2' = 5658f2 identity
43 - * columns).
43 + * columns, '3' = b062c4 consent record).
44 44 */
45 - const SCHEMA_VERSION = '2';
45 + const SCHEMA_VERSION = '3';
46 46
47 47 /**
48 48 * Bump when $legacy_prefixes gains entries. A stale version in the stored
49 49 * migration state resets the bookmark so the new prefixes get a full
@@ -86,8 +86,13 @@
86 86 'channel' => 'channel',
87 87 'name' => 'visitor_name',
88 88 'email' => 'visitor_email',
89 89 'agent_name' => 'agent_name',
90 + // Consent record (b062c4). Never lived in wp_options, so no legacy
91 + // prefixes: legacy_get() correctly falls through to the default.
92 + 'consent' => 'consent_given',
93 + 'consent_label' => 'consent_label',
94 + 'consent_at' => 'consent_recorded_at',
90 95 );
91 96
92 97 /** Logical field => the wp_options prefix it used to live under. */
93 98 private static $legacy_prefixes = array(
@@ -157,8 +162,11 @@
157 162 channel varchar(191) DEFAULT NULL,
158 163 visitor_name varchar(191) DEFAULT NULL,
159 164 visitor_email varchar(191) DEFAULT NULL,
160 165 agent_name varchar(191) DEFAULT NULL,
166 + consent_given varchar(8) DEFAULT NULL,
167 + consent_label text DEFAULT NULL,
168 + consent_recorded_at datetime DEFAULT NULL,
161 169 created_at datetime DEFAULT NULL,
162 170 updated_at datetime DEFAULT NULL,
163 171 PRIMARY KEY (session_id),
164 172 KEY updated_at (updated_at)
@@ -358,8 +366,14 @@
358 366 foreach (self::$legacy_prefixes as $prefix) {
359 367 delete_option($prefix . $session_id);
360 368 }
361 369
370 + // d0cae1: integrations holding per-session state outside WP purge on
371 + // this. Fires on every ended session — the retention sweep in trim()
372 + // fires it too with reason 'retention'; an integration that only
373 + // listens to one path leaks state on the other.
374 + do_action('mxchat_session_ended', $session_id, array('reason' => 'deleted'));
375 +
362 376 return true;
363 377 }
364 378
365 379 /**
@@ -425,9 +439,18 @@
425 439 * only ever set by the capture paths.
426 440 *
427 441 * @return array[] Rows of [session_id, visitor_email, visitor_name].
428 442 */
429 - public static function identity_rows() {
443 + /**
444 + * Sessions carrying visitor identity.
445 + *
446 + * @param bool $include_nameless_email Also return sessions that have a
447 + * NAME but no email address. Off by default, so every existing
448 + * caller sees exactly the rows it always has; the Leads tab opts in
449 + * when the owner has made the email field optional (c0cfaf), where
450 + * a name-only capture is a real lead rather than a half-filled one.
451 + */
452 + public static function identity_rows($include_nameless_email = false) {
430 453 global $wpdb;
431 454
432 455 if (!self::ensure_table()) {
433 456 return array();
@@ -434,11 +457,16 @@
434 457 }
435 458
436 459 $table = self::table();
437 460
461 + $where = "visitor_email IS NOT NULL AND visitor_email != ''";
462 + if ($include_nameless_email) {
463 + $where = "($where) OR (visitor_name IS NOT NULL AND visitor_name != '')";
464 + }
465 +
438 466 $rows = $wpdb->get_results(
439 467 "SELECT session_id, visitor_email, visitor_name FROM `$table`
440 - WHERE visitor_email IS NOT NULL AND visitor_email != ''",
468 + WHERE $where",
441 469 ARRAY_A
442 470 );
443 471
444 472 return is_array($rows) ? $rows : array();
@@ -443,8 +471,103 @@
443 471
444 472 return is_array($rows) ? $rows : array();
445 473 }
446 474
475 + /**
476 + * Record the lead-capture consent decision in one atomic upsert (b062c4).
477 + *
478 + * Three facts travel together — whether the box was ticked, when, and the
479 + * exact label text the visitor saw — because a "yes" without "to what"
480 + * proves nothing once the owner rewrites the label. One query rather than
481 + * three set() calls so a crash can never store half a consent record.
482 + *
483 + * Rows written here always also carry visitor_email (the capture endpoint
484 + * stores the email in the same request), so the retention sweep's identity
485 + * guard keeps consent records exactly as long as it keeps the lead.
486 + *
487 + * @param string $session_id
488 + * @param bool $given True when the box was ticked.
489 + * @param string $label The sanitized label markup as rendered to the visitor.
490 + * @return bool True when the write was attempted against a live table.
491 + */
492 + public static function record_consent($session_id, $given, $label) {
493 + global $wpdb;
494 +
495 + $session_id = self::sanitize($session_id);
496 + if ($session_id === '' || !self::ensure_table()) {
497 + return false;
498 + }
499 +
500 + $table = self::table();
501 + $now = current_time('mysql');
502 +
503 + $wpdb->query(
504 + $wpdb->prepare(
505 + "INSERT INTO `$table` (session_id, consent_given, consent_label, consent_recorded_at, created_at, updated_at)
506 + VALUES (%s, %s, %s, %s, %s, %s)
507 + ON DUPLICATE KEY UPDATE
508 + consent_given = VALUES(consent_given),
509 + consent_label = VALUES(consent_label),
510 + consent_recorded_at = VALUES(consent_recorded_at),
511 + updated_at = VALUES(updated_at)",
512 + $session_id,
513 + $given ? 'yes' : 'no',
514 + (string) $label,
515 + $now,
516 + $now,
517 + $now
518 + )
519 + );
520 +
521 + unset(self::$cache[$session_id]);
522 + self::maybe_trim();
523 +
524 + return true;
525 + }
526 +
527 + /**
528 + * Newest recorded consent for a lead email, across all their sessions.
529 + * The Leads tab and CSV export read this — a lead is aggregated by email
530 + * while consent is captured per session, so "the lead's consent state" is
531 + * the most recent record. NULL result means NOT RECORDED, which the
532 + * callers must render as exactly that ("not recorded" and "no consent"
533 + * are different facts).
534 + *
535 + * @param string $email
536 + * @return array|null ['given' => 'yes'|'no', 'label' => string, 'recorded_at' => string] or null.
537 + */
538 + public static function latest_consent($email) {
539 + global $wpdb;
540 +
541 + $email = is_scalar($email) ? trim((string) $email) : '';
542 + if ($email === '' || !self::ensure_table()) {
543 + return null;
544 + }
545 +
546 + $table = self::table();
547 +
548 + $row = $wpdb->get_row(
549 + $wpdb->prepare(
550 + "SELECT consent_given, consent_label, consent_recorded_at
551 + FROM `$table`
552 + WHERE visitor_email = %s AND consent_given IS NOT NULL AND consent_given != ''
553 + ORDER BY consent_recorded_at DESC LIMIT 1",
554 + $email
555 + ),
556 + ARRAY_A
557 + );
558 +
559 + if (!is_array($row)) {
560 + return null;
561 + }
562 +
563 + return array(
564 + 'given' => (string) $row['consent_given'],
565 + 'label' => is_string($row['consent_label']) ? $row['consent_label'] : '',
566 + 'recorded_at' => is_string($row['consent_recorded_at']) ? $row['consent_recorded_at'] : '',
567 + );
568 + }
569 +
447 570 private static function row($session_id) {
448 571 if (array_key_exists($session_id, self::$cache)) {
449 572 return self::$cache[$session_id];
450 573 }
@@ -723,8 +846,47 @@
723 846 // Identity guard (5658f2): rows holding a visitor name/email are LEADS
724 847 // — the Leads tab lists them and the legacy option rows they replace
725 848 // were never expired. Retention only sweeps anonymous session state;
726 849 // identity rows leave via the eraser / lead-delete / wipe paths.
850 + //
851 + // d0cae1: when something listens for ended sessions the swept ids must
852 + // be known, so the delete goes SELECT-then-DELETE-by-id. Unhooked, the
853 + // original single-statement bulk delete runs unchanged.
854 + if (has_action('mxchat_session_ended')) {
855 + $swept_ids = $wpdb->get_col(
856 + $wpdb->prepare(
857 + "SELECT session_id FROM `$table` WHERE updated_at IS NOT NULL AND updated_at < %s
858 + AND (visitor_email IS NULL OR visitor_email = '')
859 + AND (visitor_name IS NULL OR visitor_name = '')
860 + LIMIT %d",
861 + $cutoff,
862 + $limit
863 + )
864 + );
865 +
866 + if (empty($swept_ids)) {
867 + return 0;
868 + }
869 +
870 + $placeholders = implode(',', array_fill(0, count($swept_ids), '%s'));
871 + $deleted = $wpdb->query(
872 + $wpdb->prepare(
873 + "DELETE FROM `$table` WHERE session_id IN ($placeholders)",
874 + $swept_ids
875 + )
876 + );
877 +
878 + if ($deleted) {
879 + self::flush_cache();
880 + }
881 +
882 + foreach ($swept_ids as $swept_id) {
883 + do_action('mxchat_session_ended', $swept_id, array('reason' => 'retention'));
884 + }
885 +
886 + return (int) $deleted;
887 + }
888 +
727 889 $deleted = $wpdb->query(
728 890 $wpdb->prepare(
729 891 "DELETE FROM `$table` WHERE updated_at IS NOT NULL AND updated_at < %s
730 892 AND (visitor_email IS NULL OR visitor_email = '')