Selaa lähdekoodia

refactor: move dashboard and persona data logic into models

EduLascala 3 viikkoa sitten
vanhempi
sitoutus
2cb2ada6a6

+ 42 - 0
controllers/AnalyticsSentimentDashboardController.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\AnalyticsSentimentDashboardModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class AnalyticsSentimentDashboardController
+{
+    private UserModel $userModel;
+    private AnalyticsSentimentDashboardModel $analyticsSentimentDashboardModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->analyticsSentimentDashboardModel = new AnalyticsSentimentDashboardModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+
+        if ($userId <= 0) {
+            return ResponseLib::sendFail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        try {
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
+            }
+
+            return ResponseLib::sendOk(
+                $this->analyticsSentimentDashboardModel->getDashboardData($companyId, $request->getQueryParams())
+            );
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Failed to load sentiment dashboard', [], 'E_GENERIC')->withStatus(500);
+        }
+    }
+}

+ 10 - 531
controllers/DashboardOverviewController.php

@@ -2,17 +2,20 @@
 
 namespace Controllers;
 
-use Libs\Database;
 use Libs\ResponseLib;
+use Models\DashboardOverviewModel;
+use Models\UserModel;
 use Psr\Http\Message\ServerRequestInterface;
 
 class DashboardOverviewController
 {
-    private \PDO $pdo;
+    private UserModel $userModel;
+    private DashboardOverviewModel $dashboardOverviewModel;
 
     public function __construct()
     {
-        $this->pdo = Database::pdo();
+        $this->userModel = new UserModel();
+        $this->dashboardOverviewModel = new DashboardOverviewModel();
     }
 
     public function __invoke(ServerRequestInterface $request)
@@ -24,541 +27,17 @@ class DashboardOverviewController
         }
 
         try {
-            $companyId = $this->getCompanyIdByUserId($userId);
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
 
             if ($companyId === null) {
                 return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
             }
 
-            $filters = $this->normalizeFilters($request->getQueryParams());
-            $range = $this->resolveDateRange($filters['period']);
-
-            return ResponseLib::sendOk([
-                'kpis' => $this->getKpis($companyId, $filters, $range),
-                'priorityQueue' => $this->getPriorityQueue($companyId, $filters, $range),
-                'radarData' => $this->getRadarData($companyId, $range),
-                'volumeData' => $this->getVolumeData($companyId, $range),
-                'aspectsData' => $this->getAspectsData($companyId, $filters, $range),
-                'aspectsDrilldown' => $this->getAspectsDrilldown($companyId, $filters, $range),
-            ]);
+            return ResponseLib::sendOk(
+                $this->dashboardOverviewModel->getOverviewData($companyId, $request->getQueryParams())
+            );
         } catch (\Throwable $e) {
             return ResponseLib::sendFail('Failed to load dashboard overview', [], 'E_GENERIC')->withStatus(500);
         }
     }
-
-    private function getCompanyIdByUserId(int $userId): ?int
-    {
-        $stmt = $this->pdo->prepare(
-            "SELECT company_id
-            FROM \"user\"
-            WHERE user_id = :user_id
-              AND user_deleted_at = 'infinity'
-            LIMIT 1"
-        );
-        $stmt->execute(['user_id' => $userId]);
-        $companyId = $stmt->fetchColumn();
-
-        return $companyId === false ? null : (int) $companyId;
-    }
-
-    private function normalizeFilters(array $queryParams): array
-    {
-        $period = strtolower((string) ($queryParams['period'] ?? 'week'));
-        $unit = strtolower((string) ($queryParams['unit'] ?? 'all'));
-        $area = strtolower((string) ($queryParams['area'] ?? 'all'));
-        $sentiment = strtolower((string) ($queryParams['sentiment'] ?? 'all'));
-        $volumeView = strtolower((string) ($queryParams['volume_view'] ?? 'day'));
-
-        if (!in_array($period, ['today', 'yesterday', 'week'], true)) {
-            $period = 'week';
-        }
-
-        if (!in_array($sentiment, ['all', 'positive', 'neutral', 'negative'], true)) {
-            $sentiment = 'all';
-        }
-
-        if (!in_array($volumeView, ['hour', 'day'], true)) {
-            $volumeView = 'day';
-        }
-
-        return [
-            'period' => $period,
-            'unit' => $unit,
-            'area' => $area,
-            'sentiment' => $sentiment,
-            'volume_view' => $volumeView,
-        ];
-    }
-
-    private function resolveDateRange(string $period): array
-    {
-        $today = new \DateTimeImmutable('today');
-
-        if ($period === 'today') {
-            $start = $today;
-            $end = $today;
-        } elseif ($period === 'yesterday') {
-            $start = $today->modify('-1 day');
-            $end = $start;
-        } else {
-            $start = $today->modify('-6 days');
-            $end = $today;
-        }
-
-        return [
-            'start_date' => $start->format('Y-m-d'),
-            'end_date' => $end->format('Y-m-d'),
-            'start_datetime' => $start->format('Y-m-d 00:00:00'),
-            'end_exclusive_datetime' => $end->modify('+1 day')->format('Y-m-d 00:00:00'),
-        ];
-    }
-
-    private function getKpis(int $companyId, array $filters, array $range): array
-    {
-        $registeredStmt = $this->pdo->prepare(
-            "SELECT COUNT(*)
-            FROM client
-            WHERE company_id = :company_id
-              AND client_deleted_at = 'infinity'
-              AND client_is_registered = TRUE"
-        );
-        $registeredStmt->execute(['company_id' => $companyId]);
-
-        $unregisteredStmt = $this->pdo->prepare(
-            "SELECT COUNT(*)
-            FROM client
-            WHERE company_id = :company_id
-              AND client_deleted_at = 'infinity'
-              AND client_is_registered = FALSE"
-        );
-        $unregisteredStmt->execute(['company_id' => $companyId]);
-
-        $activeOperatorsSql = "SELECT COUNT(*)
-            FROM operator
-            WHERE company_id = :company_id
-              AND operator_deleted_at = 'infinity'
-              AND lower(operator_status) <> 'inativo'";
-        $activeOperatorsParams = ['company_id' => $companyId];
-
-        if ($filters['area'] !== 'all') {
-            $activeOperatorsSql .= " AND lower(operator_department) = :area";
-            $activeOperatorsParams['area'] = $filters['area'];
-        }
-
-        $activeOperatorsStmt = $this->pdo->prepare($activeOperatorsSql);
-        $activeOperatorsStmt->execute($activeOperatorsParams);
-
-        $conversationSql = "SELECT
-                COUNT(DISTINCT c.conversation_id) AS total_conversations,
-                COALESCE(AVG(ca.conversation_analysis_sentiment_score), 0) AS general_sentiment_score
-            FROM conversation c
-            INNER JOIN operator o ON o.operator_id = c.operator_id
-            LEFT JOIN conversation_analysis ca
-                ON ca.conversation_id = c.conversation_id
-               AND ca.conversation_analysis_deleted_at = 'infinity'
-            WHERE c.company_id = :company_id
-              AND c.conversation_deleted_at = 'infinity'
-              AND c.conversation_started_at >= :start_datetime
-              AND c.conversation_started_at < :end_exclusive_datetime
-              AND o.operator_deleted_at = 'infinity'";
-        $conversationParams = [
-            'company_id' => $companyId,
-            'start_datetime' => $range['start_datetime'],
-            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
-        ];
-
-        if ($filters['area'] !== 'all') {
-            $conversationSql .= " AND lower(o.operator_department) = :area";
-            $conversationParams['area'] = $filters['area'];
-        }
-
-        if ($filters['sentiment'] !== 'all') {
-            $conversationSql .= ' AND ' . $this->getSentimentWhereClause('ca', $filters['sentiment']);
-        }
-
-        $conversationStmt = $this->pdo->prepare($conversationSql);
-        $conversationStmt->execute($conversationParams);
-        $conversationMetrics = $conversationStmt->fetch(\PDO::FETCH_ASSOC) ?: [];
-
-        return [
-            'registeredUsers' => (int) $registeredStmt->fetchColumn(),
-            'activeAgents' => (int) $activeOperatorsStmt->fetchColumn(),
-            'totalConversations' => (int) ($conversationMetrics['total_conversations'] ?? 0),
-            'generalSentimentScore' => round((float) ($conversationMetrics['general_sentiment_score'] ?? 0), 2),
-            'unregisteredUsers' => (int) $unregisteredStmt->fetchColumn(),
-        ];
-    }
-
-    private function getPriorityQueue(int $companyId, array $filters, array $range): array
-    {
-        $sql = "SELECT
-                c.conversation_id AS id,
-                cl.client_name AS customer_name,
-                cl.client_segment AS segment,
-                COALESCE(NULLIF(a.alert_title, ''), ca.conversation_analysis_sentiment, c.conversation_status) AS status_label,
-                c.conversation_sla_deadline,
-                c.conversation_last_message_at,
-                o.operator_name AS seller_name,
-                c.conversation_last_message_preview AS last_message,
-                COALESCE(NULLIF(a.alert_description, ''), CONCAT(ca.conversation_analysis_aspect, ' — ', ca.conversation_analysis_sub_aspect), c.conversation_last_message_preview) AS motive,
-                c.conversation_impact_value,
-                c.conversation_ticket_value,
-                c.conversation_conversion_chance,
-                c.conversation_optimum_window,
-                c.client_id,
-                c.operator_id
-            FROM conversation c
-            INNER JOIN client cl ON cl.client_id = c.client_id AND cl.client_deleted_at = 'infinity'
-            INNER JOIN operator o ON o.operator_id = c.operator_id AND o.operator_deleted_at = 'infinity'
-            LEFT JOIN conversation_analysis ca
-                ON ca.conversation_id = c.conversation_id
-               AND ca.conversation_analysis_deleted_at = 'infinity'
-            LEFT JOIN LATERAL (
-                SELECT alert_title, alert_description
-                FROM alert
-                WHERE company_id = c.company_id
-                  AND client_id = c.client_id
-                  AND alert_deleted_at = 'infinity'
-                  AND alert_is_resolved = FALSE
-                ORDER BY alert_created_at DESC
-                LIMIT 1
-            ) a ON TRUE
-            WHERE c.company_id = :company_id
-              AND c.conversation_deleted_at = 'infinity'
-              AND c.conversation_started_at >= :start_datetime
-              AND c.conversation_started_at < :end_exclusive_datetime";
-        $params = [
-            'company_id' => $companyId,
-            'start_datetime' => $range['start_datetime'],
-            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
-        ];
-
-        if ($filters['area'] !== 'all') {
-            $sql .= " AND lower(o.operator_department) = :area";
-            $params['area'] = $filters['area'];
-        }
-
-        if ($filters['sentiment'] !== 'all') {
-            $sql .= ' AND ' . $this->getSentimentWhereClause('ca', $filters['sentiment']);
-        }
-
-        $sql .= " ORDER BY (c.conversation_sla_deadline < NOW()) DESC,
-                         ROUND(c.conversation_impact_value * (c.conversation_conversion_chance / 100.0)) DESC,
-                         c.conversation_last_message_at ASC
-                  LIMIT 10";
-
-        $stmt = $this->pdo->prepare($sql);
-        $stmt->execute($params);
-        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
-
-        return array_map(function (array $row): array {
-            $impact = (float) ($row['conversation_impact_value'] ?? 0);
-            $chance = (int) ($row['conversation_conversion_chance'] ?? 0);
-
-            return [
-                'id' => (int) $row['id'],
-                'customerName' => $row['customer_name'] ?? '',
-                'segment' => $row['segment'] ?? '',
-                'status' => $this->normalizeStatusLabel((string) ($row['status_label'] ?? 'open')),
-                'slaStatus' => $this->formatSlaStatus($row['conversation_sla_deadline'] ?? null),
-                'timeAgo' => $this->formatRelativeTime($row['conversation_last_message_at'] ?? null),
-                'sellerName' => $row['seller_name'] ?? '',
-                'lastMessage' => $row['last_message'] ?? '',
-                'motive' => $row['motive'] ?? '',
-                'impact' => (int) round($impact),
-                'ticket' => (int) round((float) ($row['conversation_ticket_value'] ?? 0)),
-                'chance' => $chance,
-                'optimumWindow' => $row['conversation_optimum_window'] ?? '',
-                'score' => (int) round($impact * ($chance / 100)),
-                'conversationId' => (int) $row['id'],
-                'clientId' => (int) ($row['client_id'] ?? 0),
-                'operatorId' => (int) ($row['operator_id'] ?? 0),
-            ];
-        }, $rows);
-    }
-
-    private function getRadarData(int $companyId, array $range): array
-    {
-        $stmt = $this->pdo->prepare(
-            "SELECT
-                emotion_confidence,
-                emotion_happiness,
-                emotion_anticipation,
-                emotion_fear,
-                emotion_sadness,
-                emotion_anger
-            FROM emotion_snapshot
-            WHERE company_id = :company_id
-              AND emotion_snapshot_date BETWEEN :start_date AND :end_date
-            ORDER BY emotion_snapshot_date DESC
-            LIMIT 1"
-        );
-        $stmt->execute([
-            'company_id' => $companyId,
-            'start_date' => $range['start_date'],
-            'end_date' => $range['end_date'],
-        ]);
-        $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
-
-        return [
-            ['name' => 'Confiança', 'value' => (float) ($row['emotion_confidence'] ?? 0)],
-            ['name' => 'Alegria', 'value' => (float) ($row['emotion_happiness'] ?? 0)],
-            ['name' => 'Antecipação', 'value' => (float) ($row['emotion_anticipation'] ?? 0)],
-            ['name' => 'Medo', 'value' => (float) ($row['emotion_fear'] ?? 0)],
-            ['name' => 'Tristeza', 'value' => (float) ($row['emotion_sadness'] ?? 0)],
-            ['name' => 'Raiva', 'value' => (float) ($row['emotion_anger'] ?? 0)],
-        ];
-    }
-
-    private function getVolumeData(int $companyId, array $range): array
-    {
-        $stmt = $this->pdo->prepare(
-            "SELECT
-                volume_snapshot_date,
-                lower(volume_channel) AS volume_channel,
-                SUM(volume_message_count) AS message_count
-            FROM volume_snapshot
-            WHERE company_id = :company_id
-              AND volume_snapshot_date BETWEEN :start_date AND :end_date
-            GROUP BY volume_snapshot_date, lower(volume_channel)
-            ORDER BY volume_snapshot_date ASC, lower(volume_channel) ASC"
-        );
-        $stmt->execute([
-            'company_id' => $companyId,
-            'start_date' => $range['start_date'],
-            'end_date' => $range['end_date'],
-        ]);
-        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
-
-        $grouped = [];
-        foreach ($rows as $row) {
-            $date = $this->formatSnapshotDate((string) $row['volume_snapshot_date']);
-            if (!isset($grouped[$date])) {
-                $grouped[$date] = ['date' => $date];
-            }
-
-            $grouped[$date][$row['volume_channel']] = (int) ($row['message_count'] ?? 0);
-        }
-
-        return array_values($grouped);
-    }
-
-    private function getAspectsData(int $companyId, array $filters, array $range): array
-    {
-        $sql = "SELECT
-                aspect_feedback_aspect AS aspect,
-                SUM(CASE WHEN " . $this->getAspectSentimentCase('positive') . " THEN 1 ELSE 0 END) AS positive_count,
-                SUM(CASE WHEN " . $this->getAspectSentimentCase('neutral') . " THEN 1 ELSE 0 END) AS neutral_count,
-                SUM(CASE WHEN " . $this->getAspectSentimentCase('negative') . " THEN 1 ELSE 0 END) AS negative_count
-            FROM aspect_feedback
-            WHERE company_id = :company_id
-              AND aspect_feedback_deleted_at = 'infinity'
-              AND aspect_feedback_created_at >= :start_datetime
-              AND aspect_feedback_created_at < :end_exclusive_datetime";
-        $params = [
-            'company_id' => $companyId,
-            'start_datetime' => $range['start_datetime'],
-            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
-        ];
-
-        if ($filters['sentiment'] !== 'all') {
-            $sql .= ' AND ' . $this->getAspectFilterClause($filters['sentiment']);
-        }
-
-        $sql .= ' GROUP BY aspect_feedback_aspect ORDER BY aspect_feedback_aspect ASC';
-
-        $stmt = $this->pdo->prepare($sql);
-        $stmt->execute($params);
-        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
-
-        return array_map(static function (array $row): array {
-            return [
-                'aspect' => $row['aspect'],
-                'positive' => (int) ($row['positive_count'] ?? 0),
-                'neutral' => (int) ($row['neutral_count'] ?? 0),
-                'negative' => (int) ($row['negative_count'] ?? 0),
-            ];
-        }, $rows);
-    }
-
-    private function getAspectsDrilldown(int $companyId, array $filters, array $range): array
-    {
-        $sql = "SELECT
-                aspect_feedback_aspect AS aspect,
-                aspect_feedback_sentiment AS sentiment,
-                aspect_feedback_text AS label,
-                COUNT(*) AS total
-            FROM aspect_feedback
-            WHERE company_id = :company_id
-              AND aspect_feedback_deleted_at = 'infinity'
-              AND aspect_feedback_created_at >= :start_datetime
-              AND aspect_feedback_created_at < :end_exclusive_datetime";
-        $params = [
-            'company_id' => $companyId,
-            'start_datetime' => $range['start_datetime'],
-            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
-        ];
-
-        if ($filters['sentiment'] !== 'all') {
-            $sql .= ' AND ' . $this->getAspectFilterClause($filters['sentiment']);
-        }
-
-        $sql .= ' GROUP BY aspect_feedback_aspect, aspect_feedback_sentiment, aspect_feedback_text
-                  ORDER BY aspect_feedback_aspect ASC, COUNT(*) DESC, aspect_feedback_text ASC';
-
-        $stmt = $this->pdo->prepare($sql);
-        $stmt->execute($params);
-        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
-
-        $drilldown = [];
-        foreach ($rows as $row) {
-            $aspect = $row['aspect'];
-            $sentiment = $this->normalizeAspectSentiment((string) ($row['sentiment'] ?? 'neutral'));
-
-            if (!isset($drilldown[$aspect])) {
-                $drilldown[$aspect] = [
-                    'positive' => [],
-                    'neutral' => [],
-                    'negative' => [],
-                ];
-            }
-
-            if (count($drilldown[$aspect][$sentiment]) >= 5) {
-                continue;
-            }
-
-            $drilldown[$aspect][$sentiment][] = [
-                'label' => $row['label'],
-                'value' => (int) ($row['total'] ?? 0),
-            ];
-        }
-
-        return $drilldown;
-    }
-
-    private function getSentimentWhereClause(string $analysisAlias, string $sentiment): string
-    {
-        if ($sentiment === 'positive') {
-            return "(
-                lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('positive', 'positivo')
-                OR {$analysisAlias}.conversation_analysis_sentiment_score >= 0.15
-            )";
-        }
-
-        if ($sentiment === 'negative') {
-            return "(
-                lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('negative', 'negativo')
-                OR {$analysisAlias}.conversation_analysis_sentiment_score <= -0.15
-            )";
-        }
-
-        return "(
-            lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) NOT IN ('positive', 'positivo', 'negative', 'negativo')
-            AND {$analysisAlias}.conversation_analysis_sentiment_score > -0.15
-            AND {$analysisAlias}.conversation_analysis_sentiment_score < 0.15
-        )";
-    }
-
-    private function getAspectSentimentCase(string $sentiment): string
-    {
-        if ($sentiment === 'positive') {
-            return "lower(aspect_feedback_sentiment) IN ('positive', 'positivo')";
-        }
-
-        if ($sentiment === 'negative') {
-            return "lower(aspect_feedback_sentiment) IN ('negative', 'negativo')";
-        }
-
-        return "lower(aspect_feedback_sentiment) NOT IN ('positive', 'positivo', 'negative', 'negativo')";
-    }
-
-    private function getAspectFilterClause(string $sentiment): string
-    {
-        return $this->getAspectSentimentCase($sentiment);
-    }
-
-    private function normalizeAspectSentiment(string $sentiment): string
-    {
-        $normalized = strtolower(trim($sentiment));
-
-        if (in_array($normalized, ['positive', 'positivo'], true)) {
-            return 'positive';
-        }
-
-        if (in_array($normalized, ['negative', 'negativo'], true)) {
-            return 'negative';
-        }
-
-        return 'neutral';
-    }
-
-    private function normalizeStatusLabel(string $status): string
-    {
-        $normalized = trim($status);
-        if ($normalized === '') {
-            return 'SEM STATUS';
-        }
-
-        return mb_strtoupper(str_replace('_', ' ', $normalized));
-    }
-
-    private function formatSlaStatus(?string $deadline): string
-    {
-        if (!$deadline) {
-            return 'Sem SLA';
-        }
-
-        $deadlineTime = strtotime($deadline);
-        if ($deadlineTime === false) {
-            return 'Sem SLA';
-        }
-
-        $delta = $deadlineTime - time();
-        if ($delta >= 0) {
-            return 'Dentro do SLA';
-        }
-
-        $overdue = abs($delta);
-        if ($overdue >= 86400) {
-            return 'SLA ' . floor($overdue / 86400) . 'd estourado';
-        }
-
-        if ($overdue >= 3600) {
-            return 'SLA ' . floor($overdue / 3600) . 'h estourado';
-        }
-
-        return 'SLA ' . max(1, floor($overdue / 60)) . 'm estourado';
-    }
-
-    private function formatRelativeTime(?string $dateTime): string
-    {
-        if (!$dateTime) {
-            return 'agora';
-        }
-
-        $timestamp = strtotime($dateTime);
-        if ($timestamp === false) {
-            return 'agora';
-        }
-
-        $delta = max(0, time() - $timestamp);
-        if ($delta >= 86400) {
-            return 'há ' . floor($delta / 86400) . 'd';
-        }
-
-        if ($delta >= 3600) {
-            return 'há ' . floor($delta / 3600) . 'h';
-        }
-
-        if ($delta >= 60) {
-            return 'há ' . floor($delta / 60) . 'm';
-        }
-
-        return 'agora';
-    }
-
-    private function formatSnapshotDate(string $date): string
-    {
-        return $date . 'T00:00:00Z';
-    }
 }

