Bladeren bron

add security, add the logs, fix the multi connections on the database, add version in routes, add fix other security problems

gdias 2 weken geleden
bovenliggende
commit
3d7a43031e

+ 6 - 0
.env.example

@@ -5,3 +5,9 @@ DB_NAME=nettown
 DB_USER=postgres
 DB_PASS=
 JWT_SECRET=Aer8woa9zeec2gai4ahQuah3Ahbee5eiSefae8pheepahnootuShoo0oKahf
+
+# Logging
+# LOG_ENABLED: liga/desliga o sistema de log (true/false)
+LOG_ENABLED=true
+# LOG_FILE: caminho do arquivo de log (padrão: log.txt na raiz do projeto)
+LOG_FILE=log.txt

+ 3 - 1
.gitignore

@@ -2,4 +2,6 @@ vendor/
 .env
 *.log
 test.db
-build/
+build/
+.windsurfrules
+claude.md

File diff suppressed because it is too large
+ 441 - 2594
composer.lock


+ 2 - 0
controllers/AgentEscalationController.php

@@ -2,6 +2,7 @@
 
 namespace Controllers;
 
+use Libs\Logger;
 use Libs\ResponseLib;
 use Models\AgentsModel;
 use Models\UserModel;
@@ -45,6 +46,7 @@ class AgentEscalationController
 
             return ResponseLib::sendOk($agent);
         } catch (\Throwable $e) {
+            Logger::error('Failed to update agent escalation', ['error' => $e->getMessage()]);
             return ResponseLib::sendFail('Failed to update agent escalation', [], 'E_GENERIC')->withStatus(500);
         }
     }

+ 2 - 0
controllers/AgentSaveController.php

@@ -2,6 +2,7 @@
 
 namespace Controllers;
 
+use Libs\Logger;
 use Libs\ResponseLib;
 use Models\AgentsModel;
 use Models\UserModel;
@@ -40,6 +41,7 @@ class AgentSaveController
 
             return ResponseLib::sendOk($agent, 'S_CREATED');
         } catch (\Throwable $e) {
+            Logger::error('Failed to save agent', ['error' => $e->getMessage()]);
             return ResponseLib::sendFail('Failed to save agent', [], 'E_GENERIC')->withStatus(500);
         }
     }

+ 2 - 0
controllers/AgentStatusController.php

@@ -2,6 +2,7 @@
 
 namespace Controllers;
 
+use Libs\Logger;
 use Libs\ResponseLib;
 use Models\AgentsModel;
 use Models\UserModel;
@@ -45,6 +46,7 @@ class AgentStatusController
 
             return ResponseLib::sendOk($agent);
         } catch (\Throwable $e) {
+            Logger::error('Failed to update agent status', ['error' => $e->getMessage()]);
             return ResponseLib::sendFail('Failed to update agent status', [], 'E_GENERIC')->withStatus(500);
         }
     }

+ 2 - 0
controllers/AgentsController.php

@@ -2,6 +2,7 @@
 
 namespace Controllers;
 
+use Libs\Logger;
 use Libs\ResponseLib;
 use Models\AgentsModel;
 use Models\UserModel;
@@ -36,6 +37,7 @@ class AgentsController
                 $this->agentsModel->getAgentsData($companyId, $request->getQueryParams())
             );
         } catch (\Throwable $e) {
+            Logger::error('Failed to load agents', ['error' => $e->getMessage()]);
             return ResponseLib::sendFail('Failed to load agents', [], 'E_GENERIC')->withStatus(500);
         }
     }

+ 2 - 0
controllers/AnalyticsSentimentDashboardController.php

@@ -2,6 +2,7 @@
 
 namespace Controllers;
 
+use Libs\Logger;
 use Libs\ResponseLib;
 use Models\AnalyticsSentimentDashboardModel;
 use Models\UserModel;
@@ -36,6 +37,7 @@ class AnalyticsSentimentDashboardController
                 $this->analyticsSentimentDashboardModel->getDashboardData($companyId, $request->getQueryParams())
             );
         } catch (\Throwable $e) {
+            Logger::error('Failed to load sentiment dashboard', ['error' => $e->getMessage()]);
             return ResponseLib::sendFail('Failed to load sentiment dashboard', [], 'E_GENERIC')->withStatus(500);
         }
     }

+ 2 - 0
controllers/DashboardOverviewController.php

@@ -2,6 +2,7 @@
 
 namespace Controllers;
 
+use Libs\Logger;
 use Libs\ResponseLib;
 use Models\DashboardOverviewModel;
 use Models\UserModel;
@@ -37,6 +38,7 @@ class DashboardOverviewController
                 $this->dashboardOverviewModel->getOverviewData($companyId, $request->getQueryParams())
             );
         } catch (\Throwable $e) {
+            Logger::error('Failed to load dashboard overview', ['error' => $e->getMessage()]);
             return ResponseLib::sendFail('Failed to load dashboard overview', [], 'E_GENERIC')->withStatus(500);
         }
     }

+ 2 - 0
controllers/EvolutionOverviewController.php

@@ -2,6 +2,7 @@
 
 namespace Controllers;
 
+use Libs\Logger;
 use Libs\ResponseLib;
 use Models\EvolutionOverviewModel;
 use Models\UserModel;
@@ -36,6 +37,7 @@ class EvolutionOverviewController
                 $this->evolutionOverviewModel->getOverviewData($companyId, $request->getQueryParams())
             );
         } catch (\Throwable $e) {
+            Logger::error('Failed to load evolution overview', ['error' => $e->getMessage()]);
             return ResponseLib::sendFail('Failed to load evolution overview', [], 'E_GENERIC')->withStatus(500);
         }
     }

+ 2 - 0
controllers/ExecutiveDashboardController.php

@@ -2,6 +2,7 @@
 
 namespace Controllers;
 
+use Libs\Logger;
 use Libs\ResponseLib;
 use Models\ExecutiveDashboardModel;
 use Models\UserModel;
@@ -36,6 +37,7 @@ class ExecutiveDashboardController
                 $this->executiveDashboardModel->getDashboardData($companyId, $request->getQueryParams())
             );
         } catch (\Throwable $e) {
+            Logger::error('Failed to load executive dashboard', ['error' => $e->getMessage()]);
             return ResponseLib::sendFail('Failed to load executive dashboard', [], 'E_GENERIC')->withStatus(500);
         }
     }

+ 0 - 1
controllers/HelloController.php

@@ -9,7 +9,6 @@ class HelloController
 {
     public function __invoke(ServerRequestInterface $request)
     {
-        //$apiUser = $request->getAttribute('api_user');  // Exemplo: usa atributo do middleware
         $data = ["message" => "Hello World!"];
         return ResponseLib::sendOk($data);
     }

+ 2 - 0
controllers/InteractionDetailsController.php

@@ -2,6 +2,7 @@
 
 namespace Controllers;
 
+use Libs\Logger;
 use Libs\ResponseLib;
 use Models\InteractionDetailsModel;
 use Models\UserModel;
@@ -44,6 +45,7 @@ class InteractionDetailsController
 
             return ResponseLib::sendOk($data);
         } catch (\Throwable $e) {
+            Logger::error('Failed to load interaction details', ['error' => $e->getMessage()]);
             return ResponseLib::sendFail('Failed to load interaction details', [], 'E_GENERIC')->withStatus(500);
         }
     }

+ 2 - 0
controllers/InteractionsController.php

@@ -2,6 +2,7 @@
 
 namespace Controllers;
 
+use Libs\Logger;
 use Libs\ResponseLib;
 use Models\InteractionsModel;
 use Models\UserModel;
@@ -37,6 +38,7 @@ class InteractionsController
                 $this->interactionsModel->getInteractionsData($companyId, $userEmail, $request->getQueryParams())
             );
         } catch (\Throwable $e) {
+            Logger::error('Failed to load interactions', ['error' => $e->getMessage()]);
             return ResponseLib::sendFail('Failed to load interactions', [], 'E_GENERIC')->withStatus(500);
         }
     }

+ 17 - 4
controllers/LoginController.php

@@ -3,7 +3,9 @@
 namespace Controllers;
 
 use Firebase\JWT\JWT;
+use Libs\Logger;
 use Libs\ResponseLib;
