InteractionsController.php 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. <?php
  2. namespace Controllers;
  3. use Libs\Database;
  4. use Libs\ResponseLib;
  5. use Psr\Http\Message\ServerRequestInterface;
  6. class InteractionsController
  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. $userEmail = (string) ($request->getAttribute('user_email') ?? '');
  17. if ($userId <= 0) {
  18. return ResponseLib::sendFail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE')->withStatus(401);
  19. }
  20. try {
  21. $companyId = $this->getCompanyIdByUserId($userId);
  22. if ($companyId === null) {
  23. return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
  24. }
  25. $filters = $this->normalizeFilters($request->getQueryParams());
  26. $myOperatorId = $this->getOperatorIdByUserEmail($companyId, $userEmail);
  27. [$whereSql, $params] = $this->buildWhereClause($companyId, $filters, $myOperatorId);
  28. $total = $this->getTotalCount($whereSql, $params);
  29. $items = $this->getItems($whereSql, $params, $filters['page'], $filters['per_page']);
  30. return ResponseLib::sendOk([
  31. 'items' => $items,
  32. 'pagination' => [
  33. 'page' => $filters['page'],
  34. 'per_page' => $filters['per_page'],
  35. 'total' => $total,
  36. 'total_pages' => $filters['per_page'] > 0 ? (int) ceil($total / $filters['per_page']) : 0,
  37. ],
  38. ]);
  39. } catch (\Throwable $e) {
  40. return ResponseLib::sendFail('Failed to load interactions', [], 'E_GENERIC')->withStatus(500);
  41. }
  42. }
  43. private function getCompanyIdByUserId(int $userId): ?int
  44. {
  45. $stmt = $this->pdo->prepare(
  46. "SELECT company_id
  47. FROM \"user\"
  48. WHERE user_id = :user_id
  49. AND user_deleted_at = 'infinity'
  50. LIMIT 1"
  51. );
  52. $stmt->execute(['user_id' => $userId]);
  53. $companyId = $stmt->fetchColumn();
  54. return $companyId === false ? null : (int) $companyId;
  55. }
  56. private function getOperatorIdByUserEmail(int $companyId, string $userEmail): ?int
  57. {
  58. $normalizedEmail = mb_strtolower(trim($userEmail));
  59. if ($normalizedEmail === '') {
  60. return null;
  61. }
  62. $stmt = $this->pdo->prepare(
  63. "SELECT operator_id
  64. FROM operator
  65. WHERE company_id = :company_id
  66. AND operator_deleted_at = 'infinity'
  67. AND lower(operator_email) = :email
  68. LIMIT 1"
  69. );
  70. $stmt->execute([
  71. 'company_id' => $companyId,
  72. 'email' => $normalizedEmail,
  73. ]);
  74. $operatorId = $stmt->fetchColumn();
  75. return $operatorId === false ? null : (int) $operatorId;
  76. }
  77. private function normalizeFilters(array $queryParams): array
  78. {
  79. $page = max(1, (int) ($queryParams['page'] ?? 1));
  80. $perPage = (int) ($queryParams['per_page'] ?? 20);
  81. $perPage = max(1, min(100, $perPage));
  82. $filter = strtolower(trim((string) ($queryParams['filter'] ?? 'all')));
  83. if (!in_array($filter, ['all', 'my_clients', 'new', 'unfinished'], true)) {
  84. $filter = 'all';
  85. }
  86. $sentiment = strtolower(trim((string) ($queryParams['sentiment'] ?? 'all')));
  87. if (!in_array($sentiment, ['all', 'positive', 'neutral', 'negative'], true)) {
  88. $sentiment = 'all';
  89. }
  90. return [
  91. 'page' => $page,
  92. 'per_page' => $perPage,
  93. 'search' => trim((string) ($queryParams['search'] ?? '')),
  94. 'filter' => $filter,
  95. 'sentiment' => $sentiment,
  96. 'operator_id' => max(0, (int) ($queryParams['operator_id'] ?? 0)),
  97. ];
  98. }
  99. private function buildWhereClause(int $companyId, array $filters, ?int $myOperatorId): array
  100. {
  101. $where = [
  102. "c.company_id = :company_id",
  103. "c.conversation_deleted_at = 'infinity'",
  104. "cl.client_deleted_at = 'infinity'",
  105. "o.operator_deleted_at = 'infinity'",
  106. ];
  107. $params = ['company_id' => $companyId];
  108. if ($filters['search'] !== '') {
  109. $where[] = '(cl.client_name ILIKE :search OR cl.client_phone ILIKE :search OR o.operator_name ILIKE :search OR c.conversation_last_message_preview ILIKE :search)';
  110. $params['search'] = '%' . $filters['search'] . '%';
  111. }
  112. if ($filters['operator_id'] > 0) {
  113. $where[] = 'c.operator_id = :operator_id';
  114. $params['operator_id'] = $filters['operator_id'];
  115. }
  116. if ($filters['filter'] === 'unfinished') {
  117. $where[] = "lower(c.conversation_status) <> 'closed'";
  118. }
  119. if ($filters['filter'] === 'new') {
  120. $where[] = "c.conversation_started_at >= NOW() - INTERVAL '24 hours'";
  121. }
  122. if ($filters['filter'] === 'my_clients') {
  123. if ($myOperatorId !== null) {
  124. $where[] = 'c.operator_id = :my_operator_id';
  125. $params['my_operator_id'] = $myOperatorId;
  126. } else {
  127. $where[] = '1 = 0';
  128. }
  129. }
  130. if ($filters['sentiment'] !== 'all') {
  131. $where[] = $this->getSentimentWhereClause('ca', $filters['sentiment']);
  132. }
  133. return [implode("\n AND ", $where), $params];
  134. }
  135. private function getTotalCount(string $whereSql, array $params): int
  136. {
  137. $stmt = $this->pdo->prepare(
  138. "SELECT COUNT(*)
  139. FROM conversation c
  140. INNER JOIN client cl ON cl.client_id = c.client_id
  141. INNER JOIN operator o ON o.operator_id = c.operator_id
  142. LEFT JOIN conversation_analysis ca
  143. ON ca.conversation_id = c.conversation_id
  144. AND ca.conversation_analysis_deleted_at = 'infinity'
  145. WHERE {$whereSql}"
  146. );
  147. $stmt->execute($params);
  148. return (int) $stmt->fetchColumn();
  149. }
  150. private function getItems(string $whereSql, array $params, int $page, int $perPage): array
  151. {
  152. $offset = ($page - 1) * $perPage;
  153. $params['limit'] = $perPage;
  154. $params['offset'] = $offset;
  155. $stmt = $this->pdo->prepare(
  156. "SELECT
  157. c.conversation_id,
  158. cl.client_phone,
  159. o.operator_name,
  160. COALESCE(ca.conversation_analysis_sentiment, c.conversation_status) AS sentiment_label,
  161. COALESCE(ca.conversation_analysis_sentiment_score, 0) AS sentiment_score,
  162. COALESCE(ca.conversation_analysis_aspect, '') AS aspect,
  163. COALESCE(ca.conversation_analysis_sub_aspect, '') AS sub_aspect,
  164. c.conversation_last_message_at
  165. FROM conversation c
  166. INNER JOIN client cl ON cl.client_id = c.client_id
  167. INNER JOIN operator o ON o.operator_id = c.operator_id
  168. LEFT JOIN conversation_analysis ca
  169. ON ca.conversation_id = c.conversation_id
  170. AND ca.conversation_analysis_deleted_at = 'infinity'
  171. WHERE {$whereSql}
  172. ORDER BY c.conversation_last_message_at DESC, c.conversation_id DESC
  173. LIMIT :limit OFFSET :offset"
  174. );
  175. foreach ($params as $key => $value) {
  176. if (in_array($key, ['limit', 'offset', 'operator_id', 'my_operator_id', 'company_id'], true)) {
  177. $stmt->bindValue(':' . $key, (int) $value, \PDO::PARAM_INT);
  178. continue;
  179. }
  180. $stmt->bindValue(':' . $key, $value);
  181. }
  182. $stmt->execute();
  183. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  184. return array_map(function (array $row): array {
  185. return [
  186. 'conversationId' => (int) $row['conversation_id'],
  187. 'client' => $row['client_phone'] ?? '',
  188. 'agent' => $row['operator_name'] ?? '',
  189. 'sentiment' => $this->normalizeSentimentLabel((string) ($row['sentiment_label'] ?? '')),
  190. 'score' => round((float) ($row['sentiment_score'] ?? 0), 2),
  191. 'aspect' => $row['aspect'] ?? '',
  192. 'subaspect' => $row['sub_aspect'] ?? '',
  193. 'datetime' => $this->formatIsoDateTime($row['conversation_last_message_at'] ?? null),
  194. ];
  195. }, $rows);
  196. }
  197. private function getSentimentWhereClause(string $analysisAlias, string $sentiment): string
  198. {
  199. if ($sentiment === 'positive') {
  200. return "(
  201. lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('positive', 'positivo')
  202. OR {$analysisAlias}.conversation_analysis_sentiment_score >= 0.15
  203. )";
  204. }
  205. if ($sentiment === 'negative') {
  206. return "(
  207. lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('negative', 'negativo')
  208. OR {$analysisAlias}.conversation_analysis_sentiment_score <= -0.15
  209. )";
  210. }
  211. return "(
  212. {$analysisAlias}.conversation_id IS NOT NULL
  213. AND lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) NOT IN ('positive', 'positivo', 'negative', 'negativo')
  214. AND {$analysisAlias}.conversation_analysis_sentiment_score > -0.15
  215. AND {$analysisAlias}.conversation_analysis_sentiment_score < 0.15
  216. )";
  217. }
  218. private function normalizeSentimentLabel(string $label): string
  219. {
  220. $normalized = trim($label);
  221. if ($normalized === '') {
  222. return 'NEUTRO';
  223. }
  224. return mb_strtoupper(str_replace('_', ' ', $normalized));
  225. }
  226. private function formatIsoDateTime(?string $dateTime): ?string
  227. {
  228. if (!$dateTime) {
  229. return null;
  230. }
  231. $timestamp = strtotime($dateTime);
  232. if ($timestamp === false) {
  233. return null;
  234. }
  235. return gmdate('Y-m-d\TH:i:s\Z', $timestamp);
  236. }
  237. }