+ 10 - 292
controllers/InteractionDetailsController.php

@@ -2,17 +2,20 @@
 
 namespace Controllers;
 
-use Libs\Database;
 use Libs\ResponseLib;
+use Models\InteractionDetailsModel;
+use Models\UserModel;
 use Psr\Http\Message\ServerRequestInterface;
 
 class InteractionDetailsController
 {
-    private \PDO $pdo;
+    private UserModel $userModel;
+    private InteractionDetailsModel $interactionDetailsModel;
 
     public function __construct()
     {
-        $this->pdo = Database::pdo();
+        $this->userModel = new UserModel();
+        $this->interactionDetailsModel = new InteractionDetailsModel();
     }
 
     public function __invoke(ServerRequestInterface $request)
@@ -29,304 +32,19 @@ class InteractionDetailsController
         }
 
         try {
-            $companyId = $this->getCompanyIdByUserId($userId);
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
             if ($companyId === null) {
                 return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
             }
 
-            $conversation = $this->getConversation($companyId, $conversationId);
-            if ($conversation === null) {
+            $data = $this->interactionDetailsModel->getInteractionDetails($companyId, $conversationId);
+            if ($data === null) {
                 return ResponseLib::sendFail('Conversation not found', [], 'E_NOT_FOUND')->withStatus(404);
             }
 
-            $messages = $this->getMessages($conversationId);
-            $report = $this->buildReport($conversation, $messages);
-
-            return ResponseLib::sendOk([
-                'conversation' => [
-                    'conversationId' => (int) $conversation['conversation_id'],
-                    'client' => $conversation['client_phone'] ?? '',
-                    'channel' => $this->formatChannel((string) ($conversation['conversation_channel'] ?? '')),
-                    'agent' => $conversation['operator_name'] ?? '',
-                ],
-                'thread' => array_map(function (array $message): array {
-                    return [
-                        'id' => 'm' . (int) $message['message_id'],
-                        'isAgent' => (bool) $message['message_is_operator'],
-                        'text' => $message['message_content'] ?? '',
-                        'time' => $this->formatTime((string) ($message['message_sent_at'] ?? '')),
-                        'date' => $this->formatDate((string) ($message['message_sent_at'] ?? '')),
-                    ];
-                }, $messages),
-                'report' => $report,
-            ]);
+            return ResponseLib::sendOk($data);
         } catch (\Throwable $e) {
             return ResponseLib::sendFail('Failed to load interaction details', [], 'E_GENERIC')->withStatus(500);
         }
     }
-
-    private function getCompanyIdByUserId(int $userId): ?int
-    {
-        $stmt = $this->pdo->prepare(
-            "SELECT company_id
-            FROM \"user\"
-            WHERE user_id = :user_id
-              AND user_deleted_at = 'infinity'
-            LIMIT 1"
-        );
-        $stmt->execute(['user_id' => $userId]);
-        $companyId = $stmt->fetchColumn();
-
-        return $companyId === false ? null : (int) $companyId;
-    }
-
-    private function getConversation(int $companyId, int $conversationId): ?array
-    {
-        $stmt = $this->pdo->prepare(
-            "SELECT
-                c.conversation_id,
-                c.conversation_channel,
-                c.conversation_started_at,
-                c.conversation_last_message_at,
-                cl.client_phone,
-                o.operator_name,
-                ca.conversation_analysis_aspect,
-                ca.conversation_analysis_sub_aspect,
-                ca.conversation_analysis_sentiment,
-                ca.conversation_analysis_sentiment_score
-            FROM conversation c
-            INNER JOIN client cl
-                ON cl.client_id = c.client_id
-               AND cl.client_deleted_at = 'infinity'
-            INNER JOIN operator o
-                ON o.operator_id = c.operator_id
-               AND o.operator_deleted_at = 'infinity'
-            LEFT JOIN conversation_analysis ca
-                ON ca.conversation_id = c.conversation_id
-               AND ca.conversation_analysis_deleted_at = 'infinity'
-            WHERE c.company_id = :company_id
-              AND c.conversation_id = :conversation_id
-              AND c.conversation_deleted_at = 'infinity'
-            LIMIT 1"
-        );
-        $stmt->execute([
-            'company_id' => $companyId,
-            'conversation_id' => $conversationId,
-        ]);
-
-        $conversation = $stmt->fetch(\PDO::FETCH_ASSOC);
-
-        return $conversation === false ? null : $conversation;
-    }
-
-    private function getMessages(int $conversationId): array
-    {
-        $stmt = $this->pdo->prepare(
-            "SELECT
-                message_id,
-                message_is_operator,
-                message_content,
-                message_sent_at
-            FROM message
-            WHERE conversation_id = :conversation_id
-              AND message_deleted_at = 'infinity'
-              AND message_deleted = FALSE
-              AND message_hidden = FALSE
-              AND message_is_event = FALSE
-            ORDER BY message_sent_at ASC, message_id ASC"
-        );
-        $stmt->execute(['conversation_id' => $conversationId]);
-
-        return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
-    }
-
-    private function buildReport(array $conversation, array $messages): array
-    {
-        $avgResponseSeconds = $this->calculateAverageResponseSeconds($messages);
-        $avgAgentSeconds = $this->calculateAverageGapByAuthor($messages, true);
-        $avgClientSeconds = $this->calculateAverageGapByAuthor($messages, false);
-        $totalDurationSeconds = $this->calculateTotalDurationSeconds($conversation, $messages);
-        $lastMessageAuthor = $this->getLastMessageAuthor($messages);
-        $consecutiveMessages = $this->hasTrailingConsecutiveMessages($messages);
-
-        return [
-            'avgResponse' => $this->formatDuration($avgResponseSeconds),
-            'totalDuration' => $this->formatDuration($totalDurationSeconds),
-            'avgAgent' => $this->formatDuration($avgAgentSeconds),
-            'avgClient' => $this->formatDuration($avgClientSeconds),
-            'mainAspect' => $conversation['conversation_analysis_aspect'] ?? '',
-            'subAspect' => $conversation['conversation_analysis_sub_aspect'] ?? '',
-            'lastMessageAuthor' => $lastMessageAuthor,
-            'consecutiveMessages' => $consecutiveMessages,
-            'sentiment' => $this->normalizeSentimentLabel((string) ($conversation['conversation_analysis_sentiment'] ?? '')),
-            'score' => round((float) ($conversation['conversation_analysis_sentiment_score'] ?? 0), 2),
-        ];
-    }
-
-    private function calculateAverageResponseSeconds(array $messages): int
-    {
-        $responseTimes = [];
-        $pendingClientTimestamp = null;
-
-        foreach ($messages as $message) {
-            $timestamp = strtotime((string) ($message['message_sent_at'] ?? ''));
-            if ($timestamp === false) {
-                continue;
-            }
-
-            $isOperator = (bool) ($message['message_is_operator'] ?? false);
-
-            if (!$isOperator) {
-                if ($pendingClientTimestamp === null) {
-                    $pendingClientTimestamp = $timestamp;
-                }
-
-                continue;
-            }
-
-            if ($pendingClientTimestamp !== null && $timestamp >= $pendingClientTimestamp) {
-                $responseTimes[] = $timestamp - $pendingClientTimestamp;
-                $pendingClientTimestamp = null;
-            }
-        }
-
-        if ($responseTimes === []) {
-            return 0;
-        }
-
-        return (int) round(array_sum($responseTimes) / count($responseTimes));
-    }
-
-    private function calculateAverageGapByAuthor(array $messages, bool $isOperator): int
-    {
-        $timestamps = [];
-
-        foreach ($messages as $message) {
-            if ((bool) ($message['message_is_operator'] ?? false) !== $isOperator) {
-                continue;
-            }
-
-            $timestamp = strtotime((string) ($message['message_sent_at'] ?? ''));
-            if ($timestamp === false) {
-                continue;
-            }
-
-            $timestamps[] = $timestamp;
-        }
-
-        if (count($timestamps) < 2) {
-            return 0;
-        }
-
-        $gaps = [];
-        for ($i = 1, $len = count($timestamps); $i < $len; $i++) {
-            $gap = $timestamps[$i] - $timestamps[$i - 1];
-            if ($gap >= 0) {
-                $gaps[] = $gap;
-            }
-        }
-
-        if ($gaps === []) {
-            return 0;
-        }
-
-        return (int) round(array_sum($gaps) / count($gaps));
-    }
-
-    private function calculateTotalDurationSeconds(array $conversation, array $messages): int
-    {
-        if (count($messages) >= 2) {
-            $first = strtotime((string) ($messages[0]['message_sent_at'] ?? ''));
-            $last = strtotime((string) ($messages[count($messages) - 1]['message_sent_at'] ?? ''));
-            if ($first !== false && $last !== false && $last >= $first) {
-                return $last - $first;
-            }
-        }
-
-        $startedAt = strtotime((string) ($conversation['conversation_started_at'] ?? ''));
-        $lastMessageAt = strtotime((string) ($conversation['conversation_last_message_at'] ?? ''));
-        if ($startedAt === false || $lastMessageAt === false || $lastMessageAt < $startedAt) {
-            return 0;
-        }
-
-        return $lastMessageAt - $startedAt;
-    }
-
-    private function getLastMessageAuthor(array $messages): string
-    {
-        if ($messages === []) {
-            return 'Cliente';
-        }
-
-        $lastMessage = $messages[count($messages) - 1];
-
-        return (bool) ($lastMessage['message_is_operator'] ?? false) ? 'Operador' : 'Cliente';
-    }
-
-    private function hasTrailingConsecutiveMessages(array $messages): bool
-    {
-        $count = count($messages);
-        if ($count < 2) {
-            return false;
-        }
-
-        $last = (bool) ($messages[$count - 1]['message_is_operator'] ?? false);
-        $previous = (bool) ($messages[$count - 2]['message_is_operator'] ?? false);
-
-        return $last === $previous;
-    }
-
-    private function normalizeSentimentLabel(string $label): string
-    {
-        $normalized = trim($label);
-        if ($normalized === '') {
-            return 'NEUTRO';
-        }
-
-        return mb_strtoupper(str_replace('_', ' ', $normalized));
-    }
-
-    private function formatChannel(string $channel): string
-    {
-        $normalized = strtolower(trim($channel));
-
-        if ($normalized === 'whatsapp') {
-            return 'WhatsApp';
-        }
-
-        if ($normalized === '') {
-            return '';
-        }
-
-        return ucfirst($normalized);
-    }
-
-    private function formatTime(string $dateTime): string
-    {
-        $timestamp = strtotime($dateTime);
-        if ($timestamp === false) {
-            return '00:00';
-        }
-
-        return date('H:i', $timestamp);
-    }
-
-    private function formatDate(string $dateTime): string
-    {
-        $timestamp = strtotime($dateTime);
-        if ($timestamp === false) {
-            return '';
-        }
-
-        return date('Y-m-d', $timestamp);
-    }
-
-    private function formatDuration(int $seconds): string
-    {
-        $seconds = max(0, $seconds);
-        $minutes = intdiv($seconds, 60);
-        $remainingSeconds = $seconds % 60;
-
-        return str_pad((string) $minutes, 2, '0', STR_PAD_LEFT) . ':' . str_pad((string) $remainingSeconds, 2, '0', STR_PAD_LEFT);
-    }
 }