+use Libs\Validator;
 use Models\UserModel;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -12,11 +14,22 @@ class LoginController
     public function __invoke(ServerRequestInterface $request)
     {
         $body = json_decode((string) $request->getBody(), true) ?: [];
+
         $email = $body['email'] ?? $body['user_email'] ?? '';
         $password = $body['password'] ?? '';
 
-        if (empty($email) || empty($password)) {
-            return ResponseLib::sendFail("Missing email or password", [], "E_VALIDATE")->withStatus(400);
+        $validator = (new Validator(['email' => $email, 'password' => $password]))
+            ->required('email')->email('email')->maxLength('email', 255)
+            ->required('password');
+
+        if ($validator->fails()) {
+            return ResponseLib::sendFail($validator->firstError(), [], "E_VALIDATE")->withStatus(400);
+        }
+
+        $secret = $_ENV['JWT_SECRET'] ?? '';
+        if ($secret === '') {
+            Logger::error('JWT_SECRET is not configured; cannot issue tokens');
+            return ResponseLib::sendFail("Internal server error", [], "E_GENERIC")->withStatus(500);
         }
 
         $userModel = new UserModel();
@@ -34,11 +47,11 @@ class LoginController
             'iat' => time(),
             'exp' => time() + 3600
         ];
-        $jwt = JWT::encode($payload, $_ENV['JWT_SECRET'], 'HS256');
+        $jwt = JWT::encode($payload, $secret, 'HS256');
 
         return ResponseLib::sendOk([
             'token' => $jwt,
             'user' => $user,
         ]);
     }
-}   
+}

+ 2 - 0
controllers/PersonasOverviewController.php

@@ -2,6 +2,7 @@
 
 namespace Controllers;
 
+use Libs\Logger;
 use Libs\ResponseLib;
 use Models\PersonasModel;
 use Models\UserModel;
@@ -36,6 +37,7 @@ class PersonasOverviewController
                 $this->personasModel->getOverviewData($companyId, $request->getQueryParams())
             );
         } catch (\Throwable $e) {
+            Logger::error('Failed to load personas overview', ['error' => $e->getMessage()]);
             return ResponseLib::sendFail('Failed to load personas overview', [], 'E_GENERIC')->withStatus(500);
         }
     }

+ 47 - 13
controllers/RegisterController.php

@@ -2,37 +2,71 @@
 
 namespace Controllers;
 
+use Libs\Logger;
 use Libs\ResponseLib;
+use Libs\Validator;
 use Models\UserModel;
 use Psr\Http\Message\ServerRequestInterface;
 
 class RegisterController
 {
+    private UserModel $userModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+    }
+
     public function __invoke(ServerRequestInterface $request)
     {
+        // company_id NÃO vem mais do body: é herdado do usuário autenticado (JWT).
+        // Isso impede que alguém se registre sob uma empresa arbitrária.
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        if ($userId <= 0) {
+            return ResponseLib::sendFail("Unauthorized: Missing authenticated user", [], "E_VALIDATE")->withStatus(401);
+        }
+
         $body = json_decode((string) $request->getBody(), true) ?: [];
-        $companyId = (int) ($body['company_id'] ?? 0);
+
         $name = $body['name'] ?? $body['user_name'] ?? null;
         $phone = $body['phone'] ?? $body['user_phone'] ?? '';
         $email = $body['email'] ?? $body['user_email'] ?? '';
         $role = $body['role'] ?? $body['user_role'] ?? '';
         $password = $body['password'] ?? '';
 
-        if ($companyId <= 0 || empty($phone) || empty($email) || empty($role) || empty($password)) {
-            return ResponseLib::sendFail("Missing company_id, phone, email, role or password", [], "E_VALIDATE")->withStatus(400);
-        }
+        $validator = (new Validator([
+            'name' => $name,
+            'phone' => $phone,
+            'email' => $email,
+            'role' => $role,
+            'password' => $password,
+        ]))
+            ->maxLength('name', 120)
+            ->required('phone')->phone('phone')
+            ->required('email')->email('email')->maxLength('email', 255)
+            ->required('role')->maxLength('role', 50)
+            ->required('password')->minLength('password', 8)->maxLength('password', 255);
 
-        if (strlen($password) < 8) {
-            return ResponseLib::sendFail("Password must be at least 8 characters", [], "E_VALIDATE")->withStatus(400);
+        if ($validator->fails()) {
+            return ResponseLib::sendFail($validator->firstError(), [], "E_VALIDATE")->withStatus(400);
         }
 
-        $userModel = new UserModel();
-        $userData = $userModel->createUser($companyId, $email, $password, $phone, $role, $name);
+        try {
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return ResponseLib::sendFail("User not found", [], "E_NOT_FOUND")->withStatus(404);
+            }
 
-        if (!$userData) {
-            return ResponseLib::sendFail("Email already exists, company not found, or creation failed", [], "E_VALIDATE")->withStatus(400);
-        }
+            $userData = $this->userModel->createUser($companyId, $email, $password, $phone, $role, $name);
 
-        return ResponseLib::sendOk($userData, "S_CREATED");
+            if (!$userData) {
+                return ResponseLib::sendFail("Email already exists or creation failed", [], "E_VALIDATE")->withStatus(400);
+            }
+
+            return ResponseLib::sendOk($userData, "S_CREATED", "User created.");
+        } catch (\Throwable $e) {
+            Logger::error('Failed to register user', ['error' => $e->getMessage()]);
+            return ResponseLib::sendFail("Failed to register user", [], "E_GENERIC")->withStatus(500);
+        }
     }
-}
+}

+ 79 - 6
libs/Database.php

@@ -2,25 +2,98 @@
 
 namespace Libs;
 
+/**
+ * Gerencia a conexão com o banco PostgreSQL.
+ *
+ * A aplicação roda como processo de longa duração (framework-X / ReactPHP),
+ * portanto criar uma conexão PDO nova a cada chamada desperdiça recursos e
+ * pode esgotar o pool de conexões do servidor. Aqui reutilizamos uma única
+ * conexão persistente por processo (singleton), validando se ela continua
+ * viva e reconectando quando necessário.
+ */
 final class Database
 {
+    private static ?\PDO $connection = null;
+
     public static function pdo(): \PDO
     {
-        $host = $_ENV['DB_HOST'] ?? '127.0.0.1';
-        $port = $_ENV['DB_PORT'] ?? '5432';
+        if (self::$connection instanceof \PDO && self::isAlive(self::$connection)) {
+            return self::$connection;
+        }
+
+        self::$connection = self::connect();
+
+        return self::$connection;
+    }
+
+    /**
+     * Verifica se a conexão em cache ainda está utilizável.
+     * Se o banco reiniciou ou derrubou a conexão, força a reconexão.
+     */
+    private static function isAlive(\PDO $pdo): bool
+    {
+        try {
+            $pdo->query('SELECT 1');
+            return true;
+        } catch (\PDOException $e) {
+            Logger::warning('Database connection lost, reconnecting', ['error' => $e->getMessage()]);
+            return false;
+        }
+    }
+
+    private static function connect(): \PDO
+    {
+        $host = self::env('DB_HOST', '127.0.0.1');
+        $port = self::env('DB_PORT', '5432');
         $name = $_ENV['DB_NAME'] ?? '';
         $user = $_ENV['DB_USER'] ?? '';
         $pass = $_ENV['DB_PASS'] ?? '';
 
         if ($name === '') {
+            Logger::error('DB_NAME is not configured');
             throw new \RuntimeException('DB_NAME is not configured.');
         }
 
+        if (($_ENV['DB_USER'] ?? '') === '') {
+            Logger::warning('DB_USER is empty; using empty database user');
+        }
+
         $dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $name);
 
-        return new \PDO($dsn, $user, $pass, [
-            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
-            \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
-        ]);
+        try {
+            $pdo = new \PDO($dsn, $user, $pass, [
+                \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
+                \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
+                \PDO::ATTR_PERSISTENT => true,
+            ]);
+        } catch (\PDOException $e) {
+            Logger::error('Failed to connect to database', [
+                'host' => $host,
+                'port' => $port,
+                'name' => $name,
+                'error' => $e->getMessage(),
+            ]);
+            throw $e;
+        }
+
+        Logger::info('Database connection established', ['host' => $host, 'port' => $port, 'name' => $name]);
+
+        return $pdo;
+    }
+
+    /**
+     * Lê uma variável de ambiente registrando em log quando cai no valor default.
+     * Torna visível o uso silencioso de defaults (ex.: host/porta ausentes).
+     */
+    private static function env(string $key, string $default): string
+    {
+        $value = $_ENV[$key] ?? null;
+
+        if ($value === null || $value === '') {
+            Logger::warning('Environment variable missing, using default', ['key' => $key, 'default' => $default]);
+            return $default;
+        }
+
+        return (string) $value;
     }
 }

+ 97 - 0
libs/Logger.php

