Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions includes/Models/Contact.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
<?php

namespace Escalated\Models;

use Escalated\Escalated;

/**
* First-class identity for guest requesters (Pattern B).
*
* Deduped by email (unique index, case-insensitively normalized
* before write). Links to a WordPress user via `user_id` once the
* guest accepts a signup invite.
*
* Coexists with the inline `guest_name` / `guest_email` /
* `guest_token` columns on Ticket for backwards compatibility —
* a follow-up pass backfills `contact_id` from `guest_email`.
* New code should resolve contacts via
* {@see Contact::find_or_create_by_email}.
*
* @see https://github.com/escalated-dev/escalated-nestjs/pull/17 reference impl
*/
class Contact
{
/**
* @return string
*/
public static function table()
{
return Escalated::table('contacts');
}

/**
* Canonical email normalization: trim + lowercase. Always call
* on any caller-supplied email before inserting or looking up.
*
* @param string|null $email
* @return string
*/
public static function normalize_email($email)
{
if (! is_string($email)) {
return '';
}

return strtolower(trim($email));
}

/**
* Decide what {@see find_or_create_by_email} should do given the
* lookup result and incoming name. Pure function — testable
* without touching the database.
*
* @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'
*/
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;
}
}
21 changes: 19 additions & 2 deletions includes/Services/TicketService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
];
Expand Down
20 changes: 19 additions & 1 deletion includes/class-activator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
99 changes: 99 additions & 0 deletions tests/Test_Contact_Model.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

/**
* Tests for the Contact model (Pattern B public-ticket dedupe).
*
* Pure-function tests for normalize_email / decide_action; the
* find_or_create_by_email flow is exercised via a live wpdb
* (the test suite's SQLite/mysql harness).
*/

use Escalated\Models\Contact;

class Test_Contact_Model extends WP_UnitTestCase
{
// ---------------------------------------------------------------------
// normalize_email
// ---------------------------------------------------------------------

public function test_normalize_email_lowercases()
{
$this->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);
}
}
Loading