+ 10 - 247
controllers/InteractionsController.php

@@ -2,17 +2,20 @@
 
 namespace Controllers;
 
-use Libs\Database;
 use Libs\ResponseLib;
+use Models\InteractionsModel;
+use Models\UserModel;
 use Psr\Http\Message\ServerRequestInterface;
 
 class InteractionsController
 {
-    private \PDO $pdo;
+    private UserModel $userModel;
+    private InteractionsModel $interactionsModel;
 
     public function __construct()
     {
-        $this->pdo = Database::pdo();
+        $this->userModel = new UserModel();
+        $this->interactionsModel = new InteractionsModel();
     }
 
     public function __invoke(ServerRequestInterface $request)
@@ -25,256 +28,16 @@ class InteractionsController
         }
 
         try {
-            $companyId = $this->getCompanyIdByUserId($userId);
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
             if ($companyId === null) {
                 return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
             }
 
-            $filters = $this->normalizeFilters($request->getQueryParams());
-            $myOperatorId = $this->getOperatorIdByUserEmail($companyId, $userEmail);
-            [$whereSql, $params] = $this->buildWhereClause($companyId, $filters, $myOperatorId);
-
-            $total = $this->getTotalCount($whereSql, $params);
-            $items = $this->getItems($whereSql, $params, $filters['page'], $filters['per_page']);
-
-            return ResponseLib::sendOk([
-                'items' => $items,
-                'pagination' => [
-                    'page' => $filters['page'],
-                    'per_page' => $filters['per_page'],
-                    'total' => $total,
-                    'total_pages' => $filters['per_page'] > 0 ? (int) ceil($total / $filters['per_page']) : 0,
-                ],
-            ]);
+            return ResponseLib::sendOk(
+                $this->interactionsModel->getInteractionsData($companyId, $userEmail, $request->getQueryParams())
+            );
         } catch (\Throwable $e) {
             return ResponseLib::sendFail('Failed to load interactions', [], 'E_GENERIC')->withStatus(500);
         }
     }
-
-    private function getCompanyIdByUserId(int $userId): ?int
-    {
-        $stmt = $this->pdo->prepare(
-            "SELECT company_id
-            FROM \"user\"
-            WHERE user_id = :user_id
-              AND user_deleted_at = 'infinity'
-            LIMIT 1"
-        );
-        $stmt->execute(['user_id' => $userId]);
-        $companyId = $stmt->fetchColumn();
-
-        return $companyId === false ? null : (int) $companyId;
-    }
-
-    private function getOperatorIdByUserEmail(int $companyId, string $userEmail): ?int
-    {
-        $normalizedEmail = mb_strtolower(trim($userEmail));
-        if ($normalizedEmail === '') {
-            return null;
-        }
-
-        $stmt = $this->pdo->prepare(
-            "SELECT operator_id
-            FROM operator
-            WHERE company_id = :company_id
-              AND operator_deleted_at = 'infinity'
-              AND lower(operator_email) = :email
-            LIMIT 1"
-        );
-        $stmt->execute([
-            'company_id' => $companyId,
-            'email' => $normalizedEmail,
-        ]);
-        $operatorId = $stmt->fetchColumn();
-
-        return $operatorId === false ? null : (int) $operatorId;
-    }
-
-    private function normalizeFilters(array $queryParams): array
-    {
-        $page = max(1, (int) ($queryParams['page'] ?? 1));
-        $perPage = (int) ($queryParams['per_page'] ?? 20);
-        $perPage = max(1, min(100, $perPage));
-
-        $filter = strtolower(trim((string) ($queryParams['filter'] ?? 'all')));
-        if (!in_array($filter, ['all', 'my_clients', 'new', 'unfinished'], true)) {
-            $filter = 'all';
-        }
-
-        $sentiment = strtolower(trim((string) ($queryParams['sentiment'] ?? 'all')));
-        if (!in_array($sentiment, ['all', 'positive', 'neutral', 'negative'], true)) {
-            $sentiment = 'all';
-        }
-
-        return [
-            'page' => $page,
-            'per_page' => $perPage,
-            'search' => trim((string) ($queryParams['search'] ?? '')),
-            'filter' => $filter,
-            'sentiment' => $sentiment,
-            'operator_id' => max(0, (int) ($queryParams['operator_id'] ?? 0)),
-        ];
-    }
-
-    private function buildWhereClause(int $companyId, array $filters, ?int $myOperatorId): array
-    {
-        $where = [
-            "c.company_id = :company_id",
-            "c.conversation_deleted_at = 'infinity'",
-            "cl.client_deleted_at = 'infinity'",
-            "o.operator_deleted_at = 'infinity'",
-        ];
-        $params = ['company_id' => $companyId];
-
-        if ($filters['search'] !== '') {
-            $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)';
-            $params['search'] = '%' . $filters['search'] . '%';
-        }
-
-        if ($filters['operator_id'] > 0) {
-            $where[] = 'c.operator_id = :operator_id';
-            $params['operator_id'] = $filters['operator_id'];
-        }
-
-        if ($filters['filter'] === 'unfinished') {
-            $where[] = "lower(c.conversation_status) <> 'closed'";
-        }
-
-        if ($filters['filter'] === 'new') {
-            $where[] = "c.conversation_started_at >= NOW() - INTERVAL '24 hours'";
-        }
-
-        if ($filters['filter'] === 'my_clients') {
-            if ($myOperatorId !== null) {
-                $where[] = 'c.operator_id = :my_operator_id';
-                $params['my_operator_id'] = $myOperatorId;
-            } else {
-                $where[] = '1 = 0';
-            }
-        }
-
-        if ($filters['sentiment'] !== 'all') {
-            $where[] = $this->getSentimentWhereClause('ca', $filters['sentiment']);
-        }
-
-        return [implode("\n              AND ", $where), $params];
-    }
-
-    private function getTotalCount(string $whereSql, array $params): int
-    {
-        $stmt = $this->pdo->prepare(
-            "SELECT COUNT(*)
-            FROM conversation c
-            INNER JOIN client cl ON cl.client_id = c.client_id
-            INNER JOIN operator o ON o.operator_id = c.operator_id
-            LEFT JOIN conversation_analysis ca
-                ON ca.conversation_id = c.conversation_id
-               AND ca.conversation_analysis_deleted_at = 'infinity'
-            WHERE {$whereSql}"
-        );
-        $stmt->execute($params);
-
-        return (int) $stmt->fetchColumn();
-    }
-
-    private function getItems(string $whereSql, array $params, int $page, int $perPage): array
-    {
-        $offset = ($page - 1) * $perPage;
-        $params['limit'] = $perPage;
-        $params['offset'] = $offset;
-
-        $stmt = $this->pdo->prepare(
-            "SELECT
-                c.conversation_id,
-                cl.client_phone,
-                o.operator_name,
-                COALESCE(ca.conversation_analysis_sentiment, c.conversation_status) AS sentiment_label,
-                COALESCE(ca.conversation_analysis_sentiment_score, 0) AS sentiment_score,
-                COALESCE(ca.conversation_analysis_aspect, '') AS aspect,
-                COALESCE(ca.conversation_analysis_sub_aspect, '') AS sub_aspect,
-                c.conversation_last_message_at
-            FROM conversation c
-            INNER JOIN client cl ON cl.client_id = c.client_id
-            INNER JOIN operator o ON o.operator_id = c.operator_id
-            LEFT JOIN conversation_analysis ca
-                ON ca.conversation_id = c.conversation_id
-               AND ca.conversation_analysis_deleted_at = 'infinity'
-            WHERE {$whereSql}
-            ORDER BY c.conversation_last_message_at DESC, c.conversation_id DESC
-            LIMIT :limit OFFSET :offset"
-        );
-
-        foreach ($params as $key => $value) {
-            if (in_array($key, ['limit', 'offset', 'operator_id', 'my_operator_id', 'company_id'], true)) {
-                $stmt->bindValue(':' . $key, (int) $value, \PDO::PARAM_INT);
-                continue;
-            }
-
-            $stmt->bindValue(':' . $key, $value);
-        }
-
-        $stmt->execute();
-        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
-
-        return array_map(function (array $row): array {
-            return [
-                'conversationId' => (int) $row['conversation_id'],
-                'client' => $row['client_phone'] ?? '',
-                'agent' => $row['operator_name'] ?? '',
-                'sentiment' => $this->normalizeSentimentLabel((string) ($row['sentiment_label'] ?? '')),
-                'score' => round((float) ($row['sentiment_score'] ?? 0), 2),
-                'aspect' => $row['aspect'] ?? '',
-                'subaspect' => $row['sub_aspect'] ?? '',
-                'datetime' => $this->formatIsoDateTime($row['conversation_last_message_at'] ?? null),
-            ];
-        }, $rows);
-    }
-
-    private function getSentimentWhereClause(string $analysisAlias, string $sentiment): string
-    {
-        if ($sentiment === 'positive') {
-            return "(
-                lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('positive', 'positivo')
-                OR {$analysisAlias}.conversation_analysis_sentiment_score >= 0.15
-            )";
-        }
-
-        if ($sentiment === 'negative') {
-            return "(
-                lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('negative', 'negativo')
-                OR {$analysisAlias}.conversation_analysis_sentiment_score <= -0.15
-            )";
-        }
-
-        return "(
-            {$analysisAlias}.conversation_id IS NOT NULL
-            AND lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) NOT IN ('positive', 'positivo', 'negative', 'negativo')
-            AND {$analysisAlias}.conversation_analysis_sentiment_score > -0.15
-            AND {$analysisAlias}.conversation_analysis_sentiment_score < 0.15
-        )";
-    }
-
-    private function normalizeSentimentLabel(string $label): string
-    {
-        $normalized = trim($label);
-        if ($normalized === '') {
-            return 'NEUTRO';
-        }
-
-        return mb_strtoupper(str_replace('_', ' ', $normalized));
-    }
-
-    private function formatIsoDateTime(?string $dateTime): ?string
-    {
-        if (!$dateTime) {
-            return null;
-        }
-
-        $timestamp = strtotime($dateTime);
-        if ($timestamp === false) {
-            return null;
-        }
-
-        return gmdate('Y-m-d\TH:i:s\Z', $timestamp);
-    }
 }

+ 11 - 44
controllers/MeController.php

@@ -2,12 +2,19 @@
 
 namespace Controllers;
 
-use Libs\Database;
 use Libs\ResponseLib;
+use Models\UserModel;
 use Psr\Http\Message\ServerRequestInterface;
 
 class MeController
 {
+    private UserModel $userModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+    }
+
     public function __invoke(ServerRequestInterface $request)
     {
         $userId = (int) ($request->getAttribute('user_id') ?? 0);
@@ -16,51 +23,11 @@ class MeController
             return ResponseLib::sendFail("Unauthorized: Missing authenticated user", [], "E_VALIDATE")->withStatus(401);
         }
 