@@ -0,0 +1,97 @@
+<?php
+
+namespace Libs;
+
+/**
+ * Logger simples da aplicação.
+ *
+ * - Escreve no terminal (STDERR) para visibilidade imediata em desenvolvimento.
+ * - Escreve também em arquivo (log.txt por padrão) para histórico persistente.
+ *
+ * Ativado/desativado pela variável de ambiente LOG_ENABLED (true/false).
+ * O caminho do arquivo pode ser customizado por LOG_FILE.
+ */
+final class Logger
+{
+    public const LEVEL_DEBUG = 'debug';
+    public const LEVEL_INFO = 'info';
+    public const LEVEL_WARNING = 'warning';
+    public const LEVEL_ERROR = 'error';
+
+    /**
+     * Verifica se o log está habilitado. Default: habilitado.
+     */
+    public static function isEnabled(): bool
+    {
+        $flag = $_ENV['LOG_ENABLED'] ?? 'true';
+
+        return filter_var($flag, FILTER_VALIDATE_BOOLEAN);
+    }
+
+    public static function debug(string $message, array $context = []): void
+    {
+        self::log(self::LEVEL_DEBUG, $message, $context);
+    }
+
+    public static function info(string $message, array $context = []): void
+    {
+        self::log(self::LEVEL_INFO, $message, $context);
+    }
+
+    public static function warning(string $message, array $context = []): void
+    {
+        self::log(self::LEVEL_WARNING, $message, $context);
+    }
+
+    public static function error(string $message, array $context = []): void
+    {
+        self::log(self::LEVEL_ERROR, $message, $context);
+    }
+
+    /**
+     * Registra uma entrada de log no terminal e no arquivo.
+     */
+    public static function log(string $level, string $message, array $context = []): void
+    {
+        if (!self::isEnabled()) {
+            return;
+        }
+
+        $line = self::format($level, $message, $context);
+
+        self::writeToTerminal($line);
+        self::writeToFile($line);
+    }
+
+    private static function format(string $level, string $message, array $context): string
+    {
+        $timestamp = date('Y-m-d H:i:s');
+        $levelLabel = strtoupper($level);
+
+        $suffix = '';
+        if (!empty($context)) {
+            $encoded = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+            $suffix = ' ' . ($encoded !== false ? $encoded : '[context not serializable]');
+        }
+
+        return sprintf('[%s] %s: %s%s', $timestamp, $levelLabel, $message, $suffix);
+    }
+
+    private static function writeToTerminal(string $line): void
+    {
+        if (defined('STDERR')) {
+            fwrite(STDERR, $line . PHP_EOL);
+            return;
+        }
+
+        error_log($line);
+    }
+
+    private static function writeToFile(string $line): void
+    {
+        $file = $_ENV['LOG_FILE'] ?? (dirname(__DIR__) . '/log.txt');
+
+        // Falha de escrita em log não deve derrubar a aplicação.
+        @file_put_contents($file, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
+    }
+}

+ 48 - 0
libs/Payload.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace Libs;
+
+use React\Http\Message\Response;
+
+/**
+ * Formato padrão de resposta da API.
+ *
+ * Toda resposta enviada ao frontend deve seguir esta estrutura:
+ *
+ *   {
+ *     "status":  "ok" | "failed",
+ *     "code":    "S_OK" | "E_VALIDATE" | ...,
+ *     "message": "mensagem legível",
+ *     "data":    { ... }   // presente apenas quando houver dados
+ *   }
+ *
+ * Qualquer informação adicional retornada ao frontend deve ficar sempre dentro de "data".
+ */
+final class Payload
+{
+    /**
+     * Monta o array padrão de resposta. "data" só é incluído quando não está vazio.
+     */
+    public static function build(string $status, string $code, string $message, $data = []): array
+    {
+        $response = [
+            'status' => $status,
+            'code' => $code,
+            'message' => $message,
+        ];
+
+        if (!empty($data)) {
+            $response['data'] = $data;
+        }
+
+        return $response;
+    }
+
+    /**
+     * Monta a resposta padrão já como Response JSON pronta para retorno no controller.
+     */
+    public static function json(string $status, string $code, string $message, $data = [], int $httpStatus = 200): Response
+    {
+        return Response::json(self::build($status, $code, $message, $data))->withStatus($httpStatus);
+    }
+}

+ 9 - 16
libs/ResponseLib.php

@@ -4,28 +4,21 @@ namespace Libs;
 
 use React\Http\Message\Response;
 
+/**
+ * Wrapper de conveniência para respostas da API.
+ *
+ * Delega a montagem do envelope para Libs\Payload, garantindo que toda
+ * resposta siga o formato padrão (status / code / message / data).
+ */
 class ResponseLib
 {
-    public static function sendOk($data, $code = "S_OK")
+    public static function sendOk($data = [], $code = "S_OK", $message = "Request ok.")
     {
-        $reply = [
-            'status' => 'ok',
-            'msg' => '[100] Request ok.',
-            'code' => $code,
-            'data' => $data
-        ];
-        return Response::json($reply);
+        return Payload::json('ok', $code, $message, $data);
     }
 
     public static function sendFail($message, $data = [], $code = "E_GENERIC")
     {
-        $reply = [
-            'status' => 'failed',
-            'msg' => $message,
-            'code' => $code,
-            'data' => $data
-        ];
-        return Response::json($reply);
+        return Payload::json('failed', $code, $message, $data);
     }
 }
-

+ 180 - 0
libs/Validator.php

@@ -0,0 +1,180 @@
+<?php
+
+namespace Libs;
+
+/**
+ * Validador de payloads de entrada.
+ *
+ * Padroniza as validações de input em todos os controllers, evitando
+ * casting solto e verificações manuais espalhadas. Uso encadeado:
+ *
+ *   $validator = (new Validator($body))
+ *       ->required('email')->email('email')
+ *       ->required('password')->minLength('password', 8);
+ *
+ *   if ($validator->fails()) {
+ *       return ResponseLib::sendFail($validator->firstError(), [], 'E_VALIDATE')->withStatus(400);
+ *   }
+ *
+ * Regras só são aplicadas se o campo não já tiver acumulado erro, evitando
+ * mensagens redundantes para o mesmo campo.
+ */
+final class Validator
+{
+    private array $data;
+
+    /** @var array<string, string> erro por campo (primeiro erro encontrado) */
+    private array $errors = [];
+
+    public function __construct(array $data)
+    {
+        $this->data = $data;
+    }
+
+    public function required(string $field, ?string $label = null): self
+    {
+        if ($this->hasError($field)) {
+            return $this;
+        }
+
+        $value = $this->data[$field] ?? null;
+
+        if ($value === null || (is_string($value) && trim($value) === '') || $value === []) {
+            $this->addError($field, sprintf('%s is required', $label ?? $field));
+        }
+
+        return $this;
+    }
+
+    public function email(string $field, ?string $label = null): self
+    {
+        if ($this->hasError($field) || !isset($this->data[$field])) {
+            return $this;
+        }
+
+        $value = trim((string) $this->data[$field]);
+        if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
+            $this->addError($field, sprintf('%s must be a valid email', $label ?? $field));
+        }
+
+        return $this;
+    }
+
+    /**
+     * Telefone aceita apenas dígitos (após remover espaços, +, -, () ),
+     * com comprimento entre $min e $max.
+     */
+    public function phone(string $field, int $min = 8, int $max = 15, ?string $label = null): self
+    {
+        if ($this->hasError($field) || !isset($this->data[$field])) {
+            return $this;
+        }
+
+        $digits = preg_replace('/\D+/', '', (string) $this->data[$field]);
+        $length = strlen((string) $digits);
+
+        if ($length < $min || $length > $max) {
+            $this->addError($field, sprintf('%s must have between %d and %d digits', $label ?? $field, $min, $max));
+        }
+
+        return $this;
+    }
+
+    public function minLength(string $field, int $min, ?string $label = null): self
+    {
+        if ($this->hasError($field) || !isset($this->data[$field])) {
+            return $this;
+        }
+
+        if (mb_strlen((string) $this->data[$field]) < $min) {
+            $this->addError($field, sprintf('%s must be at least %d characters', $label ?? $field, $min));
+        }
+
+        return $this;
+    }
+
+    public function maxLength(string $field, int $max, ?string $label = null): self
+    {
+        if ($this->hasError($field) || !isset($this->data[$field])) {
+            return $this;
+        }
+
+        if (mb_strlen((string) $this->data[$field]) > $max) {
+            $this->addError($field, sprintf('%s must be at most %d characters', $label ?? $field, $max));
+        }
+
+        return $this;
+    }
+
+    /**
+     * Garante que o valor é um inteiro dentro de [$min, $max] (limites opcionais).
+     */
+    public function intRange(string $field, ?int $min = null, ?int $max = null, ?string $label = null): self
+    {
+        if ($this->hasError($field) || !isset($this->data[$field])) {
+            return $this;
+        }
+
+        $value = filter_var($this->data[$field], FILTER_VALIDATE_INT);
+        if ($value === false) {
+            $this->addError($field, sprintf('%s must be an integer', $label ?? $field));
+            return $this;
+        }
+
+        if (($min !== null && $value < $min) || ($max !== null && $value > $max)) {
+            $this->addError($field, sprintf('%s is out of allowed range', $label ?? $field));
+        }
+
+        return $this;
+    }
+
+    /**
+     * Garante que o valor está em uma lista de valores permitidos.
+     */
+    public function in(string $field, array $allowed, ?string $label = null): self
+    {
+        if ($this->hasError($field) || !isset($this->data[$field])) {
+            return $this;
+        }
+
+        if (!in_array($this->data[$field], $allowed, true)) {
+            $this->addError($field, sprintf('%s has an invalid value', $label ?? $field));
+        }
+
+        return $this;
+    }
+
+    public function fails(): bool
+    {
+        return !empty($this->errors);
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    public function errors(): array
+    {
+        return $this->errors;
+    }
+
+    public function firstError(): ?string
+    {
+        if (empty($this->errors)) {
+            return null;
+        }
+
+        return reset($this->errors);
+    }
+
+    private function hasError(string $field): bool
+    {
+        return isset($this->errors[$field]);
+    }
+
+    private function addError(string $field, string $message): void
+    {
+        if (!isset($this->errors[$field])) {
+            $this->errors[$field] = $message;
+        }
+    }
+}

+ 16 - 8
middlewares/JwtAuthMiddleware.php

@@ -5,9 +5,9 @@ namespace Middlewares;
 use Firebase\JWT\JWT;
 use Firebase\JWT\Key;
 use Libs\Database;
+use Libs\Logger;
 use Libs\ResponseLib;
 use Psr\Http\Message\ServerRequestInterface;
-use React\Http\Message\Response;
 
 class JwtAuthMiddleware
 {
@@ -15,15 +15,21 @@ class JwtAuthMiddleware
 
     public function __construct()
     {
-        // Carrega a chave secreta do .env (ex: JWT_SECRET=seu-segredo-aqui)
-        $this->jwtSecret = $_ENV['JWT_SECRET'] ?? 'default-secret-fallback';  // Use um fallback seguro em dev
+        // Sem fallback: a chave precisa estar configurada no ambiente.
+        $this->jwtSecret = $_ENV['JWT_SECRET'] ?? '';
     }
 
     public function __invoke(ServerRequestInterface $request, callable $next)
     {
+        if ($this->jwtSecret === '') {
+            // Configuração ausente é erro de servidor, não de autenticação.
+            Logger::error('JWT_SECRET is not configured; rejecting authenticated request');
+            return ResponseLib::sendFail("Internal server error", [], "E_GENERIC")->withStatus(500);
+        }
+
         $authHeader = $request->getHeaderLine('Authorization');
         if (empty($authHeader) || !preg_match('/Bearer\s+(.*)/', $authHeader, $matches)) {
-            return ResponseLib::sendFail("Unauthorized: Missing or invalid Authorization header", [], "E_VALIDATE")->withStatus(401);
+            return ResponseLib::sendFail("Unauthorized", [], "E_VALIDATE")->withStatus(401);
         }
 
         $token = $matches[1];
@@ -34,7 +40,7 @@ class JwtAuthMiddleware
             $userEmail = $decoded->email ?? $decoded->username ?? null;
 
             if (empty($userId) || empty($userEmail)) {
-                return ResponseLib::sendFail("Unauthorized: Invalid JWT claims", [], "E_VALIDATE")->withStatus(401);
+                return ResponseLib::sendFail("Unauthorized", [], "E_VALIDATE")->withStatus(401);
             }
 
             $pdo = Database::pdo();
@@ -44,7 +50,7 @@ class JwtAuthMiddleware
             $user = $stmt->fetch(\PDO::FETCH_ASSOC);
 
             if (!$user) {
-                return ResponseLib::sendFail("Unauthorized: Invalid or inactive user", [], "E_VALIDATE")->withStatus(401);
+                return ResponseLib::sendFail("Unauthorized", [], "E_VALIDATE")->withStatus(401);
             }
 
             $request = $request
@@ -56,7 +62,9 @@ class JwtAuthMiddleware
             return $next($request);
 
         } catch (\Exception $e) {
-            return ResponseLib::sendFail("Unauthorized: " . $e->getMessage(), [], "E_VALIDATE")->withStatus(401);
+            // Detalhe do erro vai só para o log; cliente recebe mensagem genérica.
+            Logger::warning('JWT authentication failed', ['error' => $e->getMessage()]);
+            return ResponseLib::sendFail("Unauthorized", [], "E_VALIDATE")->withStatus(401);
         }
     }
-}
+}

