diff --git a/includes/Models/Contact.php b/includes/Models/Contact.php new file mode 100644 index 0000000..994b767 --- /dev/null +++ b/includes/Models/Contact.php @@ -0,0 +1,191 @@ +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/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, ]; 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..c7fe596 --- /dev/null +++ b/tests/Test_Contact_Model.php @@ -0,0 +1,99 @@ +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, '')); + } + + // ----------------------------------------------------------------- + // 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); + } +}