-        $pdo = Database::pdo();
-        $stmt = $pdo->prepare(
-            'SELECT 
-                u.user_id,
-                u.company_id,
-                u.user_name,
-                u.user_phone,
-                u.user_email,
-                u.user_role,
-                u.user_created_at,
-                c.company_name,
-                c.company_cnpj,
-                c.company_logo,
-                c.company_created_at
-            FROM "user" u
-            INNER JOIN company c ON c.company_id = u.company_id
-            WHERE u.user_id = :user_id
-              AND u.user_deleted_at = \'infinity\'
-              AND c.company_deleted_at = \'infinity\'
-            LIMIT 1'
-        );
-        $stmt->execute(['user_id' => $userId]);
-        $user = $stmt->fetch(\PDO::FETCH_ASSOC);
-
-        if (!$user) {
+        $profile = $this->userModel->getAuthenticatedProfile($userId);
+        if (!$profile) {
             return ResponseLib::sendFail("User not found", [], "E_NOT_FOUND")->withStatus(404);
         }
 
-        $data = [
-            'user_id' => (int) $user['user_id'],
-            'company_id' => (int) $user['company_id'],
-            'user_name' => $user['user_name'],
-            'user_phone' => $user['user_phone'],
-            'user_email' => $user['user_email'],
-            'user_role' => $user['user_role'],
-            'user_created_at' => $user['user_created_at'],
-            'company' => [
-                'company_id' => (int) $user['company_id'],
-                'company_name' => $user['company_name'],
-                'company_cnpj' => $user['company_cnpj'],
-                'company_logo' => $user['company_logo'],
-                'company_created_at' => $user['company_created_at'],
-            ],
-        ];
-
-        return ResponseLib::sendOk($data);
+        return ResponseLib::sendOk($profile);
     }
 }

+ 42 - 0
controllers/PersonasOverviewController.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\PersonasModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class PersonasOverviewController
+{
+    private UserModel $userModel;
+    private PersonasModel $personasModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->personasModel = new PersonasModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+
+        if ($userId <= 0) {
+            return ResponseLib::sendFail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        try {
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
+            }
+
+            return ResponseLib::sendOk(
+                $this->personasModel->getOverviewData($companyId, $request->getQueryParams())
+            );
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Failed to load personas overview', [], 'E_GENERIC')->withStatus(500);
+        }
+    }
+}

+ 636 - 0
models/AnalyticsSentimentDashboardModel.php

@@ -0,0 +1,636 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+
+class AnalyticsSentimentDashboardModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function getDashboardData(int $companyId, array $queryParams): array
+    {
+        $filters = $this->normalizeFilters($queryParams);
+        $summaryRange = $this->resolveRange($filters['timeframe']);
+
+        return [
+            'summaryCards' => $this->getSummaryCards($companyId, $filters, $summaryRange),
+            'alerts' => $this->getAlerts($companyId, $filters, $summaryRange),
+            'timelineViews' => [
+                'day' => $this->getDayTimeline($companyId, $filters),
+                'week' => $this->getWeekTimeline($companyId, $filters),
+                'month' => $this->getMonthTimeline($companyId, $filters),
+            ],
+            'aspects' => $this->getAspects($companyId, $filters, $summaryRange),
+        ];
+    }
+
+    private function normalizeFilters(array $queryParams): array
+    {
+        $timeframe = strtolower(trim((string) ($queryParams['timeframe'] ?? 'week')));
+        if (!in_array($timeframe, ['day', 'week', 'month'], true)) {
+            $timeframe = 'week';
+        }
+
+        $sentiment = strtolower(trim((string) ($queryParams['sentiment'] ?? 'all')));
+        if (!in_array($sentiment, ['all', 'positive', 'neutral', 'negative'], true)) {
+            $sentiment = 'all';
+        }
+
+        $aspect = trim((string) ($queryParams['aspect'] ?? ''));
+        if ($aspect === '' || strtolower($aspect) === 'all') {
+            $aspect = null;
+        }
+
+        return [
+            'timeframe' => $timeframe,
+            'sentiment' => $sentiment,
+            'aspect' => $aspect !== null ? mb_strtolower($aspect) : null,
+        ];
+    }
+
+    private function resolveRange(string $timeframe): array
+    {
+        $today = new \DateTimeImmutable('today');
+
+        if ($timeframe === 'day') {
+            $start = $today;
+        } elseif ($timeframe === 'month') {
+            $start = $today->modify('-29 days');
+        } else {
+            $start = $today->modify('-6 days');
+        }
+
+        return [
+            'start_datetime' => $start->format('Y-m-d 00:00:00'),
+            'end_exclusive_datetime' => $today->modify('+1 day')->format('Y-m-d 00:00:00'),
+        ];
+    }
+
+    private function getSummaryCards(int $companyId, array $filters, array $range): array
+    {
+        $atRiskClients = $this->getAlertClientCountByTypes(
+            $companyId,
+            ['churn_risk', 'frustration', 'risk', 'at_risk'],
+            $range
+        );
+        $opportunities = $this->getAlertClientCountByTypes(
+            $companyId,
+            ['buying_intent', 'opportunity', 'upsell', 'cross_sell'],
+            $range
+        );
+        $recentInteractions = $this->getRecentInteractionsCount($companyId, $filters, $range);
+        $netTrend = $this->getNetTrend($companyId, $filters, $range);
+
+        return [
+            [
+                'id' => 'atRiskClients',
+                'label' => 'Clientes em risco',
+                'value' => $atRiskClients,
+                'image' => '/images/sentiment/risk.svg',
+            ],
+            [
+                'id' => 'opportunities',
+                'label' => 'Oportunidades',
+                'value' => $opportunities,
+                'image' => '/images/sentiment/opportunity.svg',
+            ],
+            [
+                'id' => 'recentInteractions',
+                'label' => 'Interacoes recentes',
+                'value' => $recentInteractions,
+                'image' => '/images/sentiment/interactions.svg',
+            ],
+            [
+                'id' => 'netTrend',
+                'label' => 'Tendencia liquida',
+                'value' => $netTrend,
+                'image' => '/images/sentiment/trend.svg',
+            ],
+        ];
+    }
+
+    private function getAlertClientCountByTypes(int $companyId, array $types, array $range): int
+    {
+        $placeholders = [];
+        $params = [
+            'company_id' => $companyId,
+            'start_datetime' => $range['start_datetime'],
+            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
+        ];
+
+        foreach ($types as $index => $type) {
+            $key = 'type_' . $index;
+            $placeholders[] = ':' . $key;
+            $params[$key] = $type;
+        }
+
+        $stmt = $this->pdo->prepare(
+            "SELECT COUNT(DISTINCT client_id)
+            FROM alert
+            WHERE company_id = :company_id
+              AND alert_deleted_at = 'infinity'
+              AND alert_is_resolved = FALSE
+              AND alert_created_at >= :start_datetime
+              AND alert_created_at < :end_exclusive_datetime
+              AND lower(alert_type) IN (" . implode(', ', $placeholders) . ')'
+        );
+        $stmt->execute($params);
+
+        return (int) $stmt->fetchColumn();
+    }
+
+    private function getRecentInteractionsCount(int $companyId, array $filters, array $range): int
+    {
+        $sql = "SELECT COUNT(DISTINCT c.conversation_id)
+            FROM conversation c
+            LEFT JOIN conversation_analysis ca
+                ON ca.conversation_id = c.conversation_id
+               AND ca.conversation_analysis_deleted_at = 'infinity'
+            WHERE c.company_id = :company_id
+              AND c.conversation_deleted_at = 'infinity'
+              AND c.conversation_last_message_at >= :start_datetime
+              AND c.conversation_last_message_at < :end_exclusive_datetime";
+        $params = [
+            'company_id' => $companyId,
+            'start_datetime' => $range['start_datetime'],
+            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
+        ];
+        $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute($params);
+
+        return (int) $stmt->fetchColumn();
+    }
+
+    private function getNetTrend(int $companyId, array $filters, array $range): string
+    {
+        $sql = "SELECT
+                COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 1 ELSE 0 END), 0) AS gains,
+                COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 0 ELSE 1 END), 0) AS losses
+            FROM public_opinion po
+            INNER JOIN conversation c
+                ON c.conversation_id = po.conversation_id
+               AND c.conversation_deleted_at = 'infinity'
+            LEFT JOIN conversation_analysis ca
+                ON ca.conversation_id = po.conversation_id
+               AND ca.conversation_analysis_deleted_at = 'infinity'
+            WHERE po.company_id = :company_id
+              AND po.opinion_deleted_at = 'infinity'
+              AND po.opinion_classified_at >= :start_datetime
+              AND po.opinion_classified_at < :end_exclusive_datetime";
+        $params = [
+            'company_id' => $companyId,
+            'start_datetime' => $range['start_datetime'],
+            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
+        ];
+        $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute($params);
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+
+        $gains = (int) ($row['gains'] ?? 0);
+        $losses = (int) ($row['losses'] ?? 0);
+        $total = $gains + $losses;
+        if ($total === 0) {
+            return '0%';
+        }
+
+        $percentage = (int) round((($gains - $losses) / $total) * 100);
+        if ($percentage > 0) {
+            return '+' . $percentage . '%';
+        }
+
+        return $percentage . '%';
+    }
+
+    private function getAlerts(int $companyId, array $filters, array $range): array
+    {
+        $sql = "SELECT
+                a.alert_id,
+                a.client_id,
+                cl.client_name,
+                a.alert_title,
+                a.alert_description,
+                a.alert_priority,
+                a.alert_type,
+                a.alert_created_at
+            FROM alert a
+            INNER JOIN client cl
+                ON cl.client_id = a.client_id
+               AND cl.client_deleted_at = 'infinity'
+            WHERE a.company_id = :company_id
+              AND a.alert_deleted_at = 'infinity'
+              AND a.alert_is_resolved = FALSE
+              AND a.alert_created_at >= :start_datetime
+              AND a.alert_created_at < :end_exclusive_datetime";
+        $params = [
+            'company_id' => $companyId,
+            'start_datetime' => $range['start_datetime'],
+            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
+        ];
+
+        if ($filters['aspect'] !== null || $filters['sentiment'] !== 'all') {
+            $sql .= "
+              AND EXISTS (
+                    SELECT 1
+                    FROM conversation c
+                    INNER JOIN conversation_analysis ca
+                        ON ca.conversation_id = c.conversation_id
+                       AND ca.conversation_analysis_deleted_at = 'infinity'
+                    WHERE c.company_id = a.company_id
+                      AND c.client_id = a.client_id
+                      AND c.conversation_deleted_at = 'infinity'
+                      AND c.conversation_last_message_at >= :filter_start_datetime
+                      AND c.conversation_last_message_at < :filter_end_exclusive_datetime";
+            $params['filter_start_datetime'] = $range['start_datetime'];
+            $params['filter_end_exclusive_datetime'] = $range['end_exclusive_datetime'];
+            $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
+            $sql .= "
+              )";
+        }
+
+        $sql .= "
+            ORDER BY
+                CASE lower(a.alert_priority)
+                    WHEN 'high' THEN 1
+                    WHEN 'medium' THEN 2
+                    ELSE 3
+                END,
+                a.alert_created_at DESC
+            LIMIT 10";
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute($params);
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        return array_map(function (array $row): array {
+            $priority = $this->normalizeAlertPriority((string) ($row['alert_priority'] ?? 'low'));
+
+            return [
+                'id' => 'alert-' . (int) $row['alert_id'],
+                'clientId' => (int) ($row['client_id'] ?? 0),
+                'clientName' => $row['client_name'] ?? '',
+                'title' => $row['alert_title'] ?? '',
+                'description' => $row['alert_description'] ?? '',
+                'priority' => $priority,
+                'priorityLabel' => $this->getPriorityLabel($priority),
+                'category' => $this->normalizeAlertCategory((string) ($row['alert_type'] ?? '')),
+            ];
+        }, $rows);
+    }
+
+    private function getDayTimeline(int $companyId, array $filters): array
+    {
+        $today = new \DateTimeImmutable('today');
+        $start = $today->modify('-6 days');
+        $range = [
+            'start_datetime' => $start->format('Y-m-d 00:00:00'),
+            'end_exclusive_datetime' => $today->modify('+1 day')->format('Y-m-d 00:00:00'),
+        ];
+        $stats = $this->getOpinionBuckets(
+            $companyId,
+            $filters,
+            $range,
+            "TO_CHAR(DATE(po.opinion_classified_at), 'YYYY-MM-DD')"
+        );
+
+        $items = [];
+        for ($i = 0; $i < 7; $i++) {
+            $bucketDate = $start->modify('+' . $i . ' days')->format('Y-m-d');
+            $items[] = [
+                'period' => 'Dia ' . ($i + 1),
+                'gains' => (int) ($stats[$bucketDate]['gains'] ?? 0),
+                'losses' => (int) ($stats[$bucketDate]['losses'] ?? 0),
+            ];
+        }
+
+        return $items;
+    }
+
+    private function getWeekTimeline(int $companyId, array $filters): array
+    {
+        $currentWeek = new \DateTimeImmutable('monday this week');
+        $start = $currentWeek->modify('-5 weeks');
+        $range = [
+            'start_datetime' => $start->format('Y-m-d 00:00:00'),
+            'end_exclusive_datetime' => $currentWeek->modify('+1 week')->format('Y-m-d 00:00:00'),
+        ];
+        $stats = $this->getOpinionBuckets(
+            $companyId,
+            $filters,
+            $range,
+            "TO_CHAR(DATE_TRUNC('week', po.opinion_classified_at), 'YYYY-MM-DD')"
+        );
+
+        $items = [];
+        for ($i = 0; $i < 6; $i++) {
+            $bucketDate = $start->modify('+' . $i . ' weeks')->format('Y-m-d');
+            $items[] = [
+                'period' => 'Sem ' . ($i + 1),
+                'gains' => (int) ($stats[$bucketDate]['gains'] ?? 0),
+                'losses' => (int) ($stats[$bucketDate]['losses'] ?? 0),
+            ];
+        }
+
+        return $items;
+    }
+
+    private function getMonthTimeline(int $companyId, array $filters): array
+    {
+        $currentMonth = new \DateTimeImmutable('first day of this month');
+        $start = $currentMonth->modify('-5 months');
+        $range = [
+            'start_datetime' => $start->format('Y-m-d 00:00:00'),
+            'end_exclusive_datetime' => $currentMonth->modify('+1 month')->format('Y-m-d 00:00:00'),
+        ];
+        $stats = $this->getOpinionBuckets(
+            $companyId,
+            $filters,
+            $range,
+            "TO_CHAR(DATE_TRUNC('month', po.opinion_classified_at), 'YYYY-MM-DD')"
+        );
+
+        $items = [];
+        for ($i = 0; $i < 6; $i++) {
+            $bucketDate = $start->modify('+' . $i . ' months')->format('Y-m-d');
+            $items[] = [
+                'period' => $this->formatMonthLabel($start->modify('+' . $i . ' months')),
+                'gains' => (int) ($stats[$bucketDate]['gains'] ?? 0),
+                'losses' => (int) ($stats[$bucketDate]['losses'] ?? 0),
+            ];
+        }
+
+        return $items;
+    }
+
+    private function getOpinionBuckets(int $companyId, array $filters, array $range, string $bucketSql): array
+    {
+        $sql = "SELECT
+                {$bucketSql} AS bucket_key,
+                COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 1 ELSE 0 END), 0) AS gains,
+                COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 0 ELSE 1 END), 0) AS losses
+            FROM public_opinion po
+            INNER JOIN conversation c
+                ON c.conversation_id = po.conversation_id
+               AND c.conversation_deleted_at = 'infinity'
+            LEFT JOIN conversation_analysis ca
+                ON ca.conversation_id = po.conversation_id
+               AND ca.conversation_analysis_deleted_at = 'infinity'
+            WHERE po.company_id = :company_id
+              AND po.opinion_deleted_at = 'infinity'
+              AND po.opinion_classified_at >= :start_datetime
+              AND po.opinion_classified_at < :end_exclusive_datetime";
+        $params = [
+            'company_id' => $companyId,
+            'start_datetime' => $range['start_datetime'],
+            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
+        ];
+        $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
+
+        $sql .= "
+            GROUP BY bucket_key
+            ORDER BY bucket_key ASC";
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute($params);
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        $mapped = [];
+        foreach ($rows as $row) {
+            $mapped[$row['bucket_key']] = [
+                'gains' => (int) ($row['gains'] ?? 0),
+                'losses' => (int) ($row['losses'] ?? 0),
+            ];
+        }
+
+        return $mapped;
+    }
+
+    private function getAspects(int $companyId, array $filters, array $range): array
+    {
+        $sql = "SELECT
+                af.aspect_feedback_aspect,
+                af.aspect_feedback_sentiment,
+                af.aspect_feedback_text,
+                cl.client_name,
+                cl.client_phone,
+                af.aspect_feedback_created_at
+            FROM aspect_feedback af
+            INNER JOIN conversation c
+                ON c.conversation_id = af.conversation_id
+               AND c.conversation_deleted_at = 'infinity'
+            INNER JOIN client cl
+                ON cl.client_id = c.client_id
+               AND cl.client_deleted_at = 'infinity'
+            WHERE af.company_id = :company_id
+              AND af.aspect_feedback_deleted_at = 'infinity'
+              AND af.aspect_feedback_created_at >= :start_datetime
+              AND af.aspect_feedback_created_at < :end_exclusive_datetime";
+        $params = [
+            'company_id' => $companyId,
+            'start_datetime' => $range['start_datetime'],
+            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
+        ];
+
+        if ($filters['aspect'] !== null) {
+            $sql .= " AND lower(af.aspect_feedback_aspect) = :aspect";
+            $params['aspect'] = $filters['aspect'];
+        }
+
+        if ($filters['sentiment'] !== 'all') {
+            $sql .= ' AND ' . $this->getAspectSentimentWhereClause('af', $filters['sentiment']);
+        }
+
+        $sql .= "
+            ORDER BY af.aspect_feedback_aspect ASC, af.aspect_feedback_created_at DESC";
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute($params);
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        $grouped = [];
+        foreach ($rows as $row) {
+            $name = (string) ($row['aspect_feedback_aspect'] ?? '');
+            $id = $this->slugify($name);
+            if (!isset($grouped[$id])) {
+                $grouped[$id] = [
+                    'id' => $id,
+                    'name' => $name,
+                    'volume' => 0,
+                    'positive' => [],
+                    'neutral' => [],
+                    'negative' => [],
+                ];
+            }
+
+            $sentiment = $this->normalizeAspectSentiment((string) ($row['aspect_feedback_sentiment'] ?? 'neutral'));
+            $grouped[$id]['volume']++;
+            $grouped[$id][$sentiment][] = [
+                'text' => $row['aspect_feedback_text'] ?? '',
+                'client' => $row['client_name'] !== '' ? $row['client_name'] : ($row['client_phone'] ?? ''),
+            ];
+        }
+
+        usort($grouped, static function (array $left, array $right): int {
+            return $right['volume'] <=> $left['volume'];
+        });
+
+        return array_values($grouped);
+    }
+
+    private function appendConversationAnalysisFilters(string &$sql, array &$params, string $alias, array $filters): void
+    {
+        if ($filters['aspect'] !== null) {
+            $sql .= " AND lower(COALESCE({$alias}.conversation_analysis_aspect, '')) = :aspect";
+            $params['aspect'] = $filters['aspect'];
+        }
+
+        if ($filters['sentiment'] !== 'all') {
+            $sql .= ' AND ' . $this->getConversationSentimentWhereClause($alias, $filters['sentiment']);
+        }
+    }
+
+    private function getConversationSentimentWhereClause(string $alias, string $sentiment): string
+    {
+        if ($sentiment === 'positive') {
+            return "(
+                lower(COALESCE({$alias}.conversation_analysis_sentiment, '')) IN ('positive', 'positivo')
+                OR {$alias}.conversation_analysis_sentiment_score >= 0.15
+            )";
+        }
+
+        if ($sentiment === 'negative') {
+            return "(
+                lower(COALESCE({$alias}.conversation_analysis_sentiment, '')) IN ('negative', 'negativo')
+                OR {$alias}.conversation_analysis_sentiment_score <= -0.15
+            )";
+        }
+
+        return "(
+            {$alias}.conversation_id IS NOT NULL
+            AND lower(COALESCE({$alias}.conversation_analysis_sentiment, '')) NOT IN ('positive', 'positivo', 'negative', 'negativo')
+            AND {$alias}.conversation_analysis_sentiment_score > -0.15
+            AND {$alias}.conversation_analysis_sentiment_score < 0.15
+        )";
+    }
+
+    private function getAspectSentimentWhereClause(string $alias, string $sentiment): string
+    {
+        if ($sentiment === 'positive') {
+            return "lower({$alias}.aspect_feedback_sentiment) IN ('positive', 'positivo')";
+        }
+
+        if ($sentiment === 'negative') {
+            return "lower({$alias}.aspect_feedback_sentiment) IN ('negative', 'negativo')";
+        }
+
+        return "lower({$alias}.aspect_feedback_sentiment) NOT IN ('positive', 'positivo', 'negative', 'negativo')";
+    }
+
+    private function normalizeAspectSentiment(string $sentiment): string
+    {
+        $normalized = mb_strtolower(trim($sentiment));
+
+        if (in_array($normalized, ['positive', 'positivo'], true)) {
+            return 'positive';
+        }
+
+        if (in_array($normalized, ['negative', 'negativo'], true)) {
+            return 'negative';
+        }
+
+        return 'neutral';
+    }
+
+    private function normalizeAlertPriority(string $priority): string
+    {
+        $normalized = mb_strtolower(trim($priority));
+
+        if (in_array($normalized, ['high', 'alta'], true)) {
+            return 'high';
+        }
+
+        if (in_array($normalized, ['medium', 'media', 'média'], true)) {
+            return 'medium';
+        }
+
+        return 'low';
+    }
+
+    private function getPriorityLabel(string $priority): string
+    {
+        if ($priority === 'high') {
+            return 'Alta prioridade';
+        }
+
+        if ($priority === 'medium') {
+            return 'Media prioridade';
+        }
+
+        return 'Baixa prioridade';
+    }
+
+    private function normalizeAlertCategory(string $type): string
+    {
+        $normalized = mb_strtolower(trim($type));
+
+        if (in_array($normalized, ['buying_intent', 'opportunity', 'upsell', 'cross_sell'], true)) {
+            return 'buying_intent';
+        }
+
+        if (in_array($normalized, ['frustration', 'frustracao', 'frustração'], true)) {
+            return 'frustration';
+        }
+
+        return 'churn_risk';
+    }
+
+    private function formatMonthLabel(\DateTimeImmutable $date): string
+    {
+        $labels = [
+            '01' => 'Jan',
+            '02' => 'Fev',
+            '03' => 'Mar',
+            '04' => 'Abr',
+            '05' => 'Mai',
+            '06' => 'Jun',
+            '07' => 'Jul',
+            '08' => 'Ago',
+            '09' => 'Set',
+            '10' => 'Out',
+            '11' => 'Nov',
+            '12' => 'Dez',
+        ];
+
+        return $labels[$date->format('m')] ?? $date->format('m');
+    }
+
+    private function slugify(string $value): string
+    {
+        $normalized = trim($value);
+        if ($normalized === '') {
+            return 'aspect';
+        }
+
+        if (function_exists('iconv')) {
+            $converted = iconv('UTF-8', 'ASCII//TRANSLIT', $normalized);
+            if (is_string($converted) && $converted !== '') {
+                $normalized = $converted;
+            }
+        }
+
+        $normalized = mb_strtolower($normalized);
+        $normalized = preg_replace('/[^a-z0-9]+/', '-', $normalized) ?? 'aspect';
+        $normalized = trim($normalized, '-');
+
+        return $normalized !== '' ? $normalized : 'aspect';
+    }
+}