+ 37 - 38
models/AgentsModel.php

@@ -201,7 +201,8 @@ class AgentsModel
                 COALESCE(curr.operator_attendances_count, 0) AS today_attendances,
                 curr.operator_avg_response_seconds AS avg_response_seconds,
                 prev.operator_avg_response_seconds AS prev_avg_response_seconds,
-                COALESCE(curr.operator_sla_compliance_pct, 0) AS sla_pct
+                COALESCE(curr.operator_sla_compliance_pct, 0) AS sla_pct,
+                COALESCE(string_agg(DISTINCT lower(oc.operator_channel), ',' ORDER BY lower(oc.operator_channel)), '') AS channels
             FROM operator o
             LEFT JOIN operator_daily_stats curr
                 ON curr.operator_id = o.operator_id
@@ -209,6 +210,9 @@ class AgentsModel
             LEFT JOIN operator_daily_stats prev
                 ON prev.operator_id = o.operator_id
                AND prev.operator_stat_date = :previous_date
+            LEFT JOIN operator_channel oc
+                ON oc.operator_id = o.operator_id
+               AND oc.operator_channel_deleted_at = 'infinity'
             WHERE o.company_id = :company_id
               AND o.operator_deleted_at = 'infinity'";
 
@@ -249,17 +253,21 @@ class AgentsModel
             $params['channel'] = $filters['channel'];
         }
 
+        $sql .= " GROUP BY
+                o.operator_id,
+                curr.operator_attendances_count,
+                curr.operator_avg_response_seconds,
+                prev.operator_avg_response_seconds,
+                curr.operator_sla_compliance_pct";
         $sql .= ' ORDER BY o.operator_name ASC, o.operator_id ASC';
 
         $stmt = $this->pdo->prepare($sql);
         $stmt->execute($params);
         $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
 
-        $channelsByOperator = $this->getChannelsByOperator($companyId);
         $items = [];
         foreach ($rows as $row) {
-            $operatorId = (int) ($row['operator_id'] ?? 0);
-            $items[] = $this->formatAgentItem($row, $channelsByOperator[$operatorId] ?? []);
+            $items[] = $this->formatAgentItem($row, $this->splitChannels((string) ($row['channels'] ?? '')));
         }
 
         return $items;
@@ -294,39 +302,22 @@ class AgentsModel
         ];
     }
 
