diff --git a/includes/Models/SideConversation.php b/includes/Models/SideConversation.php new file mode 100644 index 0000000..137861b --- /dev/null +++ b/includes/Models/SideConversation.php @@ -0,0 +1,121 @@ +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 + ) + ) ?: []; + } +} diff --git a/includes/Models/SideConversationReply.php b/includes/Models/SideConversationReply.php new file mode 100644 index 0000000..ad17df9 --- /dev/null +++ b/includes/Models/SideConversationReply.php @@ -0,0 +1,60 @@ +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 + ) + ) ?: []; + } +} diff --git a/includes/Services/SideConversationService.php b/includes/Services/SideConversationService.php new file mode 100644 index 0000000..1aff910 --- /dev/null +++ b/includes/Services/SideConversationService.php @@ -0,0 +1,108 @@ + $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; + } +} diff --git a/includes/class-activator.php b/includes/class-activator.php index 70188c9..367cec0 100644 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -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); } /** diff --git a/tests/Test_Side_Conversation_Service.php b/tests/Test_Side_Conversation_Service.php new file mode 100644 index 0000000..75aef67 --- /dev/null +++ b/tests/Test_Side_Conversation_Service.php @@ -0,0 +1,85 @@ +assertTrue(SideConversation::valid_channel('internal')); + $this->assertTrue(SideConversation::valid_channel('email')); + $this->assertFalse(SideConversation::valid_channel('sms')); + $this->assertFalse(SideConversation::valid_channel('')); + } + + // --------------------------------------------------------------------- + // Service flow (live wpdb) + // --------------------------------------------------------------------- + + public function test_create_opens_conversation_with_first_reply() + { + $service = new SideConversationService; + + $id = $service->create(555, 'Need vendor input', 'internal', 'Can you advise?', 7); + $this->assertNotFalse($id); + + $conversation = SideConversation::find($id); + $this->assertEquals('open', $conversation->status); + $this->assertEquals('Need vendor input', $conversation->subject); + + $replies = SideConversationReply::for_conversation($id); + $this->assertCount(1, $replies); + $this->assertEquals('Can you advise?', $replies[0]->body); + } + + public function test_create_rejects_invalid_input() + { + $service = new SideConversationService; + + $this->assertFalse($service->create(555, '', 'internal', 'body')); + $this->assertFalse($service->create(555, 'Subject', 'sms', 'body')); + $this->assertFalse($service->create(555, 'Subject', 'internal', ' ')); + } + + public function test_add_reply_and_close() + { + $service = new SideConversationService; + + $id = $service->create(556, 'Thread', 'email', 'First', 7); + $this->assertNotFalse($service->add_reply($id, 'Second', 9)); + $this->assertFalse($service->add_reply($id, ' ')); + + $replies = SideConversationReply::for_conversation($id); + $this->assertCount(2, $replies); + + $service->close($id); + $this->assertEquals('closed', SideConversation::find($id)->status); + } + + public function test_for_ticket_attaches_replies_newest_first() + { + $service = new SideConversationService; + + $service->create(557, 'Older', 'internal', 'a', 7); + $newer = $service->create(557, 'Newer', 'internal', 'b', 7); + $service->add_reply($newer, 'b2', 7); + + $threads = $service->for_ticket(557); + $this->assertCount(2, $threads); + $this->assertEquals('Newer', $threads[0]->subject); + $this->assertCount(2, $threads[0]->replies); + } +}