+ 531 - 0
models/DashboardOverviewModel.php

@@ -0,0 +1,531 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+
+class DashboardOverviewModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function getOverviewData(int $companyId, array $queryParams): array
+    {
+        $filters = $this->normalizeFilters($queryParams);
+        $range = $this->resolveDateRange($filters['period']);
+
+        return [
+            'kpis' => $this->getKpis($companyId, $filters, $range),
+            'priorityQueue' => $this->getPriorityQueue($companyId, $filters, $range),
+            'radarData' => $this->getRadarData($companyId, $range),
+            'volumeData' => $this->getVolumeData($companyId, $range),
+            'aspectsData' => $this->getAspectsData($companyId, $filters, $range),
+            'aspectsDrilldown' => $this->getAspectsDrilldown($companyId, $filters, $range),
+        ];
+    }
+
+    private function normalizeFilters(array $queryParams): array
+    {
+        $period = strtolower((string) ($queryParams['period'] ?? 'week'));
+        $unit = strtolower((string) ($queryParams['unit'] ?? 'all'));
+        $area = strtolower((string) ($queryParams['area'] ?? 'all'));
+        $sentiment = strtolower((string) ($queryParams['sentiment'] ?? 'all'));
+        $volumeView = strtolower((string) ($queryParams['volume_view'] ?? 'day'));
+
+        if (!in_array($period, ['today', 'yesterday', 'week'], true)) {
+            $period = 'week';
+        }
+
+        if (!in_array($sentiment, ['all', 'positive', 'neutral', 'negative'], true)) {
+            $sentiment = 'all';
+        }
+
+        if (!in_array($volumeView, ['hour', 'day'], true)) {
+            $volumeView = 'day';
+        }
+
+        return [
+            'period' => $period,
+            'unit' => $unit,
+            'area' => $area,
+            'sentiment' => $sentiment,
+            'volume_view' => $volumeView,
+        ];
+    }
+
+    private function resolveDateRange(string $period): array
+    {
+        $today = new \DateTimeImmutable('today');
+
+        if ($period === 'today') {
+            $start = $today;
+            $end = $today;
+        } elseif ($period === 'yesterday') {
+            $start = $today->modify('-1 day');
+            $end = $start;
+        } else {
+            $start = $today->modify('-6 days');
+            $end = $today;
+        }
+
+        return [
+            'start_date' => $start->format('Y-m-d'),
+            'end_date' => $end->format('Y-m-d'),
+            'start_datetime' => $start->format('Y-m-d 00:00:00'),
+            'end_exclusive_datetime' => $end->modify('+1 day')->format('Y-m-d 00:00:00'),
+        ];
+    }
+
+    private function getKpis(int $companyId, array $filters, array $range): array
+    {
+        $registeredStmt = $this->pdo->prepare(
+            "SELECT COUNT(*)
+            FROM client
+            WHERE company_id = :company_id
+              AND client_deleted_at = 'infinity'
+              AND client_is_registered = TRUE"
+        );
+        $registeredStmt->execute(['company_id' => $companyId]);
+
+        $unregisteredStmt = $this->pdo->prepare(
+            "SELECT COUNT(*)
+            FROM client
+            WHERE company_id = :company_id
+              AND client_deleted_at = 'infinity'
+              AND client_is_registered = FALSE"
+        );
+        $unregisteredStmt->execute(['company_id' => $companyId]);
+
+        $activeOperatorsSql = "SELECT COUNT(*)
+            FROM operator
+            WHERE company_id = :company_id
+              AND operator_deleted_at = 'infinity'
+              AND lower(operator_status) <> 'inativo'";
+        $activeOperatorsParams = ['company_id' => $companyId];
+
+        if ($filters['area'] !== 'all') {
+            $activeOperatorsSql .= " AND lower(operator_department) = :area";
+            $activeOperatorsParams['area'] = $filters['area'];
+        }
+
+        $activeOperatorsStmt = $this->pdo->prepare($activeOperatorsSql);
+        $activeOperatorsStmt->execute($activeOperatorsParams);
+
+        $conversationSql = "SELECT
+                COUNT(DISTINCT c.conversation_id) AS total_conversations,
+                COALESCE(AVG(ca.conversation_analysis_sentiment_score), 0) AS general_sentiment_score
+            FROM conversation c
+            INNER JOIN operator o ON o.operator_id = c.operator_id
+            LEFT JOIN conversation_analysis ca
+                ON ca.conversation_id = c.conversation_id
+               AND ca.conversation_analysis_deleted_at = 'infinity'
+            WHERE c.company_id = :company_id
+              AND c.conversation_deleted_at = 'infinity'
+              AND c.conversation_started_at >= :start_datetime
+              AND c.conversation_started_at < :end_exclusive_datetime
+              AND o.operator_deleted_at = 'infinity'";
+        $conversationParams = [
+            'company_id' => $companyId,
+            'start_datetime' => $range['start_datetime'],
+            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
+        ];
+
+        if ($filters['area'] !== 'all') {
+            $conversationSql .= " AND lower(o.operator_department) = :area";
+            $conversationParams['area'] = $filters['area'];
+        }
+
+        if ($filters['sentiment'] !== 'all') {
+            $conversationSql .= ' AND ' . $this->getSentimentWhereClause('ca', $filters['sentiment']);
+        }
+
+        $conversationStmt = $this->pdo->prepare($conversationSql);
+        $conversationStmt->execute($conversationParams);
+        $conversationMetrics = $conversationStmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+
+        return [
+            'registeredUsers' => (int) $registeredStmt->fetchColumn(),
+            'activeAgents' => (int) $activeOperatorsStmt->fetchColumn(),
+            'totalConversations' => (int) ($conversationMetrics['total_conversations'] ?? 0),
+            'generalSentimentScore' => round((float) ($conversationMetrics['general_sentiment_score'] ?? 0), 2),
+            'unregisteredUsers' => (int) $unregisteredStmt->fetchColumn(),
+        ];
+    }
+
+    private function getPriorityQueue(int $companyId, array $filters, array $range): array
+    {
+        $sql = "SELECT
+                c.conversation_id AS id,
+                cl.client_name AS customer_name,
+                cl.client_segment AS segment,
+                COALESCE(NULLIF(a.alert_title, ''), ca.conversation_analysis_sentiment, c.conversation_status) AS status_label,
+                c.conversation_sla_deadline,
+                c.conversation_last_message_at,
+                o.operator_name AS seller_name,
+                c.conversation_last_message_preview AS last_message,
+                COALESCE(NULLIF(a.alert_description, ''), CONCAT(ca.conversation_analysis_aspect, ' — ', ca.conversation_analysis_sub_aspect), c.conversation_last_message_preview) AS motive,
+                c.conversation_impact_value,
+                c.conversation_ticket_value,
+                c.conversation_conversion_chance,
+                c.conversation_optimum_window,
+                c.client_id,
+                c.operator_id
+            FROM conversation c
+            INNER JOIN client cl ON cl.client_id = c.client_id AND cl.client_deleted_at = 'infinity'
+            INNER JOIN operator o ON o.operator_id = c.operator_id AND o.operator_deleted_at = 'infinity'
+            LEFT JOIN conversation_analysis ca
+                ON ca.conversation_id = c.conversation_id
+               AND ca.conversation_analysis_deleted_at = 'infinity'
+            LEFT JOIN LATERAL (
+                SELECT alert_title, alert_description
+                FROM alert
+                WHERE company_id = c.company_id
+                  AND client_id = c.client_id
+                  AND alert_deleted_at = 'infinity'
+                  AND alert_is_resolved = FALSE
+                ORDER BY alert_created_at DESC
+                LIMIT 1
+            ) a ON TRUE
+            WHERE c.company_id = :company_id
+              AND c.conversation_deleted_at = 'infinity'
+              AND c.conversation_started_at >= :start_datetime
+              AND c.conversation_started_at < :end_exclusive_datetime";
+        $params = [
+            'company_id' => $companyId,
+            'start_datetime' => $range['start_datetime'],
+            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
+        ];
+
+        if ($filters['area'] !== 'all') {
+            $sql .= " AND lower(o.operator_department) = :area";
+            $params['area'] = $filters['area'];
+        }
+
+        if ($filters['sentiment'] !== 'all') {
+            $sql .= ' AND ' . $this->getSentimentWhereClause('ca', $filters['sentiment']);
+        }
+
+        $sql .= " ORDER BY (c.conversation_sla_deadline < NOW()) DESC,
+                         ROUND(c.conversation_impact_value * (c.conversation_conversion_chance / 100.0)) DESC,
+                         c.conversation_last_message_at ASC
+                  LIMIT 10";
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute($params);
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        return array_map(function (array $row): array {
+            $impact = (float) ($row['conversation_impact_value'] ?? 0);
+            $chance = (int) ($row['conversation_conversion_chance'] ?? 0);
+
+            return [
+                'id' => (int) $row['id'],
+                'customerName' => $row['customer_name'] ?? '',
+                'segment' => $row['segment'] ?? '',
+                'status' => $this->normalizeStatusLabel((string) ($row['status_label'] ?? 'open')),
+                'slaStatus' => $this->formatSlaStatus($row['conversation_sla_deadline'] ?? null),
+                'timeAgo' => $this->formatRelativeTime($row['conversation_last_message_at'] ?? null),
+                'sellerName' => $row['seller_name'] ?? '',
+                'lastMessage' => $row['last_message'] ?? '',
+                'motive' => $row['motive'] ?? '',
+                'impact' => (int) round($impact),
+                'ticket' => (int) round((float) ($row['conversation_ticket_value'] ?? 0)),
+                'chance' => $chance,
+                'optimumWindow' => $row['conversation_optimum_window'] ?? '',
+                'score' => (int) round($impact * ($chance / 100)),
+                'conversationId' => (int) $row['id'],
+                'clientId' => (int) ($row['client_id'] ?? 0),
+                'operatorId' => (int) ($row['operator_id'] ?? 0),
+            ];
+        }, $rows);
+    }
+
+    private function getRadarData(int $companyId, array $range): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                emotion_confidence,
+                emotion_happiness,
+                emotion_anticipation,
+                emotion_fear,
+                emotion_sadness,
+                emotion_anger
+            FROM emotion_snapshot
+            WHERE company_id = :company_id
+              AND emotion_snapshot_date BETWEEN :start_date AND :end_date
+            ORDER BY emotion_snapshot_date DESC
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'start_date' => $range['start_date'],
+            'end_date' => $range['end_date'],
+        ]);
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+
+        return [
+            ['name' => 'Confiança', 'value' => (float) ($row['emotion_confidence'] ?? 0)],
+            ['name' => 'Alegria', 'value' => (float) ($row['emotion_happiness'] ?? 0)],
+            ['name' => 'Antecipação', 'value' => (float) ($row['emotion_anticipation'] ?? 0)],
+            ['name' => 'Medo', 'value' => (float) ($row['emotion_fear'] ?? 0)],
+            ['name' => 'Tristeza', 'value' => (float) ($row['emotion_sadness'] ?? 0)],
+            ['name' => 'Raiva', 'value' => (float) ($row['emotion_anger'] ?? 0)],
+        ];
+    }
+
+    private function getVolumeData(int $companyId, array $range): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                volume_snapshot_date,
+                lower(volume_channel) AS volume_channel,
+                SUM(volume_message_count) AS message_count
+            FROM volume_snapshot
+            WHERE company_id = :company_id
+              AND volume_snapshot_date BETWEEN :start_date AND :end_date
+            GROUP BY volume_snapshot_date, lower(volume_channel)
+            ORDER BY volume_snapshot_date ASC, lower(volume_channel) ASC"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'start_date' => $range['start_date'],
+            'end_date' => $range['end_date'],
+        ]);
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        $grouped = [];
+        foreach ($rows as $row) {
+            $date = $this->formatSnapshotDate((string) $row['volume_snapshot_date']);
+            if (!isset($grouped[$date])) {
+                $grouped[$date] = ['date' => $date];
+            }
+
+            $grouped[$date][$row['volume_channel']] = (int) ($row['message_count'] ?? 0);
+        }
+
+        return array_values($grouped);
+    }
+
+    private function getAspectsData(int $companyId, array $filters, array $range): array
+    {
+        $sql = "SELECT
+                aspect_feedback_aspect AS aspect,
+                SUM(CASE WHEN " . $this->getAspectSentimentCase('positive') . " THEN 1 ELSE 0 END) AS positive_count,
+                SUM(CASE WHEN " . $this->getAspectSentimentCase('neutral') . " THEN 1 ELSE 0 END) AS neutral_count,
+                SUM(CASE WHEN " . $this->getAspectSentimentCase('negative') . " THEN 1 ELSE 0 END) AS negative_count
+            FROM aspect_feedback
+            WHERE company_id = :company_id
+              AND aspect_feedback_deleted_at = 'infinity'
+              AND aspect_feedback_created_at >= :start_datetime
+              AND aspect_feedback_created_at < :end_exclusive_datetime";
+        $params = [
+            'company_id' => $companyId,
+            'start_datetime' => $range['start_datetime'],
+            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
+        ];
+
+        if ($filters['sentiment'] !== 'all') {
+            $sql .= ' AND ' . $this->getAspectFilterClause($filters['sentiment']);
+        }
+
+        $sql .= ' GROUP BY aspect_feedback_aspect ORDER BY aspect_feedback_aspect ASC';
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute($params);
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        return array_map(static function (array $row): array {
+            return [
+                'aspect' => $row['aspect'],
+                'positive' => (int) ($row['positive_count'] ?? 0),
+                'neutral' => (int) ($row['neutral_count'] ?? 0),
+                'negative' => (int) ($row['negative_count'] ?? 0),
+            ];
+        }, $rows);
+    }
+
+    private function getAspectsDrilldown(int $companyId, array $filters, array $range): array
+    {
+        $sql = "SELECT
+                aspect_feedback_aspect AS aspect,
+                aspect_feedback_sentiment AS sentiment,
+                aspect_feedback_text AS label,
+                COUNT(*) AS total
+            FROM aspect_feedback
+            WHERE company_id = :company_id
+              AND aspect_feedback_deleted_at = 'infinity'
+              AND aspect_feedback_created_at >= :start_datetime
+              AND aspect_feedback_created_at < :end_exclusive_datetime";
+        $params = [
+            'company_id' => $companyId,
+            'start_datetime' => $range['start_datetime'],
+            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
+        ];
+
+        if ($filters['sentiment'] !== 'all') {
+            $sql .= ' AND ' . $this->getAspectFilterClause($filters['sentiment']);
+        }
+
+        $sql .= ' GROUP BY aspect_feedback_aspect, aspect_feedback_sentiment, aspect_feedback_text
+                  ORDER BY aspect_feedback_aspect ASC, COUNT(*) DESC, aspect_feedback_text ASC';
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute($params);
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        $drilldown = [];
+        foreach ($rows as $row) {
+            $aspect = $row['aspect'];
+            $sentiment = $this->normalizeAspectSentiment((string) ($row['sentiment'] ?? 'neutral'));
+
+            if (!isset($drilldown[$aspect])) {
+                $drilldown[$aspect] = [
+                    'positive' => [],
+                    'neutral' => [],
+                    'negative' => [],
+                ];
+            }
+
+            if (count($drilldown[$aspect][$sentiment]) >= 5) {
+                continue;
+            }
+
+            $drilldown[$aspect][$sentiment][] = [
+                'label' => $row['label'],
+                'value' => (int) ($row['total'] ?? 0),
+            ];
+        }
+
+        return $drilldown;
+    }
+
+    private function getSentimentWhereClause(string $analysisAlias, string $sentiment): string
+    {
+        if ($sentiment === 'positive') {
+            return "(
+                lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('positive', 'positivo')
+                OR {$analysisAlias}.conversation_analysis_sentiment_score >= 0.15
+            )";
+        }
+
+        if ($sentiment === 'negative') {
+            return "(
+                lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('negative', 'negativo')
+                OR {$analysisAlias}.conversation_analysis_sentiment_score <= -0.15
+            )";
+        }
+
+        return "(
+            lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) NOT IN ('positive', 'positivo', 'negative', 'negativo')
+            AND {$analysisAlias}.conversation_analysis_sentiment_score > -0.15
+            AND {$analysisAlias}.conversation_analysis_sentiment_score < 0.15
+        )";
+    }
+
+    private function getAspectSentimentCase(string $sentiment): string
+    {
+        if ($sentiment === 'positive') {
+            return "lower(aspect_feedback_sentiment) IN ('positive', 'positivo')";
+        }
+
+        if ($sentiment === 'negative') {
+            return "lower(aspect_feedback_sentiment) IN ('negative', 'negativo')";
+        }
+
+        return "lower(aspect_feedback_sentiment) NOT IN ('positive', 'positivo', 'negative', 'negativo')";
+    }
+
+    private function getAspectFilterClause(string $sentiment): string
+    {
+        return $this->getAspectSentimentCase($sentiment);
+    }
+
+    private function normalizeAspectSentiment(string $sentiment): string
+    {
+        $normalized = strtolower(trim($sentiment));
+
+        if (in_array($normalized, ['positive', 'positivo'], true)) {
+            return 'positive';
+        }
+
+        if (in_array($normalized, ['negative', 'negativo'], true)) {
+            return 'negative';
+        }
+
+        return 'neutral';
+    }
+
+    private function normalizeStatusLabel(string $status): string
+    {
+        $normalized = trim($status);
+        if ($normalized === '') {
+            return 'SEM STATUS';
+        }
+
+        return mb_strtoupper(str_replace('_', ' ', $normalized));
+    }
+
+    private function formatSlaStatus(?string $deadline): string
+    {
+        if (!$deadline) {
+            return 'Sem SLA';
+        }
+
+        $deadlineTime = strtotime($deadline);
+        if ($deadlineTime === false) {
+            return 'Sem SLA';
+        }
+
+        $delta = $deadlineTime - time();
+        if ($delta >= 0) {
+            return 'Dentro do SLA';
+        }
+
+        $overdue = abs($delta);
+        if ($overdue >= 86400) {
+            return 'SLA ' . floor($overdue / 86400) . 'd estourado';
+        }
+
+        if ($overdue >= 3600) {
+            return 'SLA ' . floor($overdue / 3600) . 'h estourado';
+        }
+
+        return 'SLA ' . max(1, floor($overdue / 60)) . 'm estourado';
+    }
+
+    private function formatRelativeTime(?string $dateTime): string
+    {
+        if (!$dateTime) {
+            return 'agora';
+        }
+
+        $timestamp = strtotime($dateTime);
+        if ($timestamp === false) {
+            return 'agora';
+        }
+
+        $delta = max(0, time() - $timestamp);
+        if ($delta >= 86400) {
+            return 'há ' . floor($delta / 86400) . 'd';
+        }
+
+        if ($delta >= 3600) {
+            return 'há ' . floor($delta / 3600) . 'h';
+        }
+
+        if ($delta >= 60) {
+            return 'há ' . floor($delta / 60) . 'm';
+        }
+
+        return 'agora';
+    }
+
+    private function formatSnapshotDate(string $date): string
+    {
+        return $date . 'T00:00:00Z';
+    }
+}