-    private function getChannelsByOperator(int $companyId): array
+    /**
+     * Converte a lista de canais agregada pelo banco (string separada por vírgula)
+     * em array de canais únicos. Os canais já vêm em minúsculas do string_agg.
+     */
+    private function splitChannels(string $raw): array
     {
-        $stmt = $this->pdo->prepare(
-            "SELECT
-                o.operator_id,
-                oc.operator_channel
-            FROM operator o
-            LEFT JOIN operator_channel oc
-                ON oc.operator_id = o.operator_id
-               AND oc.operator_channel_deleted_at = 'infinity'
-            WHERE o.company_id = :company_id
-              AND o.operator_deleted_at = 'infinity'
-            ORDER BY o.operator_id ASC, oc.operator_channel ASC"
-        );
-        $stmt->execute(['company_id' => $companyId]);
-        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
-
-        $grouped = [];
-        foreach ($rows as $row) {
-            $operatorId = (int) ($row['operator_id'] ?? 0);
-            $channel = trim((string) ($row['operator_channel'] ?? ''));
-            if ($operatorId <= 0 || $channel === '') {
-                continue;
-            }
-
-            if (!isset($grouped[$operatorId])) {
-                $grouped[$operatorId] = [];
-            }
-
-            $grouped[$operatorId][] = mb_strtolower($channel);
+        if ($raw === '') {
+            return [];
         }
 
-        return $grouped;
+        $channels = array_filter(
+            array_map('trim', explode(',', $raw)),
+            static fn(string $channel): bool => $channel !== ''
+        );
+
+        return array_values(array_unique($channels));
     }
 
     private function formatAgentItem(array $row, array $channels): array
@@ -486,7 +477,8 @@ class AgentsModel
                 COALESCE(curr.operator_attendances_count, 0) AS today_attendances,
                 curr.operator_avg_response_seconds AS avg_response_seconds,
                 prev.operator_avg_response_seconds AS prev_avg_response_seconds,
-                COALESCE(curr.operator_sla_compliance_pct, 0) AS sla_pct
+                COALESCE(curr.operator_sla_compliance_pct, 0) AS sla_pct,
+                COALESCE(string_agg(DISTINCT lower(oc.operator_channel), ',' ORDER BY lower(oc.operator_channel)), '') AS channels
             FROM operator o
             LEFT JOIN operator_daily_stats curr
                 ON curr.operator_id = o.operator_id
@@ -494,9 +486,18 @@ class AgentsModel
             LEFT JOIN operator_daily_stats prev
                 ON prev.operator_id = o.operator_id
                AND prev.operator_stat_date = :previous_date
+            LEFT JOIN operator_channel oc
+                ON oc.operator_id = o.operator_id
+               AND oc.operator_channel_deleted_at = 'infinity'
             WHERE o.company_id = :company_id
               AND o.operator_id = :operator_id
               AND o.operator_deleted_at = 'infinity'
+            GROUP BY
+                o.operator_id,
+                curr.operator_attendances_count,
+                curr.operator_avg_response_seconds,
+                prev.operator_avg_response_seconds,
+                curr.operator_sla_compliance_pct
             LIMIT 1";
 
         $stmt = $this->pdo->prepare($sql);
@@ -511,9 +512,7 @@ class AgentsModel
             return null;
         }
 
-        $channelsByOperator = $this->getChannelsByOperator($companyId);
-
-        return $this->formatAgentItem($row, $channelsByOperator[$agentId] ?? []);
+        return $this->formatAgentItem($row, $this->splitChannels((string) ($row['channels'] ?? '')));
     }
 
     private function buildInitials(string $name): string

+ 2 - 0
models/UserModel.php

@@ -3,6 +3,7 @@
 namespace Models;
 
 use Libs\Database;
+use Libs\Logger;
 
 class UserModel
 {
@@ -75,6 +76,7 @@ class UserModel
 
             $createdUser = $stmt->fetch(\PDO::FETCH_ASSOC);
         } catch (\PDOException $e) {
+            Logger::error('Failed to insert user', ['email' => $normalizedEmail, 'error' => $e->getMessage()]);
             return false;
         }
 

+ 18 - 16
public/index.php

@@ -24,21 +24,23 @@ error_reporting(E_ALL);
 $app = new App();
 $authJwt = new JwtAuthMiddleware();
 
-$app->get('/jwthelloworld', $authJwt,\Controllers\HelloController::class);
-$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->get('/evolution/overview', $authJwt,\Controllers\EvolutionOverviewController::class);
-$app->get('/executive/dashboard', $authJwt,\Controllers\ExecutiveDashboardController::class);
-$app->get('/agents', $authJwt,\Controllers\AgentsController::class);
-
-$app->post('/login', \Controllers\LoginController::class);
-$app->post('/register', \Controllers\RegisterController::class);
-$app->post('/agents', $authJwt, \Controllers\AgentSaveController::class);
-$app->post('/agents/status', $authJwt, \Controllers\AgentStatusController::class);
-$app->post('/agents/escalation', $authJwt, \Controllers\AgentEscalationController::class);
+// Rotas versionadas sob /v1 para permitir evolução sem quebrar clientes existentes.
+$app->get('/v1/jwthelloworld', $authJwt, \Controllers\HelloController::class);
+$app->get('/v1/me', $authJwt, \Controllers\MeController::class);
+$app->get('/v1/dashboard/overview', $authJwt, \Controllers\DashboardOverviewController::class);
+$app->get('/v1/interactions', $authJwt, \Controllers\InteractionsController::class);
+$app->get('/v1/interactions/details', $authJwt, \Controllers\InteractionDetailsController::class);
+$app->get('/v1/analytics/sentiment/dashboard', $authJwt, \Controllers\AnalyticsSentimentDashboardController::class);
+$app->get('/v1/personas/overview', $authJwt, \Controllers\PersonasOverviewController::class);
+$app->get('/v1/evolution/overview', $authJwt, \Controllers\EvolutionOverviewController::class);
+$app->get('/v1/executive/dashboard', $authJwt, \Controllers\ExecutiveDashboardController::class);
+$app->get('/v1/agents', $authJwt, \Controllers\AgentsController::class);
+
+$app->post('/v1/login', \Controllers\LoginController::class);
+// Registro exige autenticação: o novo usuário herda o company_id do solicitante.
+$app->post('/v1/register', $authJwt, \Controllers\RegisterController::class);
+$app->post('/v1/agents', $authJwt, \Controllers\AgentSaveController::class);
+$app->post('/v1/agents/status', $authJwt, \Controllers\AgentStatusController::class);
+$app->post('/v1/agents/escalation', $authJwt, \Controllers\AgentEscalationController::class);
 
 $app->run();

+ 1237 - 0
rotas.md

