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
121 changes: 121 additions & 0 deletions includes/Models/SideConversation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

namespace Escalated\Models;

use Escalated\Escalated;

/**
* A side conversation — a private thread (internal note or outbound email)
* attached to a ticket, used by agents to consult colleagues or third
* parties without exposing the main customer thread. Mirrors the Laravel
* SideConversation model.
*/
class SideConversation
{
const CHANNEL_INTERNAL = 'internal';

const CHANNEL_EMAIL = 'email';

const STATUS_OPEN = 'open';

const STATUS_CLOSED = 'closed';

/**
* Get the table name.
*
* @return string
*/
public static function table()
{
return Escalated::table('side_conversations');
}

// ---------------------------------------------------------------------
// Pure helpers (no database)
// ---------------------------------------------------------------------

/**
* Whether a channel value is one of the accepted values.
*
* @param string $channel
* @return bool
*/
public static function valid_channel($channel)
{
return in_array($channel, [self::CHANNEL_INTERNAL, self::CHANNEL_EMAIL], true);
}

// ---------------------------------------------------------------------
// Database access
// ---------------------------------------------------------------------

/**
* Find a side conversation by ID.
*
* @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)
);
}

/**
* Create a new side conversation.
*
* @return int|false Inserted ID or false on failure.
*/
public static function create(array $data)
{
global $wpdb;
$table = static::table();
$now = current_time('mysql');

$data['created_at'] = $now;
$data['updated_at'] = $now;

$result = $wpdb->insert($table, $data);

return $result !== false ? $wpdb->insert_id : false;
}

/**
* Update a side conversation.
*
* @param int $id
* @return bool
*/
public static function update($id, array $data)
{
global $wpdb;
$table = static::table();

$data['updated_at'] = current_time('mysql');

return $wpdb->update($table, $data, ['id' => $id]) !== false;
}

/**
* All side conversations for a ticket, newest first.
*
* @param int $ticket_id
* @return array
*/
public static function for_ticket($ticket_id)
{
global $wpdb;
$table = static::table();

return $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE ticket_id = %d ORDER BY created_at DESC, id DESC",
$ticket_id
)
) ?: [];
}
}
60 changes: 60 additions & 0 deletions includes/Models/SideConversationReply.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace Escalated\Models;

use Escalated\Escalated;

/**
* A single message within a SideConversation. Mirrors the Laravel
* SideConversationReply model.
*/
class SideConversationReply
{
/**
* Get the table name.
*
* @return string
*/
public static function table()
{
return Escalated::table('side_conversation_replies');
}

/**
* Create a new reply.
*
* @return int|false Inserted ID or false on failure.
*/
public static function create(array $data)
{
global $wpdb;
$table = static::table();
$now = current_time('mysql');

$data['created_at'] = $now;
$data['updated_at'] = $now;

$result = $wpdb->insert($table, $data);

return $result !== false ? $wpdb->insert_id : false;
}

/**
* All replies for a conversation, oldest first.
*
* @param int $side_conversation_id
* @return array
*/
public static function for_conversation($side_conversation_id)
{
global $wpdb;
$table = static::table();

return $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE side_conversation_id = %d ORDER BY created_at ASC, id ASC",
$side_conversation_id
)
) ?: [];
}
}
108 changes: 108 additions & 0 deletions includes/Services/SideConversationService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

namespace Escalated\Services;

use Escalated\Models\SideConversation;
use Escalated\Models\SideConversationReply;

/**
* Manages side conversations (private internal/email threads on a ticket).
* Mirrors the Laravel SideConversationController: creating a conversation
* opens it with a first reply, replies can be appended, and a conversation
* can be closed.
*/
class SideConversationService
{
/**
* Open a new side conversation on a ticket with its first reply.
*
* @param int $ticket_id
* @param string $subject
* @param string $channel
* @param string $body
* @param int|string|null $created_by
* @return int|false The new conversation ID, or false on invalid input.
*/
public function create($ticket_id, $subject, $channel, $body, $created_by = null)
{
$subject = trim((string) $subject);
$body = trim((string) $body);

if ($subject === '' || $body === '' || ! SideConversation::valid_channel($channel)) {
return false;
}

$conversation_id = SideConversation::create([
'ticket_id' => $ticket_id,
'subject' => $subject,
'channel' => $channel,
'status' => SideConversation::STATUS_OPEN,
'created_by' => $created_by,
]);

if ($conversation_id === false) {
return false;
}

SideConversationReply::create([
'side_conversation_id' => $conversation_id,
'body' => $body,
'author_id' => $created_by,
]);

return $conversation_id;
}

/**
* Append a reply to a conversation.
*
* @param int $conversation_id
* @param string $body
* @param int|string|null $author_id
* @return int|false The new reply ID, or false on invalid input.
*/
public function add_reply($conversation_id, $body, $author_id = null)
{
$body = trim((string) $body);
if ($body === '') {
return false;
}

return SideConversationReply::create([
'side_conversation_id' => $conversation_id,
'body' => $body,
'author_id' => $author_id,
]);
}

/**
* Close a side conversation.
*
* @param int $conversation_id
* @return bool
*/
public function close($conversation_id)
{
return SideConversation::update($conversation_id, [
'status' => SideConversation::STATUS_CLOSED,
]);
}

/**
* All side conversations for a ticket (newest first), each with its
* replies attached as a `replies` property.
*
* @param int $ticket_id
* @return array
*/
public function for_ticket($ticket_id)
{
$conversations = SideConversation::for_ticket($ticket_id);

foreach ($conversations as $conversation) {
$conversation->replies = SideConversationReply::for_conversation($conversation->id);
}

return $conversations;
}
}
28 changes: 28 additions & 0 deletions includes/class-activator.php
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,34 @@ private static function create_tables(): void
UNIQUE KEY user_channel (user_id, channel)
) $charset_collate;";
dbDelta($sql);

// 32. escalated_side_conversations
$sql = "CREATE TABLE {$prefix}side_conversations (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ticket_id BIGINT UNSIGNED NOT NULL,
subject VARCHAR(255) NOT NULL,
channel VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
created_by BIGINT UNSIGNED NULL,
created_at DATETIME,
updated_at DATETIME,
PRIMARY KEY (id),
KEY ticket_id (ticket_id)
) $charset_collate;";
dbDelta($sql);

// 33. escalated_side_conversation_replies
$sql = "CREATE TABLE {$prefix}side_conversation_replies (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
side_conversation_id BIGINT UNSIGNED NOT NULL,
body LONGTEXT NOT NULL,
author_id BIGINT UNSIGNED NULL,
created_at DATETIME,
updated_at DATETIME,
PRIMARY KEY (id),
KEY side_conversation_id (side_conversation_id)
) $charset_collate;";
dbDelta($sql);
}

/**
Expand Down
Loading