+ 295 - 0
models/InteractionDetailsModel.php

@@ -0,0 +1,295 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+
+class InteractionDetailsModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function getInteractionDetails(int $companyId, int $conversationId): ?array
+    {
+        $conversation = $this->getConversation($companyId, $conversationId);
+        if ($conversation === null) {
+            return null;
+        }
+
+        $messages = $this->getMessages($conversationId);
+        $report = $this->buildReport($conversation, $messages);
+
+        return [
+            'conversation' => [
+                'conversationId' => (int) $conversation['conversation_id'],
+                'client' => $conversation['client_phone'] ?? '',
+                'channel' => $this->formatChannel((string) ($conversation['conversation_channel'] ?? '')),
+                'agent' => $conversation['operator_name'] ?? '',
+            ],
+            'thread' => array_map(function (array $message): array {
+                return [
+                    'id' => 'm' . (int) $message['message_id'],
+                    'isAgent' => (bool) $message['message_is_operator'],
+                    'text' => $message['message_content'] ?? '',
+                    'time' => $this->formatTime((string) ($message['message_sent_at'] ?? '')),
+                    'date' => $this->formatDate((string) ($message['message_sent_at'] ?? '')),
+                ];
+            }, $messages),
+            'report' => $report,
+        ];
+    }
+
+    private function getConversation(int $companyId, int $conversationId): ?array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                c.conversation_id,
+                c.conversation_channel,
+                c.conversation_started_at,
+                c.conversation_last_message_at,
+                cl.client_phone,
+                o.operator_name,
+                ca.conversation_analysis_aspect,
+                ca.conversation_analysis_sub_aspect,
+                ca.conversation_analysis_sentiment,
+                ca.conversation_analysis_sentiment_score
+            FROM conversation c
+            INNER JOIN client cl
+                ON cl.client_id = c.client_id
+               AND cl.client_deleted_at = 'infinity'
+            INNER JOIN operator o
+                ON o.operator_id = c.operator_id
+               AND o.operator_deleted_at = 'infinity'
+            LEFT JOIN conversation_analysis ca
+                ON ca.conversation_id = c.conversation_id
+               AND ca.conversation_analysis_deleted_at = 'infinity'
+            WHERE c.company_id = :company_id
+              AND c.conversation_id = :conversation_id
+              AND c.conversation_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'conversation_id' => $conversationId,
+        ]);
+
+        $conversation = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        return $conversation === false ? null : $conversation;
+    }
+
+    private function getMessages(int $conversationId): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                message_id,
+                message_is_operator,
+                message_content,
+                message_sent_at
+            FROM message
+            WHERE conversation_id = :conversation_id
+              AND message_deleted_at = 'infinity'
+              AND message_deleted = FALSE
+              AND message_hidden = FALSE
+              AND message_is_event = FALSE
+            ORDER BY message_sent_at ASC, message_id ASC"
+        );
+        $stmt->execute(['conversation_id' => $conversationId]);
+
+        return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    private function buildReport(array $conversation, array $messages): array
+    {
+        $avgResponseSeconds = $this->calculateAverageResponseSeconds($messages);
+        $avgAgentSeconds = $this->calculateAverageGapByAuthor($messages, true);
+        $avgClientSeconds = $this->calculateAverageGapByAuthor($messages, false);
+        $totalDurationSeconds = $this->calculateTotalDurationSeconds($conversation, $messages);
+        $lastMessageAuthor = $this->getLastMessageAuthor($messages);
+        $consecutiveMessages = $this->hasTrailingConsecutiveMessages($messages);
+
+        return [
+            'avgResponse' => $this->formatDuration($avgResponseSeconds),
+            'totalDuration' => $this->formatDuration($totalDurationSeconds),
+            'avgAgent' => $this->formatDuration($avgAgentSeconds),
+            'avgClient' => $this->formatDuration($avgClientSeconds),
+            'mainAspect' => $conversation['conversation_analysis_aspect'] ?? '',
+            'subAspect' => $conversation['conversation_analysis_sub_aspect'] ?? '',
+            'lastMessageAuthor' => $lastMessageAuthor,
+            'consecutiveMessages' => $consecutiveMessages,
+            'sentiment' => $this->normalizeSentimentLabel((string) ($conversation['conversation_analysis_sentiment'] ?? '')),
+            'score' => round((float) ($conversation['conversation_analysis_sentiment_score'] ?? 0), 2),
+        ];
+    }
+
+    private function calculateAverageResponseSeconds(array $messages): int
+    {
+        $responseTimes = [];
+        $pendingClientTimestamp = null;
+
+        foreach ($messages as $message) {
+            $timestamp = strtotime((string) ($message['message_sent_at'] ?? ''));
+            if ($timestamp === false) {
+                continue;
+            }
+
+            $isOperator = (bool) ($message['message_is_operator'] ?? false);
+
+            if (!$isOperator) {
+                if ($pendingClientTimestamp === null) {
+                    $pendingClientTimestamp = $timestamp;
+                }
+
+                continue;
+            }
+
+            if ($pendingClientTimestamp !== null && $timestamp >= $pendingClientTimestamp) {
+                $responseTimes[] = $timestamp - $pendingClientTimestamp;
+                $pendingClientTimestamp = null;
+            }
+        }
+
+        if ($responseTimes === []) {
+            return 0;
+        }
+
+        return (int) round(array_sum($responseTimes) / count($responseTimes));
+    }
+
+    private function calculateAverageGapByAuthor(array $messages, bool $isOperator): int
+    {
+        $timestamps = [];
+
+        foreach ($messages as $message) {
+            if ((bool) ($message['message_is_operator'] ?? false) !== $isOperator) {
+                continue;
+            }
+
+            $timestamp = strtotime((string) ($message['message_sent_at'] ?? ''));
+            if ($timestamp === false) {
+                continue;
+            }
+
+            $timestamps[] = $timestamp;
+        }
+
+        if (count($timestamps) < 2) {
+            return 0;
+        }
+
+        $gaps = [];
+        for ($i = 1, $len = count($timestamps); $i < $len; $i++) {
+            $gap = $timestamps[$i] - $timestamps[$i - 1];
+            if ($gap >= 0) {
+                $gaps[] = $gap;
+            }
+        }
+
+        if ($gaps === []) {
+            return 0;
+        }
+
+        return (int) round(array_sum($gaps) / count($gaps));
+    }
+
+    private function calculateTotalDurationSeconds(array $conversation, array $messages): int
+    {
+        if (count($messages) >= 2) {
+            $first = strtotime((string) ($messages[0]['message_sent_at'] ?? ''));
+            $last = strtotime((string) ($messages[count($messages) - 1]['message_sent_at'] ?? ''));
+            if ($first !== false && $last !== false && $last >= $first) {
+                return $last - $first;
+            }
+        }
+
+        $startedAt = strtotime((string) ($conversation['conversation_started_at'] ?? ''));
+        $lastMessageAt = strtotime((string) ($conversation['conversation_last_message_at'] ?? ''));
+        if ($startedAt === false || $lastMessageAt === false || $lastMessageAt < $startedAt) {
+            return 0;
+        }
+
+        return $lastMessageAt - $startedAt;
+    }
+
+    private function getLastMessageAuthor(array $messages): string
+    {
+        if ($messages === []) {
+            return 'Cliente';
+        }
+
+        $lastMessage = $messages[count($messages) - 1];
+
+        return (bool) ($lastMessage['message_is_operator'] ?? false) ? 'Operador' : 'Cliente';
+    }
+
+    private function hasTrailingConsecutiveMessages(array $messages): bool
+    {
+        $count = count($messages);
+        if ($count < 2) {
+            return false;
+        }
+
+        $last = (bool) ($messages[$count - 1]['message_is_operator'] ?? false);
+        $previous = (bool) ($messages[$count - 2]['message_is_operator'] ?? false);
+
+        return $last === $previous;
+    }
+
+    private function normalizeSentimentLabel(string $label): string
+    {
+        $normalized = trim($label);
+        if ($normalized === '') {
+            return 'NEUTRO';
+        }
+
+        return mb_strtoupper(str_replace('_', ' ', $normalized));
+    }
+
+    private function formatChannel(string $channel): string
+    {
+        $normalized = strtolower(trim($channel));
+
+        if ($normalized === 'whatsapp') {
+            return 'WhatsApp';
+        }
+
+        if ($normalized === '') {
+            return '';
+        }
+
+        return ucfirst($normalized);
+    }
+
+    private function formatTime(string $dateTime): string
+    {
+        $timestamp = strtotime($dateTime);
+        if ($timestamp === false) {
+            return '00:00';
+        }
+
+        return date('H:i', $timestamp);
+    }
+
+    private function formatDate(string $dateTime): string
+    {
+        $timestamp = strtotime($dateTime);
+        if ($timestamp === false) {
+            return '';
+        }
+
+        return date('Y-m-d', $timestamp);
+    }
+
+    private function formatDuration(int $seconds): string
+    {
+        $seconds = max(0, $seconds);
+        $minutes = intdiv($seconds, 60);
+        $remainingSeconds = $seconds % 60;
+
+        return str_pad((string) $minutes, 2, '0', STR_PAD_LEFT) . ':' . str_pad((string) $remainingSeconds, 2, '0', STR_PAD_LEFT);
+    }
+}