@@ -0,0 +1,1237 @@
+# Análise da Codebase e Contrato Backend → Frontend
+
+## Objetivo
+
+Este documento resume a análise da codebase do projeto e define **o que o backend precisa entregar para o frontend**.
+
+Hoje o backend tem apenas as rotas:
+
+- `POST /login`
+- `POST /register`
+
+Mas o frontend já possui diversas telas prontas. Portanto, este arquivo serve como **mapa de implementação do backend**, indicando:
+
+- o estado atual do projeto;
+- o que cada tela do frontend precisa receber;
+- quais endpoints precisam existir;
+- quais formatos de payload e resposta são recomendados;
+- quais partes da modelagem atual já suportam isso e quais ainda não suportam.
+
+---
+
+## 1. Estrutura da codebase
+
+## Frontend
+
+Pasta: `nettown_frontend`
+
+Stack identificada:
+
+- `SvelteKit`
+- `adapter-static`
+- `Svelte 5`
+- `Tailwind`
+- `layerchart`
+- `lucide-svelte`
+
+### Achados principais do frontend
+
+- O frontend está **visualmente completo** para várias áreas do produto.
+- O frontend está **quase 100% mockado**.
+- **Não existe client HTTP real** no frontend hoje.
+- **Não há `fetch`, `axios`, `Authorization` ou persistência de JWT** implementados nas telas principais.
+- A tela de login ainda usa validação mockada:
+  - e-mail: `admin@nettown.com`
+  - senha: `admin`
+- Várias telas consomem dados de:
+  - `src/lib/core/models/mock-data.js`
+  - `src/lib/features/sentiment/data/sentiment-dashboard.mock.js`
+- Existem rotas duplicadas em PT/EN para algumas telas:
+  - `profile` e `perfil`
+  - `subscription` e `assinatura`
+  - `help` e `ajuda`
+
+### Conclusão sobre o frontend
+
+O frontend está pronto como **produto visual e funcional local**, mas **não está integrado ao backend**.
+
+Ou seja:
+
+- a parte de layout/componentes está pronta;
+- a parte de contratos de API ainda precisa ser implementada.
+
+---
+
+## Backend
+
+Pasta: `php_api`
+
+Stack identificada:
+
+- `PHP`
+- `FrameworkX`
+- `Firebase JWT`
+- `PostgreSQL`
+- `PDO`
+
+### Rotas atuais do backend
+
+- `POST /login`
+- `POST /register`
+- `GET /jwthelloworld` protegido por JWT
+
+### Padrão de resposta atual do backend
+
+O backend hoje responde com envelope padrão:
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {}
+}
+```
+
+Em erro:
+
+```json
+{
+  "status": "failed",
+  "msg": "mensagem de erro",
+  "code": "E_VALIDATE",
+  "data": {}
+}
+```
+
+### Autenticação atual
+
+- Login usa `email` + `password`.
+- JWT contém:
+  - `sub`
+  - `email`
+  - `company_id`
+  - `role`
+- Middleware JWT valida usuário ativo por `user_deleted_at = 'infinity'`.
+
+### Conclusão sobre o backend
+
+O backend já está preparado para:
+
+- autenticação com JWT;
+- conexão PostgreSQL;
+- leitura do banco novo.
+
+Mas **a camada de API de produto ainda não foi construída**.
+
+---
+
+## 2. Banco atual vs necessidade do frontend
+
+## Tabelas que já suportam bem o frontend
+
+- `company`
+- `user`
+- `operator`
+- `operator_channel`
+- `sla_config`
+- `integration`
+- `client`
+- `conversation`
+- `message`
+- `message_attachment`
+- `message_reaction`
+- `conversation_participant`
+- `webhook_event`
+- `conversation_analysis`
+- `aspect_feedback`
+- `emotion_snapshot`
+- `public_opinion`
+- `alert`
+- `ai_action`
+- `persona`
+- `client_persona`
+- `best_action`
+- `volume_snapshot`
+- `sentiment_evolution`
+- `playbooks_monitor`
+- `operator_daily_stats`
+- `kpi_snapshot`
+
+## Gaps entre frontend e schema atual
+
+Estas são as principais lacunas identificadas:
+
+- **Filtro de unidade**
+  - O frontend usa filtro de `unidade` em várias telas.
+  - O schema atual **não tem tabela explícita de unidade/loja/filial**.
+
+- **Configuração de alerta de SLA por percentual**
+  - A tela de SLA usa `alertPct`.
+  - A tabela `sla_config` hoje guarda apenas:
+    - `sla_config_response_hours`
+    - `sla_config_resolution_hours`
+  - **Não existe coluna de percentual de alerta**.
+
+- **Configurações / notificações**
+  - O frontend de configurações mostra preferências e integrações.
+  - O schema atual **não tem tabela dedicada para preferências/notificações**.
+
+- **Assinatura / billing**
+  - O frontend possui telas de assinatura e planos.
+  - O schema atual **não tem modelagem de assinatura/plano/cobrança**.
+
+- **Ajuda / FAQ / suporte**
+  - O frontend tem tela pronta.
+  - Isso pode ser estático, mas **não existe modelagem específica** hoje.
+
+---
+
+## 3. Mapa de telas do frontend
+
+## Públicas
+
+- `/`
+  - landing page institucional
+  - não depende de backend para funcionar
+
+- `/login`
+  - hoje mockada
+  - deve passar a consumir `POST /login`
+
+## Privadas
+
+- `/dashboard`
+- `/dashboard/interactions`
+- `/dashboard/analytics`
+- `/dashboard/personas`
+- `/dashboard/evolucao`
+- `/dashboard/executive`
+- `/dashboard/agents`
+- `/dashboard/sla`
+- `/dashboard/settings`
+- `/dashboard/profile`
+- `/dashboard/subscription`
+- `/dashboard/help`
+
+Rotas duplicadas equivalentes:
+
+- `/dashboard/perfil`
+- `/dashboard/assinatura`
+- `/dashboard/ajuda`
+
+---
+
+## 4. Situação real por tela
+
+## 4.1 Login
+
+### Estado atual
+
+- tela pronta;
+- autenticação ainda fake;
+- não salva token;
+- não consulta `GET /me`;
+- não protege navegação real por sessão.
+
+### Backend precisa entregar
+
+- `POST /login`
+- `GET /me`
+
+### Contrato recomendado
+
+#### `POST /login`
+
+Request:
+
+```json
+{
+  "email": "admin@empresa.com",
+  "password": "12345678"
+}
+```
+
+Response:
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {
+    "token": "jwt-token",
+    "user": {
+      "user_id": 1,
+      "company_id": 1,
+      "user_name": "Admin",
+      "user_phone": "5511999999999",
+      "user_email": "admin@empresa.com",
+      "user_role": "admin",
+      "user_created_at": "2026-05-25T10:00:00Z"
+    }
+  }
+}
+```
+
+#### `GET /me`
+
+Objetivo:
+
+- reidratar sessão no refresh da página;
+- preencher header do app;
+- popular perfil.
+
+Response:
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {
+    "user_id": 1,
+    "company_id": 1,
+    "user_name": "Admin",
+    "user_phone": "5511999999999",
+    "user_email": "admin@empresa.com",
+    "user_role": "admin",
+    "company": {
+      "company_id": 1,
+      "company_name": "Empresa X",
+      "company_cnpj": "12345678000199",
+      "company_logo": "https://..."
+    }
+  }
+}
+```
+
+---
+
+## 4.2 Dashboard principal (`/dashboard`)
+
+### Estado atual
+
+Hoje a tela usa mocks para:
+
+- KPIs do topo;
+- fila priorizada;
+- radar emocional;
+- volume por canal;
+- distribuição por aspectos;
+- drilldown por aspecto.
+
+### Backend precisa entregar
+
+#### `GET /dashboard/overview`
+
+Query params sugeridos:
+
+- `period=today|yesterday|week`
+- `unit=all|flagship|franquias|pop-up|digital`
+- `area=all|atendimento|produto|logistica|marketing`
+- `sentiment=all|positive|neutral|negative`
+- `volume_view=hour|day`
+
+### Response recomendada
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {
+    "kpis": {
+      "registeredUsers": 55,
+      "activeAgents": 4,
+      "totalConversations": 418,
+      "generalSentimentScore": 0.1,
+      "unregisteredUsers": 149682
+    },
+    "priorityQueue": [
+      {
+        "id": 1,
+        "customerName": "Carolina Ribeiro",
+        "segment": "Elegante Estratégica",
+        "status": "VENDEDORA NÃO RESPONDEU",
+        "slaStatus": "SLA 12h estourado",
+        "timeAgo": "há 18h",
+        "sellerName": "Maria",
+        "lastMessage": "Não tem no meu tamanho 40?",
+        "motive": "Pediu tamanho indisponível — reservar/repor",
+        "impact": 8639,
+        "ticket": 879,
+        "chance": 82,
+        "optimumWindow": "6h",
+        "score": 7084,
+        "conversationId": 101,
+        "clientId": 51,
+        "operatorId": 7
+      }
+    ],
+    "radarData": [
+      { "name": "Confiança", "value": 45 },
+      { "name": "Alegria", "value": 100 },
+      { "name": "Antecipação", "value": 41 },
+      { "name": "Medo", "value": 18 },
+      { "name": "Tristeza", "value": 3 },
+      { "name": "Raiva", "value": 13 }
+    ],
+    "volumeData": [
+      { "date": "2026-05-02T11:00:00Z", "whatsapp": 10 },
+      { "date": "2026-05-02T12:00:00Z", "whatsapp": 230 }
+    ],
+    "aspectsData": [
+      { "aspect": "Atendimento", "positive": 350, "neutral": 50, "negative": 20 },
+      { "aspect": "Produto", "positive": 130, "neutral": 30, "negative": 40 }
+    ],
+    "aspectsDrilldown": {
+      "Atendimento": {
+        "positive": [
+          { "label": "Satisfação com atendimento", "value": 285 }
+        ],
+        "neutral": [],
+        "negative": []
+      }
+    }
+  }
+}
+```
+
+### Fontes no banco
+
+- `kpi_snapshot`
+- `alert`
+- `conversation`
+- `client`
+- `operator`
+- `emotion_snapshot`
+- `volume_snapshot`
+- `conversation_analysis`
+
+---
+
+## 4.3 Interações (`/dashboard/interactions`)
+
+### Estado atual
+
+Hoje a tela mostra:
+
+- listagem de conversas analisadas;
+- filtros rápidos;
+- modal com chat;
+- mini relatório lateral.
+
+### Backend precisa entregar
+
+- `GET /interactions`
+- `GET /interactions/details`
+
+#### `GET /interactions`
+
+Query params sugeridos:
+
+- `page`
+- `per_page`
+- `search`
+- `filter=all|my_clients|new|unfinished`
+- `sentiment`
+- `operator_id`
+- `company_id`
+
+Response:
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {
+    "items": [
+      {
+        "conversationId": 201,
+        "client": "554196690452",
+        "agent": "Leticia",
+        "sentiment": "CONTENTAMENTO",
+        "score": 0.5,
+        "aspect": "Atendimento",
+        "subaspect": "Informativo",
+        "datetime": "2026-05-02T19:59:00Z"
+      }
+    ],
+    "pagination": {
+      "page": 1,
+      "per_page": 20,
+      "total": 120,
+      "total_pages": 6
+    }
+  }
+}
+```
+
+#### `GET /interactions/details`
+
+Query params sugeridos:
+
+- `conversation_id`
+
+Response:
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {
+    "conversation": {
+      "conversationId": 201,
+      "client": "554196690452",
+      "channel": "WhatsApp",
+      "agent": "Leticia"
+    },
+    "thread": [
+      {
+        "id": "m1",
+        "isAgent": false,
+        "text": "Oi, queria saber se esse look chega na loja física.",
+        "time": "19:58",
+        "date": "2026-05-02"
+      },
+      {
+        "id": "m2",
+        "isAgent": true,
+        "text": "Chega sim, posso te mostrar opções.",
+        "time": "19:59",
+        "date": "2026-05-02"
+      }
+    ],
+    "report": {
+      "avgResponse": "04:03",
+      "totalDuration": "08:28",
+      "avgAgent": "00:00",
+      "avgClient": "08:05",
+      "mainAspect": "Atendimento",
+      "subAspect": "Informativo",
+      "lastMessageAuthor": "Cliente",
+      "consecutiveMessages": false,
+      "sentiment": "CONTENTAMENTO",
+      "score": 0.5
+    }
+  }
+}
+```
+
+### Fontes no banco
+
+- `conversation`
+- `message`
+- `conversation_analysis`
+- `client`
+- `operator`
+
+---
+
+## 4.4 Análise de sentimento (`/dashboard/analytics`)
+
+### Estado atual
+
+Hoje a tela trabalha com um view model único contendo:
+
+- cards-resumo;
+- alertas;
+- linha temporal de ganhos/perdas;
+- lista de aspectos com frases reais.
+
+### Backend precisa entregar
+
+#### `GET /analytics/sentiment/dashboard`
+
+Query params sugeridos:
+
+- `timeframe=day|week|month`
+- `company_id`
+- `aspect`
+- `sentiment`
+
+Response:
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {
+    "summaryCards": [
+      {
+        "id": "atRiskClients",
+        "label": "Clientes em risco",
+        "value": 23,
+        "image": "/images/sentiment/risk.svg"
+      },
+      {
+        "id": "opportunities",
+        "label": "Oportunidades",
+        "value": 41,
+        "image": "/images/sentiment/opportunity.svg"
+      }
+    ],
+    "alerts": [
+      {
+        "id": "alert-1",
+        "clientId": 12,
+        "clientName": "Grupo Horizonte",
+        "title": "Grupo Horizonte com alta chance de churn",
+        "description": "Queda de engajamento nas últimas 2 semanas.",
+        "priority": "high",
+        "priorityLabel": "Alta prioridade",
+        "category": "churn_risk"
+      }
+    ],
+    "timelineViews": {
+      "day": [
+        { "period": "Dia 1", "gains": 28, "losses": 12 }
+      ],
+      "week": [
+        { "period": "Sem 1", "gains": 38, "losses": 16 }
+      ],
+      "month": [
+        { "period": "Jan", "gains": 180, "losses": 75 }
+      ]
+    },
+    "aspects": [
+      {
+        "id": "atendimento",
+        "name": "Atendimento",
+        "volume": 14,
+        "positive": [
+          { "text": "Fui respondida super rápido.", "client": "Maria Silva" }
+        ],
+        "neutral": [],
+        "negative": []
+      }
+    ]
+  }
+}
+```
+
+### Fontes no banco
+
+- `alert`
+- `public_opinion`
+- `aspect_feedback`
+- `conversation_analysis`
+- `client`
+- `conversation`
+
+---
+
+## 4.5 Personas (`/dashboard/personas`)
+
+### Estado atual
+
+A tela precisa de:
+
+- KPIs de personas;
+- estatísticas gerais;
+- cards de personas;
+- detalhes da persona selecionada;
+- next best action.
+
+### Backend precisa entregar
+
+#### `GET /personas/overview`
+
+Query params sugeridos:
+
+- `period=week|month|quarter`
+- `unit`
+- `area`
+- `sentiment=all|positive|neutral|negative`
+
+Response:
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {
+    "kpis": {
+      "active": 5,
+      "churn": 60.1,
+      "loss": 7521.27,
+      "potentialLabel": "Neutro"
+    },
+    "stats": {
+      "identified": 28,
+      "messages": 1840,
+      "aspects": 18,
+      "subaspects": 56
+    },
+    "personas": [
+      {
+        "id": "1",
+        "nome": "Cliente Tristeza Invisível",
+        "tipo": "O PERFIL",
+        "descricao": "Sem conexão com CRM",
+        "detalhes": "Cliente antigo, compras esporádicas.",
+        "expansao": "Oferecer atendimento personalizado.",
+        "engajamento": "Acionar contato humano imediato.",
+        "risco": "Alto"
+      }
+    ]
+  }
+}
+```
+
+### Fontes no banco
+
+- `persona`
+- `client_persona`
+- `best_action`
+- `conversation_analysis`
+- `message`
+
+---
+
+## 4.6 Evolução (`/dashboard/evolucao`)
+
+### Estado atual
+
+A tela precisa de:
+
+- KPIs de evolução;
+- série de evolução dos sentimentos;
+- série de monitoramento de playbooks;
+- totais do período.
+
+### Backend precisa entregar
+
+#### `GET /evolution/overview`
+
+Response:
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {
+    "kpis": {
+      "churnEvitado": 85000,
+      "roiUpsell": 3.1,
+      "scoreMedio": 74,
+      "taxaEvolucao": 21.0,
+      "conversaoEmocao": 47
+    },
+    "sentimentSeries": [
+      { "date": "2024-04-19", "value": 0.05 },
+      { "date": "2024-04-20", "value": 0.08 }
+    ],
+    "playbooksSeries": [
+      { "date": "2024-04-19", "novos": 5, "convertidos": 1 },
+      { "date": "2024-04-20", "novos": 15, "convertidos": 3 }
+    ],
+    "playbooksTotals": {
+      "novos": 20,
+      "convertidos": 4
+    }
+  }
+}
+```
+
+### Fontes no banco
+
+- `sentiment_evolution`
+- `playbooks_monitor`
+- `kpi_snapshot`
+
+---
+
+## 4.7 Executive Dashboard (`/dashboard/executive`)
+
+### Estado atual
+
+A tela precisa de:
+
+- KPIs executivos;
+- distribuição de churn;
+- LTV em risco;
+- status de SLA;
+- emoção geral da base;
+- acessos rápidos.
+
+### Backend precisa entregar
+
+#### `GET /executive/dashboard`
+
+Response:
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {
+    "topKpis": [
+      {
+        "title": "Venda Atual",
+        "value": "R$ 879",
+        "trendLabel": "↑ +12% vs ontem",
+        "danger": false
+      }
+    ],
+    "churnDistribution": [
+      { "label": "Baixo", "value": 45, "color": "#10b981" },
+      { "label": "Crítico", "value": 10, "color": "#ef4444" }
+    ],
+    "ltvRisk": {
+      "ltvTotal": 285000,
+      "ltvAtRisk": 34556,
+      "ltvRiskPct": 12,
+      "criticalClients": 34,
+      "avgTicket": 1016,
+      "trendText": "+3 clientes entraram em risco crítico desde ontem"
+    },
+    "sla": {
+      "withinPct": 78,
+      "breachPct": 22,
+      "byDepartment": [
+        { "department": "SAC", "value": 72 },
+        { "department": "Vendas", "value": 85 },
+        { "department": "Suporte", "value": 91 }
+      ]
+    },
+    "emotions": {
+      "items": [
+        { "label": "Alegria", "value": 38, "count": 1524, "color": "#10b981" }
+      ],
+      "avgSentimentScore": 0.28
+    },
+    "quickAccess": {
+      "conversationsToday": 418,
+      "activePersonas": 5,
+      "activeAgents": 12,
+      "activePlaybooks": 3,
+      "pendingSettings": 2,
+      "evolutionDelta": "+12%"
+    }
+  }
+}
+```
+
+### Fontes no banco
+
+- `kpi_snapshot`
+- `emotion_snapshot`
+- `operator_daily_stats`
+- `alert`
+- `sla_config`
+
+---
+
+## 4.8 Agentes (`/dashboard/agents`)
+
+### Estado atual
+
+A tela precisa de:
+
+- listagem de agentes;
+- filtros;
+- KPIs resumidos;
+- criação de agente;
+- edição;
+- ativação/desativação;
+- toggle de escalonamento.
+
+### Backend precisa entregar
+
+- `GET /agents`
+- `POST /agents`
+- `POST /agents/status`
+- `POST /agents/escalation`
+
+#### `GET /agents`
+
+Response:
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {
+    "items": [
+      {
+        "id": 1,
+        "name": "Maria Santos",
+        "email": "maria@empresa.com",
+        "initials": "MS",
+        "department": "SAC",
+        "channels": ["whatsapp"],
+        "status": "Ativo",
+        "availableForEscalation": true,
+        "todayAttendances": 24,
+        "avgResponseTime": "3m 12s",
+        "responseTimeTrend": "down",
+        "slaPct": 94
+      }
+    ],
+    "stats": {
+      "total": 8,
+      "active": 6,
+      "inAttendance": 2,
+      "availableForEscalation": 5
+    }
+  }
+}
+```
+
+#### `POST /agents`
+
+Request:
+
+```json
+{
+  "name": "Carolina Ribeiro",
+  "email": "carolina@empresa.com",
+  "department": "SAC",
+  "channels": ["whatsapp", "instagram"],
+  "status": "Ativo",
+  "availableForEscalation": true
+}
+```
+
+Para editar um agente existente, usar `POST /agents` novamente enviando o `id` no payload.
+
+Para alterar status, usar `POST /agents/status` enviando o `id` no payload.
+
+Para alterar escalonamento, usar `POST /agents/escalation` enviando o `id` no payload.
+
+### Fontes no banco
+
+- `operator`
+- `operator_channel`
+- `operator_daily_stats`
+
+---
+
+## 4.9 SLA (`/dashboard/sla`)
+
+### Estado atual
+
+A tela precisa de:
+
+- lista de departamentos configurados;
+- edição inline;
+- criação de departamento/configuração;
+- status em tempo real por departamento.
+
+### Backend precisa entregar
+
+- `GET /sla/configs`
+- `POST /sla/configs`
+- `GET /sla/live-status`
+
+#### `GET /sla/configs`
+
+Response:
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {
+    "items": [
+      {
+        "id": "sac",
+        "name": "SAC",
+        "firstResponseH": 1,
+        "firstResponseM": 30,
+        "resolutionH": 24,
+        "alertPct": 80,
+        "liveStatus": "breach",
+        "liveDetail": "14h estourado",
+        "lastUpdated": "há 8 minutos"
+      }
+    ]
+  }
+}
+```
+
+Para editar uma configuração existente, usar `POST /sla/configs` novamente enviando o `id` no payload.
+
+### Observação importante
+
+O banco atual **não possui `alertPct` na tabela `sla_config`**.
+
+Então existem duas opções:
+
+- **Opção A**: adicionar uma coluna nova na tabela `sla_config`;
+- **Opção B**: manter esse percentual em configuração de aplicação.
+
+### Fontes no banco
+
+- `sla_config`
+- `conversation`
+- `operator`
+
+---
+
+## 4.10 Configurações (`/dashboard/settings`)
+
+### Estado atual
+
+A tela é estática e mostra:
+
+- integrações;
+- notificações;
+- suporte.
+
+### Backend precisa entregar
+
+#### `GET /settings/overview`
+
+Response:
+
+```json
+{
+  "status": "ok",
+  "msg": "[100] Request ok.",
+  "code": "S_OK",
+  "data": {
+    "integrations": [
+      {
+        "title": "WhatsApp Business",
+        "description": "Configure sua conta do WhatsApp Business para comunicação",
+        "status": "connected",
+        "action": "gerenciar"
+      },
+      {
+        "title": "WhatsApp dos Vendedores",
+        "description": "Adicione os números de WhatsApp da equipe de vendas",
+        "status": "partial",
+        "action": "configurar",
+        "count": "3 de 5 configurados"
+      }
+    ],
+    "notifications": [
+      {
+        "title": "Alertas de Sentimento",
+        "description": "Receba alertas sobre mudanças no sentimento dos clientes",
+        "status": "enabled",
+        "action": "configurar"
+      }
+    ],
+    "support": [
+      {
+        "title": "Central de Ajuda",
+        "description": "Acesse nossa documentação e tutoriais",
+        "status": "available",
+        "action": "acessar"
+      }
+    ]
+  }
+}
+```
+
+### Observação importante
+
+Esta parte do produto **não está coberta pelo schema atual** de forma completa.
+
+Existe suporte parcial em:
+
+- `integration`
+- `operator_channel`
+
+Mas **não há modelagem explícita** para preferências de notificação e configurações gerais.
+
+---
+
+## 4.11 Perfil (`/dashboard/profile` e `/dashboard/perfil`)
+
+### Backend precisa entregar
+
+- `POST /user/get`
+- `POST /me`
+- `POST /me/change-password`
+
+#### `POST /me`
+
+Request:
+
+```json
+{
+  "user_name": "Admin",
+  "user_phone": "5511999999999"
+}
+```
+
+#### `POST /me/change-password`
+
+Request:
+
+```json
+{
+  "currentPassword": "senha-atual",
+  "newPassword": "senha-nova",
+  "confirmPassword": "senha-nova"
+}
+```
+
+### Fontes no banco
+
+- `user`
+- `company`
+
+---
+
+## 4.13 Ajuda (`/dashboard/help` e `/dashboard/ajuda`)
+
+### Backend precisa entregar
+
+Opcionalmente:
+
+- `GET /support/faq`
+- `GET /support/channels`
+
+### Observação
+
+Essa tela pode continuar **100% estática** no frontend, se preferir.
+
+---
+
+## 5. Rotas recomendadas para o backend
+
+## Prioridade 0 — obrigatórias para integrar o app
+
+- `POST /login`
+- `POST /user/get`
+- `POST /me`
+- `POST /me/change-password`
+
+## Prioridade 1 — telas mais centrais do produto
+
+- `GET /dashboard/overview`
+- `GET /interactions`
+- `GET /interactions/details`
+- `GET /analytics/sentiment/dashboard`
+
+## Prioridade 2 — gestão e inteligência
+
+- `GET /personas/overview`
+- `GET /evolution/overview`
+- `GET /executive/dashboard`
+- `GET /agents`
+- `POST /agents`
+- `POST /agents/status`
+- `POST /agents/escalation`
+- `GET /sla/configs`
+- `POST /sla/configs`
+- `GET /sla/live-status`
+
+## Prioridade 3 — configurações e módulos acessórios
+
+- `GET /settings/overview`
+- `GET /billing/subscription`
+- `GET /billing/plans`
+- `POST /billing/change-plan`
+- `POST /billing/cancel`
+- `GET /support/faq`
+- `GET /support/channels`
+
+---
+
+## 6. Padrão de implementação recomendado
+
+## Envelope de resposta
+
+Manter o padrão já existente no backend:
+
+```json
+{
+  "status": "ok|failed",
+  "msg": "mensagem",
+  "code": "S_OK|E_VALIDATE|...",
+  "data": {}
+}
+```
+
+## Autorização
+
+Todas as rotas privadas devem exigir:
+
+```http
+Authorization: Bearer <jwt>
+```
+
+## Convenção prática para o frontend
+
+Como o frontend atual está em Svelte e usa muitos objetos de tela prontos, o caminho mais simples é:
+
+- backend responder com objetos **já próximos do formato consumido pelas telas**;
+- frontend fazer apenas adaptação leve, se necessário.
+
+---
+
+## 7. Observações importantes da análise
+
+- O frontend **não está chamando API nenhuma hoje**.
+- Portanto, além de criar as rotas do backend, será necessário integrar o frontend com:
+  - `fetch`/client HTTP;
+  - persistência de token;
+  - carregamento inicial com `POST /user/get`;
+  - tratamento de `401`.
+- Como o frontend usa `adapter-static`, o consumo da API será **client-side**.
+- Isso significa que o backend provavelmente precisará de:
+  - **CORS habilitado**;
+  - URL pública configurável por ambiente.
+
+---
+
+## 8. Resumo executivo
+
+## O que já existe
+
+- frontend pronto visualmente;
+- banco PostgreSQL bem modelado para analytics/atendimento;
+- login e registro no backend;
+- JWT funcionando;
+- migration pronta.
+
+## O que falta
+
+- praticamente toda a API de produto;
+- integração real do frontend com backend;
+- endpoints agregadores para dashboards;
+- CRUD de agentes;
+- endpoints de SLA;
+- endpoint de perfil;
+- eventualmente modelagem extra para billing/settings.
+
+## Melhor ordem de implementação
+
+1. autenticação real no frontend;
+2. `/user/get`;
+3. dashboard principal;
+4. interações;
+5. analytics/sentiment;
+6. agentes;
+7. SLA;
+8. personas/evolução/executivo;
+9. settings/billing/help.
+
+---
+
+## 9. Conclusão
+
+O projeto está em um ponto muito bom para integração:
+
+- o frontend já mostra claramente o produto final;
+- o banco já cobre boa parte do domínio;
+- o backend já tem base de auth pronta.
+
+O principal trabalho agora é transformar os mocks do frontend em contratos reais de API.
+
+Este arquivo pode ser usado como backlog técnico para construir o backend na ordem correta.

Some files were not shown because too many files changed in this diff