From 82a1b8632fe0ac0487a15ff23156747e64d13611 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 01:47:14 -0400 Subject: [PATCH 1/4] feat(contact): WordPress Contact model for public-ticket dedupe (Pattern B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WordPress port of the Pattern B design shipped in escalated-nestjs PR #17 and companion PRs for Laravel / Rails / Django / Adonis / .NET. Changes: - class-activator.php — adds nullable contact_id column to escalated_tickets (via dbDelta; idempotent on re-activation) and a new escalated_contacts table with unique email index and nullable user_id. - includes/Models/Contact.php — class with: static normalize_email (trim + lowercase, pure) static decide_action (branch selection, pure) static find_by_email / find static find_or_create_by_email (dedupe, dual-mode name fill) static link_to_user static promote_to_user (link + back-stamp requester_id on existing tickets) - tests/Test_Contact_Model.php — 7 WP_UnitTestCase cases (normalize_email ×3, decide_action ×4). Inline guest_name / guest_email / guest_token columns preserved on tickets for backwards compatibility; follow-up pass migrates the AJAX handler and inbound controller to write via Contact. Local verification: WP test library not installed in author's env; CI on this PR will run the tests. The pure-function tests (normalize_email, decide_action) have no WP dependencies and match the equivalents passing in NestJS (message-id), Adonis (contact_model), .NET (ContactModelTests) PRs. See escalated-dev/escalated docs/superpowers/plans/2026-04-24-public-tickets-rollout-status.md --- includes/Models/Contact.php | 183 +++++++++++++++++++++++++++++++++++ includes/class-activator.php | 20 +++- tests/Test_Contact_Model.php | 65 +++++++++++++ 3 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 includes/Models/Contact.php create mode 100644 tests/Test_Contact_Model.php diff --git a/includes/Models/Contact.php b/includes/Models/Contact.php new file mode 100644 index 0000000..cbbf7c5 --- /dev/null +++ b/includes/Models/Contact.php @@ -0,0 +1,183 @@ +get_row or null + * @param string|null $incoming_name + * @return string One of 'create', 'update-name', 'return-existing' + */ + public static function decide_action($existing, $incoming_name) + { + if ($existing === null) { + return 'create'; + } + $existing_name = isset($existing->name) ? $existing->name : null; + if ((is_null($existing_name) || $existing_name === '') && ! empty($incoming_name)) { + return 'update-name'; + } + return 'return-existing'; + } + + /** + * @param int $id + * @return object|null + */ + public static function find($id) + { + global $wpdb; + $table = static::table(); + return $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id) + ); + } + + /** + * @param string $email + * @return object|null + */ + public static function find_by_email($email) + { + global $wpdb; + $table = static::table(); + $normalized = static::normalize_email($email); + if ($normalized === '') { + return null; + } + return $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE email = %s", $normalized) + ); + } + + /** + * Dedupe-on-write: returns the existing Contact row for a + * previously-seen email, or creates a new one. If the existing + * row has a blank name and a non-blank name is supplied, + * the existing row is updated in place. + * + * @param string $email + * @param string|null $name + * @return object Row from wpdb->get_row + */ + public static function find_or_create_by_email($email, $name = null) + { + global $wpdb; + $table = static::table(); + $normalized = static::normalize_email($email); + + $existing = static::find_by_email($normalized); + $action = static::decide_action($existing, $name); + + if ($action === 'return-existing') { + return $existing; + } + + $now = current_time('mysql'); + + if ($action === 'update-name') { + $wpdb->update( + $table, + ['name' => $name, 'updated_at' => $now], + ['id' => $existing->id] + ); + return static::find($existing->id); + } + + // action === 'create' + $wpdb->insert($table, [ + 'email' => $normalized, + 'name' => $name ?: null, + 'user_id' => null, + 'metadata' => wp_json_encode(new \stdClass()), + 'created_at' => $now, + 'updated_at' => $now, + ]); + return static::find((int) $wpdb->insert_id); + } + + /** + * Link a Contact to a WP user id. + * + * @param int $contact_id + * @param int $user_id + * @return object|null + */ + public static function link_to_user($contact_id, $user_id) + { + global $wpdb; + $table = static::table(); + $wpdb->update( + $table, + ['user_id' => $user_id, 'updated_at' => current_time('mysql')], + ['id' => $contact_id] + ); + return static::find($contact_id); + } + + /** + * Link + back-stamp requester_id on all prior tickets owned + * by this contact. Called when a guest accepts the signup invite. + * + * @param int $contact_id + * @param int $user_id + * @return object|null + */ + public static function promote_to_user($contact_id, $user_id) + { + global $wpdb; + $contact = static::link_to_user($contact_id, $user_id); + $tickets_table = Ticket::table(); + $wpdb->update( + $tickets_table, + ['requester_id' => $user_id, 'updated_at' => current_time('mysql')], + ['contact_id' => $contact_id] + ); + return $contact; + } +} diff --git a/includes/class-activator.php b/includes/class-activator.php index c7ffa64..c308380 100644 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -98,12 +98,30 @@ private static function create_tables(): void guest_name VARCHAR(255) NULL, guest_email VARCHAR(255) NULL, guest_token VARCHAR(64) NULL, + contact_id BIGINT UNSIGNED NULL, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME NULL, PRIMARY KEY (id), UNIQUE KEY reference (reference), - UNIQUE KEY guest_token (guest_token) + UNIQUE KEY guest_token (guest_token), + KEY contact_id (contact_id) + ) $charset_collate;"; + dbDelta($sql); + + // 4b. escalated_contacts (Pattern B convergence — first-class + // identity for guest requesters, deduped by email). + $sql = "CREATE TABLE {$prefix}contacts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + email VARCHAR(320) NOT NULL, + name VARCHAR(255) NULL, + user_id BIGINT UNSIGNED NULL, + metadata TEXT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY email (email), + KEY user_id (user_id) ) $charset_collate;"; dbDelta($sql); diff --git a/tests/Test_Contact_Model.php b/tests/Test_Contact_Model.php new file mode 100644 index 0000000..1d110de --- /dev/null +++ b/tests/Test_Contact_Model.php @@ -0,0 +1,65 @@ +assertEquals('alice@example.com', Contact::normalize_email('ALICE@Example.COM')); + } + + public function test_normalize_email_trims_whitespace() + { + $this->assertEquals('alice@example.com', Contact::normalize_email(' alice@example.com ')); + } + + public function test_normalize_email_handles_null_and_non_string() + { + $this->assertEquals('', Contact::normalize_email(null)); + $this->assertEquals('', Contact::normalize_email(123)); + } + + // --------------------------------------------------------------------- + // decide_action + // --------------------------------------------------------------------- + + public function test_decide_action_create_when_no_existing() + { + $this->assertEquals('create', Contact::decide_action(null, 'Alice')); + } + + public function test_decide_action_return_existing_when_existing_has_name() + { + $existing = (object) ['name' => 'Alice']; + $this->assertEquals('return-existing', Contact::decide_action($existing, 'Different')); + } + + public function test_decide_action_update_name_when_existing_name_is_blank() + { + $existing = (object) ['name' => null]; + $this->assertEquals('update-name', Contact::decide_action($existing, 'Alice')); + + $existing->name = ''; + $this->assertEquals('update-name', Contact::decide_action($existing, 'Alice')); + } + + public function test_decide_action_return_existing_when_no_incoming_name() + { + $existing = (object) ['name' => null]; + $this->assertEquals('return-existing', Contact::decide_action($existing, null)); + $this->assertEquals('return-existing', Contact::decide_action($existing, '')); + } +} From 04c28634fccab02e25eabc85f0e9e0d71964e7e2 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:28:08 -0400 Subject: [PATCH 2/4] feat(contact): wire Contact into WordPress TicketService::create_guest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the Contact model added earlier on this branch. TicketService::create_guest now resolves/creates a Contact by email and stamps contact_id on the new ticket row. The widget REST controller and frontend AJAX handler both delegate here, so one change covers both submission paths. Inline guest_name / guest_email / guest_token columns remain populated for backwards compat. Tests: 1 new integration case in Test_Contact_Model — test_create_guest_dedupes_contacts_by_email (two submissions with casing variants yield one Contact row, both tickets carry the same contact_id) Local verification: WP test library not installed in author's env; CI on this PR will run the tests. Pure-function coverage (normalize_email + decide_action) mirrors equivalents passing in NestJS/Adonis/Go/.NET/Symfony. --- includes/Services/TicketService.php | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/includes/Services/TicketService.php b/includes/Services/TicketService.php index 37a625b..8a67de6 100644 --- a/includes/Services/TicketService.php +++ b/includes/Services/TicketService.php @@ -70,6 +70,22 @@ public function create_guest(array $data): object $priority = $data['priority'] ?? \Escalated\Models\Setting::get('default_priority', 'medium'); $valid_priorities = array_keys(\Escalated\Helpers\Enums::ticket_priorities()); + $guest_email = sanitize_email($data['guest_email'] ?? ''); + $guest_name = sanitize_text_field($data['guest_name'] ?? ''); + + // Dedupe repeat guests by email (Pattern B). Inline guest_* fields + // remain populated for the backwards-compat dual-read period. + $contact_id = null; + if (! empty($guest_email)) { + $contact = \Escalated\Models\Contact::find_or_create_by_email( + $guest_email, + $guest_name ?: null + ); + if ($contact && isset($contact->id)) { + $contact_id = (int) $contact->id; + } + } + $ticket_data = [ 'reference' => $reference, 'requester_id' => null, @@ -80,9 +96,10 @@ public function create_guest(array $data): object 'ticket_type' => in_array($data['ticket_type'] ?? '', ['question', 'problem', 'incident', 'task'], true) ? $data['ticket_type'] : 'question', 'channel' => sanitize_text_field($data['channel'] ?? 'web'), 'department_id' => ! empty($data['department_id']) ? absint($data['department_id']) : null, - 'guest_name' => sanitize_text_field($data['guest_name'] ?? ''), - 'guest_email' => sanitize_email($data['guest_email'] ?? ''), + 'guest_name' => $guest_name, + 'guest_email' => $guest_email, 'guest_token' => wp_generate_password(64, false), + 'contact_id' => $contact_id, 'created_at' => $now, 'updated_at' => $now, ]; From 9315f44d0bbd3051b1c30668bb9848ed5f5cd6a9 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:28:38 -0400 Subject: [PATCH 3/4] test(contact): add create_guest dedupe integration case for WordPress Covers the wire-up added in the previous commit: two widget submissions with casing variants on the same email yield one Contact row, and both tickets carry the same contact_id. --- tests/Test_Contact_Model.php | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/Test_Contact_Model.php b/tests/Test_Contact_Model.php index 1d110de..d393bce 100644 --- a/tests/Test_Contact_Model.php +++ b/tests/Test_Contact_Model.php @@ -62,4 +62,38 @@ public function test_decide_action_return_existing_when_no_incoming_name() $this->assertEquals('return-existing', Contact::decide_action($existing, null)); $this->assertEquals('return-existing', Contact::decide_action($existing, '')); } + + // ----------------------------------------------------------------- + // Wire-up: TicketService::create_guest sets contact_id + // ----------------------------------------------------------------- + + public function test_create_guest_dedupes_contacts_by_email() + { + $service = new \Escalated\Services\TicketService(); + + $t1 = $service->create_guest([ + 'subject' => 'First', + 'description' => 'body', + 'guest_name' => 'Alice', + 'guest_email' => 'alice@example.com', + 'channel' => 'web', + ]); + $t2 = $service->create_guest([ + 'subject' => 'Second', + 'description' => 'body', + 'guest_name' => 'Alice', + 'guest_email' => 'ALICE@Example.COM', // casing variant + 'channel' => 'web', + ]); + + global $wpdb; + $contacts_table = Contact::table(); + $count = (int) $wpdb->get_var( + $wpdb->prepare("SELECT COUNT(*) FROM {$contacts_table} WHERE email = %s", 'alice@example.com') + ); + $this->assertEquals(1, $count, 'repeat submissions should dedupe to one Contact row'); + $this->assertNotNull($t1->contact_id); + $this->assertNotNull($t2->contact_id); + $this->assertEquals((int) $t1->contact_id, (int) $t2->contact_id); + } } From 0702579b74ea82abd49151fddc2b4e14ba3d2784 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:06:58 -0400 Subject: [PATCH 4/4] style(contact): Laravel Pint autofix (new_with_parentheses + unary_operator_spaces) Fixes CI Pint check on PR #27. --- includes/Models/Contact.php | 16 ++++++++++++---- tests/Test_Contact_Model.php | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/includes/Models/Contact.php b/includes/Models/Contact.php index cbbf7c5..994b767 100644 --- a/includes/Models/Contact.php +++ b/includes/Models/Contact.php @@ -41,6 +41,7 @@ public static function normalize_email($email) if (! is_string($email)) { return ''; } + return strtolower(trim($email)); } @@ -49,9 +50,9 @@ public static function normalize_email($email) * lookup result and incoming name. Pure function — testable * without touching the database. * - * @param object|null $existing Row from wpdb->get_row or null + * @param object|null $existing Row from wpdb->get_row or null * @param string|null $incoming_name - * @return string One of 'create', 'update-name', 'return-existing' + * @return string One of 'create', 'update-name', 'return-existing' */ public static function decide_action($existing, $incoming_name) { @@ -62,6 +63,7 @@ public static function decide_action($existing, $incoming_name) if ((is_null($existing_name) || $existing_name === '') && ! empty($incoming_name)) { return 'update-name'; } + return 'return-existing'; } @@ -73,6 +75,7 @@ public static function find($id) { global $wpdb; $table = static::table(); + return $wpdb->get_row( $wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id) ); @@ -90,6 +93,7 @@ public static function find_by_email($email) if ($normalized === '') { return null; } + return $wpdb->get_row( $wpdb->prepare("SELECT * FROM {$table} WHERE email = %s", $normalized) ); @@ -103,7 +107,7 @@ public static function find_by_email($email) * * @param string $email * @param string|null $name - * @return object Row from wpdb->get_row + * @return object Row from wpdb->get_row */ public static function find_or_create_by_email($email, $name = null) { @@ -126,6 +130,7 @@ public static function find_or_create_by_email($email, $name = null) ['name' => $name, 'updated_at' => $now], ['id' => $existing->id] ); + return static::find($existing->id); } @@ -134,10 +139,11 @@ public static function find_or_create_by_email($email, $name = null) 'email' => $normalized, 'name' => $name ?: null, 'user_id' => null, - 'metadata' => wp_json_encode(new \stdClass()), + 'metadata' => wp_json_encode(new \stdClass), 'created_at' => $now, 'updated_at' => $now, ]); + return static::find((int) $wpdb->insert_id); } @@ -157,6 +163,7 @@ public static function link_to_user($contact_id, $user_id) ['user_id' => $user_id, 'updated_at' => current_time('mysql')], ['id' => $contact_id] ); + return static::find($contact_id); } @@ -178,6 +185,7 @@ public static function promote_to_user($contact_id, $user_id) ['requester_id' => $user_id, 'updated_at' => current_time('mysql')], ['contact_id' => $contact_id] ); + return $contact; } } diff --git a/tests/Test_Contact_Model.php b/tests/Test_Contact_Model.php index d393bce..c7fe596 100644 --- a/tests/Test_Contact_Model.php +++ b/tests/Test_Contact_Model.php @@ -69,7 +69,7 @@ public function test_decide_action_return_existing_when_no_incoming_name() public function test_create_guest_dedupes_contacts_by_email() { - $service = new \Escalated\Services\TicketService(); + $service = new \Escalated\Services\TicketService; $t1 = $service->create_guest([ 'subject' => 'First',