+ 247 - 0
models/InteractionsModel.php

@@ -0,0 +1,247 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+
+class InteractionsModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function getInteractionsData(int $companyId, string $userEmail, array $queryParams): array
+    {
+        $filters = $this->normalizeFilters($queryParams);
+        $myOperatorId = $this->getOperatorIdByUserEmail($companyId, $userEmail);
+        [$whereSql, $params] = $this->buildWhereClause($companyId, $filters, $myOperatorId);
+
+        $total = $this->getTotalCount($whereSql, $params);
+        $items = $this->getItems($whereSql, $params, $filters['page'], $filters['per_page']);
+
+        return [
+            'items' => $items,
+            'pagination' => [
+                'page' => $filters['page'],
+                'per_page' => $filters['per_page'],
+                'total' => $total,
+                'total_pages' => $filters['per_page'] > 0 ? (int) ceil($total / $filters['per_page']) : 0,
+            ],
+        ];
+    }
+
+    private function getOperatorIdByUserEmail(int $companyId, string $userEmail): ?int
+    {
+        $normalizedEmail = mb_strtolower(trim($userEmail));
+        if ($normalizedEmail === '') {
+            return null;
+        }
+
+        $stmt = $this->pdo->prepare(
+            "SELECT operator_id
+            FROM operator
+            WHERE company_id = :company_id
+              AND operator_deleted_at = 'infinity'
+              AND lower(operator_email) = :email
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'email' => $normalizedEmail,
+        ]);
+        $operatorId = $stmt->fetchColumn();
+
+        return $operatorId === false ? null : (int) $operatorId;
+    }
+
+    private function normalizeFilters(array $queryParams): array
+    {
+        $page = max(1, (int) ($queryParams['page'] ?? 1));
+        $perPage = (int) ($queryParams['per_page'] ?? 20);
+        $perPage = max(1, min(100, $perPage));
+
+        $filter = strtolower(trim((string) ($queryParams['filter'] ?? 'all')));
+        if (!in_array($filter, ['all', 'my_clients', 'new', 'unfinished'], true)) {
+            $filter = 'all';
+        }
+
+        $sentiment = strtolower(trim((string) ($queryParams['sentiment'] ?? 'all')));
+        if (!in_array($sentiment, ['all', 'positive', 'neutral', 'negative'], true)) {
+            $sentiment = 'all';
+        }
+
+        return [
+            'page' => $page,
+            'per_page' => $perPage,
+            'search' => trim((string) ($queryParams['search'] ?? '')),
+            'filter' => $filter,
+            'sentiment' => $sentiment,
+            'operator_id' => max(0, (int) ($queryParams['operator_id'] ?? 0)),
+        ];
+    }
+
+    private function buildWhereClause(int $companyId, array $filters, ?int $myOperatorId): array
+    {
+        $where = [
+            "c.company_id = :company_id",
+            "c.conversation_deleted_at = 'infinity'",
+            "cl.client_deleted_at = 'infinity'",
+            "o.operator_deleted_at = 'infinity'",
+        ];
+        $params = ['company_id' => $companyId];
+
+        if ($filters['search'] !== '') {
+            $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)';
+            $params['search'] = '%' . $filters['search'] . '%';
+        }
+
+        if ($filters['operator_id'] > 0) {
+            $where[] = 'c.operator_id = :operator_id';
+            $params['operator_id'] = $filters['operator_id'];
+        }
+
+        if ($filters['filter'] === 'unfinished') {
+            $where[] = "lower(c.conversation_status) <> 'closed'";
+        }
+
+        if ($filters['filter'] === 'new') {
+            $where[] = "c.conversation_started_at >= NOW() - INTERVAL '24 hours'";
+        }
+
+        if ($filters['filter'] === 'my_clients') {
+            if ($myOperatorId !== null) {
+                $where[] = 'c.operator_id = :my_operator_id';
+                $params['my_operator_id'] = $myOperatorId;
+            } else {
+                $where[] = '1 = 0';
+            }
+        }
+
+        if ($filters['sentiment'] !== 'all') {
+            $where[] = $this->getSentimentWhereClause('ca', $filters['sentiment']);
+        }
+
+        return [implode("\n              AND ", $where), $params];
+    }
+
+    private function getTotalCount(string $whereSql, array $params): int
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT COUNT(*)
+            FROM conversation c
+            INNER JOIN client cl ON cl.client_id = c.client_id
+            INNER JOIN operator o ON o.operator_id = c.operator_id
+            LEFT JOIN conversation_analysis ca
+                ON ca.conversation_id = c.conversation_id
+               AND ca.conversation_analysis_deleted_at = 'infinity'
+            WHERE {$whereSql}"
+        );
+        $stmt->execute($params);
+
+        return (int) $stmt->fetchColumn();
+    }
+
+    private function getItems(string $whereSql, array $params, int $page, int $perPage): array
+    {
+        $offset = ($page - 1) * $perPage;
+        $params['limit'] = $perPage;
+        $params['offset'] = $offset;
+
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                c.conversation_id,
+                cl.client_phone,
+                o.operator_name,
+                COALESCE(ca.conversation_analysis_sentiment, c.conversation_status) AS sentiment_label,
+                COALESCE(ca.conversation_analysis_sentiment_score, 0) AS sentiment_score,
+                COALESCE(ca.conversation_analysis_aspect, '') AS aspect,
+                COALESCE(ca.conversation_analysis_sub_aspect, '') AS sub_aspect,
+                c.conversation_last_message_at
+            FROM conversation c
+            INNER JOIN client cl ON cl.client_id = c.client_id
+            INNER JOIN operator o ON o.operator_id = c.operator_id
+            LEFT JOIN conversation_analysis ca
+                ON ca.conversation_id = c.conversation_id
+               AND ca.conversation_analysis_deleted_at = 'infinity'
+            WHERE {$whereSql}
+            ORDER BY c.conversation_last_message_at DESC, c.conversation_id DESC
+            LIMIT :limit OFFSET :offset"
+        );
+
+        foreach ($params as $key => $value) {
+            if (in_array($key, ['limit', 'offset', 'operator_id', 'my_operator_id', 'company_id'], true)) {
+                $stmt->bindValue(':' . $key, (int) $value, \PDO::PARAM_INT);
+                continue;
+            }
+
+            $stmt->bindValue(':' . $key, $value);
+        }
+
+        $stmt->execute();
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        return array_map(function (array $row): array {
+            return [
+                'conversationId' => (int) $row['conversation_id'],
+                'client' => $row['client_phone'] ?? '',
+                'agent' => $row['operator_name'] ?? '',
+                'sentiment' => $this->normalizeSentimentLabel((string) ($row['sentiment_label'] ?? '')),
+                'score' => round((float) ($row['sentiment_score'] ?? 0), 2),
+                'aspect' => $row['aspect'] ?? '',
+                'subaspect' => $row['sub_aspect'] ?? '',
+                'datetime' => $this->formatIsoDateTime($row['conversation_last_message_at'] ?? null),
+            ];
+        }, $rows);
+    }
+
+    private function getSentimentWhereClause(string $analysisAlias, string $sentiment): string
+    {
+        if ($sentiment === 'positive') {
+            return "(
+                lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('positive', 'positivo')
+                OR {$analysisAlias}.conversation_analysis_sentiment_score >= 0.15
+            )";
+        }
+
+        if ($sentiment === 'negative') {
+            return "(
+                lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('negative', 'negativo')
+                OR {$analysisAlias}.conversation_analysis_sentiment_score <= -0.15
+            )";
+        }
+
+        return "(
+            {$analysisAlias}.conversation_id IS NOT NULL
+            AND lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) NOT IN ('positive', 'positivo', 'negative', 'negativo')
+            AND {$analysisAlias}.conversation_analysis_sentiment_score > -0.15
+            AND {$analysisAlias}.conversation_analysis_sentiment_score < 0.15
+        )";
+    }
+
+    private function normalizeSentimentLabel(string $label): string
+    {
+        $normalized = trim($label);
+        if ($normalized === '') {
+            return 'NEUTRO';
+        }
+
+        return mb_strtoupper(str_replace('_', ' ', $normalized));
+    }
+
+    private function formatIsoDateTime(?string $dateTime): ?string
+    {
+        if (!$dateTime) {
+            return null;
+        }
+
+        $timestamp = strtotime($dateTime);
+        if ($timestamp === false) {
+            return null;
+        }
+
+        return gmdate('Y-m-d\TH:i:s\Z', $timestamp);
+    }
+}

+ 354 - 0
models/PersonasModel.php

