InteractionDetailsController.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. <?php
  2. namespace Controllers;
  3. use Libs\Database;
  4. use Libs\ResponseLib;
  5. use Psr\Http\Message\ServerRequestInterface;
  6. class InteractionDetailsController
  7. {
  8. private \PDO $pdo;
  9. public function __construct()
  10. {
  11. $this->pdo = Database::pdo();
  12. }
  13. public function __invoke(ServerRequestInterface $request)
  14. {
  15. $userId = (int) ($request->getAttribute('user_id') ?? 0);
  16. $conversationId = (int) (($request->getQueryParams()['conversation_id'] ?? 0));
  17. if ($userId <= 0) {
  18. return ResponseLib::sendFail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE')->withStatus(401);
  19. }
  20. if ($conversationId <= 0) {
  21. return ResponseLib::sendFail('Missing or invalid conversation_id', [], 'E_VALIDATE')->withStatus(400);
  22. }
  23. try {
  24. $companyId = $this->getCompanyIdByUserId($userId);
  25. if ($companyId === null) {
  26. return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
  27. }
  28. $conversation = $this->getConversation($companyId, $conversationId);
  29. if ($conversation === null) {
  30. return ResponseLib::sendFail('Conversation not found', [], 'E_NOT_FOUND')->withStatus(404);
  31. }
  32. $messages = $this->getMessages($conversationId);
  33. $report = $this->buildReport($conversation, $messages);
  34. return ResponseLib::sendOk([
  35. 'conversation' => [
  36. 'conversationId' => (int) $conversation['conversation_id'],
  37. 'client' => $conversation['client_phone'] ?? '',
  38. 'channel' => $this->formatChannel((string) ($conversation['conversation_channel'] ?? '')),
  39. 'agent' => $conversation['operator_name'] ?? '',
  40. ],
  41. 'thread' => array_map(function (array $message): array {
  42. return [
  43. 'id' => 'm' . (int) $message['message_id'],
  44. 'isAgent' => (bool) $message['message_is_operator'],
  45. 'text' => $message['message_content'] ?? '',
  46. 'time' => $this->formatTime((string) ($message['message_sent_at'] ?? '')),
  47. 'date' => $this->formatDate((string) ($message['message_sent_at'] ?? '')),
  48. ];
  49. }, $messages),
  50. 'report' => $report,
  51. ]);
  52. } catch (\Throwable $e) {
  53. return ResponseLib::sendFail('Failed to load interaction details', [], 'E_GENERIC')->withStatus(500);
  54. }
  55. }
  56. private function getCompanyIdByUserId(int $userId): ?int
  57. {
  58. $stmt = $this->pdo->prepare(
  59. "SELECT company_id
  60. FROM \"user\"
  61. WHERE user_id = :user_id
  62. AND user_deleted_at = 'infinity'
  63. LIMIT 1"
  64. );
  65. $stmt->execute(['user_id' => $userId]);
  66. $companyId = $stmt->fetchColumn();
  67. return $companyId === false ? null : (int) $companyId;
  68. }
  69. private function getConversation(int $companyId, int $conversationId): ?array
  70. {
  71. $stmt = $this->pdo->prepare(
  72. "SELECT
  73. c.conversation_id,
  74. c.conversation_channel,
  75. c.conversation_started_at,
  76. c.conversation_last_message_at,
  77. cl.client_phone,
  78. o.operator_name,
  79. ca.conversation_analysis_aspect,
  80. ca.conversation_analysis_sub_aspect,
  81. ca.conversation_analysis_sentiment,
  82. ca.conversation_analysis_sentiment_score
  83. FROM conversation c
  84. INNER JOIN client cl
  85. ON cl.client_id = c.client_id
  86. AND cl.client_deleted_at = 'infinity'
  87. INNER JOIN operator o
  88. ON o.operator_id = c.operator_id
  89. AND o.operator_deleted_at = 'infinity'
  90. LEFT JOIN conversation_analysis ca
  91. ON ca.conversation_id = c.conversation_id
  92. AND ca.conversation_analysis_deleted_at = 'infinity'
  93. WHERE c.company_id = :company_id
  94. AND c.conversation_id = :conversation_id
  95. AND c.conversation_deleted_at = 'infinity'
  96. LIMIT 1"
  97. );
  98. $stmt->execute([
  99. 'company_id' => $companyId,
  100. 'conversation_id' => $conversationId,
  101. ]);
  102. $conversation = $stmt->fetch(\PDO::FETCH_ASSOC);
  103. return $conversation === false ? null : $conversation;
  104. }
  105. private function getMessages(int $conversationId): array
  106. {
  107. $stmt = $this->pdo->prepare(
  108. "SELECT
  109. message_id,
  110. message_is_operator,
  111. message_content,
  112. message_sent_at
  113. FROM message
  114. WHERE conversation_id = :conversation_id
  115. AND message_deleted_at = 'infinity'
  116. AND message_deleted = FALSE
  117. AND message_hidden = FALSE
  118. AND message_is_event = FALSE
  119. ORDER BY message_sent_at ASC, message_id ASC"
  120. );
  121. $stmt->execute(['conversation_id' => $conversationId]);
  122. return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  123. }
  124. private function buildReport(array $conversation, array $messages): array
  125. {
  126. $avgResponseSeconds = $this->calculateAverageResponseSeconds($messages);
  127. $avgAgentSeconds = $this->calculateAverageGapByAuthor($messages, true);
  128. $avgClientSeconds = $this->calculateAverageGapByAuthor($messages, false);
  129. $totalDurationSeconds = $this->calculateTotalDurationSeconds($conversation, $messages);
  130. $lastMessageAuthor = $this->getLastMessageAuthor($messages);
  131. $consecutiveMessages = $this->hasTrailingConsecutiveMessages($messages);
  132. return [
  133. 'avgResponse' => $this->formatDuration($avgResponseSeconds),
  134. 'totalDuration' => $this->formatDuration($totalDurationSeconds),
  135. 'avgAgent' => $this->formatDuration($avgAgentSeconds),
  136. 'avgClient' => $this->formatDuration($avgClientSeconds),
  137. 'mainAspect' => $conversation['conversation_analysis_aspect'] ?? '',
  138. 'subAspect' => $conversation['conversation_analysis_sub_aspect'] ?? '',
  139. 'lastMessageAuthor' => $lastMessageAuthor,
  140. 'consecutiveMessages' => $consecutiveMessages,
  141. 'sentiment' => $this->normalizeSentimentLabel((string) ($conversation['conversation_analysis_sentiment'] ?? '')),
  142. 'score' => round((float) ($conversation['conversation_analysis_sentiment_score'] ?? 0), 2),
  143. ];
  144. }
  145. private function calculateAverageResponseSeconds(array $messages): int
  146. {
  147. $responseTimes = [];
  148. $pendingClientTimestamp = null;
  149. foreach ($messages as $message) {
  150. $timestamp = strtotime((string) ($message['message_sent_at'] ?? ''));
  151. if ($timestamp === false) {
  152. continue;
  153. }
  154. $isOperator = (bool) ($message['message_is_operator'] ?? false);
  155. if (!$isOperator) {
  156. if ($pendingClientTimestamp === null) {
  157. $pendingClientTimestamp = $timestamp;
  158. }
  159. continue;
  160. }
  161. if ($pendingClientTimestamp !== null && $timestamp >= $pendingClientTimestamp) {
  162. $responseTimes[] = $timestamp - $pendingClientTimestamp;
  163. $pendingClientTimestamp = null;
  164. }
  165. }
  166. if ($responseTimes === []) {
  167. return 0;
  168. }
  169. return (int) round(array_sum($responseTimes) / count($responseTimes));
  170. }
  171. private function calculateAverageGapByAuthor(array $messages, bool $isOperator): int
  172. {
  173. $timestamps = [];
  174. foreach ($messages as $message) {
  175. if ((bool) ($message['message_is_operator'] ?? false) !== $isOperator) {
  176. continue;
  177. }
  178. $timestamp = strtotime((string) ($message['message_sent_at'] ?? ''));
  179. if ($timestamp === false) {
  180. continue;
  181. }
  182. $timestamps[] = $timestamp;
  183. }
  184. if (count($timestamps) < 2) {
  185. return 0;
  186. }
  187. $gaps = [];
  188. for ($i = 1, $len = count($timestamps); $i < $len; $i++) {
  189. $gap = $timestamps[$i] - $timestamps[$i - 1];
  190. if ($gap >= 0) {
  191. $gaps[] = $gap;
  192. }
  193. }
  194. if ($gaps === []) {
  195. return 0;
  196. }
  197. return (int) round(array_sum($gaps) / count($gaps));
  198. }
  199. private function calculateTotalDurationSeconds(array $conversation, array $messages): int
  200. {
  201. if (count($messages) >= 2) {
  202. $first = strtotime((string) ($messages[0]['message_sent_at'] ?? ''));
  203. $last = strtotime((string) ($messages[count($messages) - 1]['message_sent_at'] ?? ''));
  204. if ($first !== false && $last !== false && $last >= $first) {
  205. return $last - $first;
  206. }
  207. }
  208. $startedAt = strtotime((string) ($conversation['conversation_started_at'] ?? ''));
  209. $lastMessageAt = strtotime((string) ($conversation['conversation_last_message_at'] ?? ''));
  210. if ($startedAt === false || $lastMessageAt === false || $lastMessageAt < $startedAt) {
  211. return 0;
  212. }
  213. return $lastMessageAt - $startedAt;
  214. }
  215. private function getLastMessageAuthor(array $messages): string
  216. {
  217. if ($messages === []) {
  218. return 'Cliente';
  219. }
  220. $lastMessage = $messages[count($messages) - 1];
  221. return (bool) ($lastMessage['message_is_operator'] ?? false) ? 'Operador' : 'Cliente';
  222. }
  223. private function hasTrailingConsecutiveMessages(array $messages): bool
  224. {
  225. $count = count($messages);
  226. if ($count < 2) {
  227. return false;
  228. }
  229. $last = (bool) ($messages[$count - 1]['message_is_operator'] ?? false);
  230. $previous = (bool) ($messages[$count - 2]['message_is_operator'] ?? false);
  231. return $last === $previous;
  232. }
  233. private function normalizeSentimentLabel(string $label): string
  234. {
  235. $normalized = trim($label);
  236. if ($normalized === '') {
  237. return 'NEUTRO';
  238. }
  239. return mb_strtoupper(str_replace('_', ' ', $normalized));
  240. }
  241. private function formatChannel(string $channel): string
  242. {
  243. $normalized = strtolower(trim($channel));
  244. if ($normalized === 'whatsapp') {
  245. return 'WhatsApp';
  246. }
  247. if ($normalized === '') {
  248. return '';
  249. }
  250. return ucfirst($normalized);
  251. }
  252. private function formatTime(string $dateTime): string
  253. {
  254. $timestamp = strtotime($dateTime);
  255. if ($timestamp === false) {
  256. return '00:00';
  257. }
  258. return date('H:i', $timestamp);
  259. }
  260. private function formatDate(string $dateTime): string
  261. {
  262. $timestamp = strtotime($dateTime);
  263. if ($timestamp === false) {
  264. return '';
  265. }
  266. return date('Y-m-d', $timestamp);
  267. }
  268. private function formatDuration(int $seconds): string
  269. {
  270. $seconds = max(0, $seconds);
  271. $minutes = intdiv($seconds, 60);
  272. $remainingSeconds = $seconds % 60;
  273. return str_pad((string) $minutes, 2, '0', STR_PAD_LEFT) . ':' . str_pad((string) $remainingSeconds, 2, '0', STR_PAD_LEFT);
  274. }
  275. }