|
@@ -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 = '') |