@@ -0,0 +1,354 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+
+class PersonasModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function getOverviewData(int $companyId, array $queryParams): array
+    {
+        $filters = $this->normalizeFilters($queryParams);
+        $range = $this->resolveDateRange($filters['period']);
+        $personaRows = $this->getPersonaRows($companyId, $filters, $range);
+        $stats = $this->getStats($companyId, $filters, $range);
+
+        return [
+            'kpis' => $this->buildKpis($personaRows, $stats),
+            'stats' => $stats,
+            'personas' => array_map(fn (array $row): array => $this->formatPersonaRow($row), $personaRows),
+        ];
+    }
+
+    private function normalizeFilters(array $queryParams): array
+    {
+        $period = strtolower(trim((string) ($queryParams['period'] ?? 'week')));
+        if (!in_array($period, ['week', 'month', 'quarter'], true)) {
+            $period = 'week';
+        }
+
+        $unit = strtolower(trim((string) ($queryParams['unit'] ?? 'all')));
+        if ($unit === '') {
+            $unit = 'all';
+        }
+
+        $area = trim((string) ($queryParams['area'] ?? 'any'));
+        if ($area === '') {
+            $area = 'any';
+        }
+        $area = mb_strtolower($area);
+        if ($area === 'all') {
+            $area = 'any';
+        }
+
+        $sentiment = strtolower(trim((string) ($queryParams['sentiment'] ?? 'all')));
+        if (!in_array($sentiment, ['all', 'positive', 'neutral', 'negative'], true)) {
+            $sentiment = 'all';
+        }
+
+        return [
+            'period' => $period,
+            'unit' => $unit,
+            'area' => $area,
+            'area_token' => $this->normalizeToken($area),
+            'sentiment' => $sentiment,
+        ];
+    }
+
+    private function resolveDateRange(string $period): array
+    {
+        $today = new \DateTimeImmutable('today');
+
+        if ($period === 'quarter') {
+            $start = $today->modify('-89 days');
+        } elseif ($period === 'month') {
+            $start = $today->modify('-29 days');
+        } else {
+            $start = $today->modify('-6 days');
+        }
+
+        return [
+            'start_datetime' => $start->format('Y-m-d 00:00:00'),
+            'end_exclusive_datetime' => $today->modify('+1 day')->format('Y-m-d 00:00:00'),
+        ];
+    }
+
+    private function getPersonaRows(int $companyId, array $filters, array $range): array
+    {
+        $sql = "SELECT
+                p.persona_id,
+                p.persona_name,
+                p.persona_type,
+                p.persona_description,
+                p.persona_details,
+                p.persona_risk_level,
+                p.persona_churn_risk_pct,
+                p.persona_expansion_potential,
+                COALESCE((
+                    SELECT ba.best_action_idea
+                    FROM best_action ba
+                    WHERE ba.persona_id = p.persona_id
+                      AND ba.best_action_deleted_at = 'infinity'
+                      AND lower(ba.best_action_type) IN ('expansao', 'expansão', 'expansion', 'upsell', 'cross_sell')
+                    ORDER BY ba.best_action_created_at DESC
+                    LIMIT 1
+                ), p.persona_expansion_strategy) AS expansao,
+                COALESCE((
+                    SELECT ba.best_action_idea
+                    FROM best_action ba
+                    WHERE ba.persona_id = p.persona_id
+                      AND ba.best_action_deleted_at = 'infinity'
+                      AND lower(ba.best_action_type) IN ('engajamento', 'engagement', 'retencao', 'retenção', 'churn', 'save_churn')
+                    ORDER BY ba.best_action_created_at DESC
+                    LIMIT 1
+                ), p.persona_engagement_strategy) AS engajamento,
+                COUNT(DISTINCT CASE WHEN c.conversation_id IS NOT NULL THEN cp.client_id END) AS matched_clients
+            FROM persona p
+            LEFT JOIN client_persona cp
+                ON cp.persona_id = p.persona_id
+               AND cp.client_persona_deleted_at = 'infinity'
+            LEFT JOIN conversation c
+                ON c.client_id = cp.client_id
+               AND c.company_id = p.company_id
+               AND c.conversation_deleted_at = 'infinity'
+               AND c.conversation_last_message_at >= :start_datetime
+               AND c.conversation_last_message_at < :end_exclusive_datetime
+            LEFT JOIN conversation_analysis ca
+                ON ca.conversation_id = c.conversation_id
+               AND ca.conversation_analysis_deleted_at = 'infinity'
+            WHERE p.company_id = :company_id
+              AND p.persona_deleted_at = 'infinity'";
+        $params = [
+            'company_id' => $companyId,
+            'start_datetime' => $range['start_datetime'],
+            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
+        ];
+        $this->appendPersonaActivityFilters($sql, $params, 'ca', $filters);
+
+        $sql .= "
+            GROUP BY
+                p.persona_id,
+                p.persona_name,
+                p.persona_type,
+                p.persona_description,
+                p.persona_details,
+                p.persona_risk_level,
+                p.persona_churn_risk_pct,
+                p.persona_expansion_potential,
+                p.persona_expansion_strategy,
+                p.persona_engagement_strategy,
+                p.persona_created_at
+            ORDER BY matched_clients DESC, p.persona_created_at DESC, p.persona_id ASC";
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute($params);
+
+        return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    private function getStats(int $companyId, array $filters, array $range): array
+    {
+        $sql = "SELECT
+                COUNT(DISTINCT CASE WHEN c.conversation_id IS NOT NULL THEN cp.client_id END) AS identified,
+                COUNT(DISTINCT m.message_id) AS messages,
+                COUNT(DISTINCT NULLIF(ca.conversation_analysis_aspect, '')) AS aspects,
+                COUNT(DISTINCT NULLIF(ca.conversation_analysis_sub_aspect, '')) AS subaspects
+            FROM persona p
+            LEFT JOIN client_persona cp
+                ON cp.persona_id = p.persona_id
+               AND cp.client_persona_deleted_at = 'infinity'
+            LEFT JOIN conversation c
+                ON c.client_id = cp.client_id
+               AND c.company_id = p.company_id
+               AND c.conversation_deleted_at = 'infinity'
+               AND c.conversation_last_message_at >= :start_datetime
+               AND c.conversation_last_message_at < :end_exclusive_datetime
+            LEFT JOIN conversation_analysis ca
+                ON ca.conversation_id = c.conversation_id
+               AND ca.conversation_analysis_deleted_at = 'infinity'
+            LEFT JOIN message m
+                ON m.conversation_id = c.conversation_id
+               AND m.message_deleted_at = 'infinity'
+               AND m.message_sent_at >= :start_datetime
+               AND m.message_sent_at < :end_exclusive_datetime
+            WHERE p.company_id = :company_id
+              AND p.persona_deleted_at = 'infinity'";
+        $params = [
+            'company_id' => $companyId,
+            'start_datetime' => $range['start_datetime'],
+            'end_exclusive_datetime' => $range['end_exclusive_datetime'],
+        ];
+        $this->appendPersonaActivityFilters($sql, $params, 'ca', $filters);
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute($params);
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+
+        return [
+            'identified' => (int) ($row['identified'] ?? 0),
+            'messages' => (int) ($row['messages'] ?? 0),
+            'aspects' => (int) ($row['aspects'] ?? 0),
+            'subaspects' => (int) ($row['subaspects'] ?? 0),
+        ];
+    }
+
+    private function buildKpis(array $personaRows, array $stats): array
+    {
+        $activeRows = array_values(array_filter($personaRows, static function (array $row): bool {
+            return (int) ($row['matched_clients'] ?? 0) > 0;
+        }));
+        $sourceRows = $activeRows !== [] ? $activeRows : $personaRows;
+
+        if ($sourceRows === []) {
+            return [
+                'active' => 0,
+                'churn' => 0.0,
+                'loss' => 0.0,
+                'potentialLabel' => 'Neutro',
+            ];
+        }
+
+        $avgChurn = array_sum(array_map(static function (array $row): float {
+            return (float) ($row['persona_churn_risk_pct'] ?? 0);
+        }, $sourceRows)) / count($sourceRows);
+
+        $avgPotentialScore = array_sum(array_map(function (array $row): float {
+            return $this->mapPotentialLabelToScore((string) ($row['persona_expansion_potential'] ?? ''));
+        }, $sourceRows)) / count($sourceRows);
+
+        $estimatedLoss = round($stats['identified'] * ($avgChurn / 100) * 450, 2);
+
+        return [
+            'active' => count($activeRows),
+            'churn' => round($avgChurn, 1),
+            'loss' => $estimatedLoss,
+            'potentialLabel' => $this->mapScoreToPotentialLabel($avgPotentialScore),
+        ];
+    }
+
+    private function formatPersonaRow(array $row): array
+    {
+        return [
+            'id' => (string) ($row['persona_id'] ?? ''),
+            'nome' => $row['persona_name'] ?? '',
+            'tipo' => $row['persona_type'] ?? 'O PERFIL',
+            'descricao' => $row['persona_description'] ?? '',
+            'detalhes' => $row['persona_details'] ?? '',
+            'expansao' => $row['expansao'] ?? '',
+            'engajamento' => $row['engajamento'] ?? '',
+            'risco' => $row['persona_risk_level'] ?? 'Médio',
+        ];
+    }
+
+    private function appendPersonaActivityFilters(string &$sql, array &$params, string $analysisAlias, array $filters): void
+    {
+        if ($filters['area'] !== 'any' && $filters['area_token'] !== '') {
+            $sql .= ' AND ('
+                . $this->getNormalizedTextSql("{$analysisAlias}.conversation_analysis_aspect") . ' = :area_token'
+                . ' OR '
+                . $this->getNormalizedTextSql("{$analysisAlias}.conversation_analysis_sub_aspect") . ' = :area_token'
+                . ')';
+            $params['area_token'] = $filters['area_token'];
+        }
+
+        if ($filters['sentiment'] !== 'all') {
+            $sql .= ' AND ' . $this->getSentimentWhereClause($analysisAlias, $filters['sentiment']);
+        }
+    }
+
+    private function getSentimentWhereClause(string $analysisAlias, string $sentiment): string
+    {
+        if ($sentiment === 'positive') {
+            return "(
+                lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('positive', 'positivo')
+                OR {$analysisAlias}.conversation_analysis_sentiment_score >= 0.15
+            )";
+        }
+
+        if ($sentiment === 'negative') {
+            return "(
+                lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('negative', 'negativo')
+                OR {$analysisAlias}.conversation_analysis_sentiment_score <= -0.15
+            )";
+        }
+
+        return "(
+            {$analysisAlias}.conversation_id IS NOT NULL
+            AND lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) NOT IN ('positive', 'positivo', 'negative', 'negativo')
+            AND {$analysisAlias}.conversation_analysis_sentiment_score > -0.15
+            AND {$analysisAlias}.conversation_analysis_sentiment_score < 0.15
+        )";
+    }
+
+    private function getNormalizedTextSql(string $column): string
+    {
+        return "regexp_replace(translate(lower(COALESCE({$column}, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn'), '[^a-z0-9]+', '', 'g')";
+    }
+
+    private function normalizeToken(string $value): string
+    {
+        $normalized = trim($value);
+        if ($normalized === '' || $normalized === 'any') {
+            return '';
+        }
+
+        if (function_exists('iconv')) {
+            $converted = iconv('UTF-8', 'ASCII//TRANSLIT', $normalized);
+            if (is_string($converted) && $converted !== '') {
+                $normalized = $converted;
+            }
+        }
+
+        $normalized = mb_strtolower($normalized);
+        $normalized = preg_replace('/[^a-z0-9]+/', '', $normalized) ?? '';
+
+        return $normalized;
+    }
+
+    private function mapPotentialLabelToScore(string $label): float
+    {
+        $normalized = $this->normalizeToken($label);
+
+        if ($normalized === 'muitoalto') {
+            return 1.5;
+        }
+
+        if ($normalized === 'alto') {
+            return 1.0;
+        }
+
+        if ($normalized === 'medio') {
+            return 0.35;
+        }
+
+        if ($normalized === 'baixo') {
+            return -1.0;
+        }
+
+        return 0.0;
+    }
+
+    private function mapScoreToPotentialLabel(float $score): string
+    {
+        if ($score >= 1.2) {
+            return 'Muito alto';
+        }
+
+        if ($score >= 0.5) {
+            return 'Alto';
+        }
+
+        if ($score <= -0.35) {
+            return 'Baixo';
+        }
+
+        return 'Neutro';
+    }
+}

+ 63 - 1
models/UserModel.php

@@ -92,4 +92,66 @@ class UserModel
             'user_created_at' => $createdUser['user_created_at'],
         ];
     }
-}
+
+    public function getCompanyIdByUserId(int $userId): ?int
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT company_id
+            FROM \"user\"
+            WHERE user_id = :user_id
+              AND user_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute(['user_id' => $userId]);
+        $companyId = $stmt->fetchColumn();
+
+        return $companyId === false ? null : (int) $companyId;
+    }
+
+    public function getAuthenticatedProfile(int $userId): ?array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT 
+                u.user_id,
+                u.company_id,
+                u.user_name,
+                u.user_phone,
+                u.user_email,
+                u.user_role,
+                u.user_created_at,
+                c.company_name,
+                c.company_cnpj,
+                c.company_logo,
+                c.company_created_at
+            FROM "user" u
+            INNER JOIN company c ON c.company_id = u.company_id
+            WHERE u.user_id = :user_id
+              AND u.user_deleted_at = \'infinity\'
+              AND c.company_deleted_at = \'infinity\'
+            LIMIT 1'
+        );
+        $stmt->execute(['user_id' => $userId]);
+        $user = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        if (!$user) {
+            return null;
+        }
+
+        return [
+            'user_id' => (int) $user['user_id'],
+            'company_id' => (int) $user['company_id'],
+            'user_name' => $user['user_name'],
+            'user_phone' => $user['user_phone'],
+            'user_email' => $user['user_email'],
+            'user_role' => $user['user_role'],
+            'user_created_at' => $user['user_created_at'],
+            'company' => [
+                'company_id' => (int) $user['company_id'],
+                'company_name' => $user['company_name'],
+                'company_cnpj' => $user['company_cnpj'],
+                'company_logo' => $user['company_logo'],
+                'company_created_at' => $user['company_created_at'],
+            ],
+        ];
+    }
+}

+ 2 - 0
public/index.php

@@ -29,6 +29,8 @@ $app->get('/me', $authJwt,\Controllers\MeController::class);
 $app->get('/dashboard/overview', $authJwt,\Controllers\DashboardOverviewController::class);
 $app->get('/interactions', $authJwt,\Controllers\InteractionsController::class);
 $app->get('/interactions/details', $authJwt,\Controllers\InteractionDetailsController::class);
+$app->get('/analytics/sentiment/dashboard', $authJwt,\Controllers\AnalyticsSentimentDashboardController::class);
+$app->get('/personas/overview', $authJwt,\Controllers\PersonasOverviewController::class);
 
 $app->post('/login', \Controllers\LoginController::class);
 $app->post('/register', \Controllers\RegisterController::class);