EduLascala пре 3 недеља
родитељ
комит
bb7db7afbf

+ 5 - 18
.env.example

@@ -1,20 +1,7 @@
 APP_PORT=8080
-DB_FILE=test.db
+DB_HOST=127.0.0.1
+DB_PORT=5432
+DB_NAME=nettown
+DB_USER=postgres
+DB_PASS=
 JWT_SECRET=Aer8woa9zeec2gai4ahQuah3Ahbee5eiSefae8pheepahnootuShoo0oKahf
-
-
-#================= BUILD =================
-BUILD_APP="php-api"
-BUILD_VER="1.0.0"
-BUILD_STAGE="build/php-api"
-BUILD_DESCRIPTION="API PHP php-api (SmartPay)"
-BUILD_LICENSE="Proprietary"
-BUILD_VENDOR="SmartPay"
-BUILD_MAINTAINER="lucas.joaquim@smartpay.com.vc"
-BUILD_DEPENDS="php8.2-cli | php-cli (>= 8.2)"
-BUILD_ARCH="all"
-
-#================= DEPLOY =================
-DEPLOY_HOST="pixplay.com.vc"
-DEPLOY_TOKEN=""
-DEPLOY_FILE="./php-api_1.0.0_all.deb"

+ 566 - 0
DATABASE.md

@@ -0,0 +1,566 @@
+# Documentação do Banco de Dados
+
+Este documento descreve o schema PostgreSQL definido em `migrations/migrations_v1.sql` para o projeto `php_api`.
+
+## Visão geral
+
+O banco foi modelado para suportar uma operação multiempresa, com as seguintes áreas principais:
+
+- **Entidades centrais**: `company`, `user`, `operator`, `sku`, `integration`.
+- **Operação de atendimento**: `client`, `conversation`, `message`, `message_attachment`, `message_reaction`, `conversation_participant`, `webhook_event`.
+- **Análise e inteligência**: `conversation_analysis`, `aspect_feedback`, `emotion_snapshot`, `public_opinion`, `ai_action`.
+- **Estratégia e gestão**: `persona`, `client_persona`, `best_action`.
+- **Métricas e snapshots**: `volume_snapshot`, `sentiment_evolution`, `playbooks_monitor`, `operator_daily_stats`, `kpi_snapshot`.
+
+## Convenções do schema
+
+- Todas as tabelas usam `SERIAL PRIMARY KEY` para o identificador principal.
+- Todas as colunas relevantes estão marcadas como `NOT NULL`.
+- Colunas de exclusão lógica usam o padrão:
+  - `TIMESTAMP NOT NULL DEFAULT 'infinity'`
+- O valor `'infinity'` indica que o registro está ativo.
+- A tabela `user` é criada entre aspas (`"user"`) porque o nome é reservado em PostgreSQL.
+- As chaves estrangeiras foram declaradas com `CONSTRAINT` em linha única.
+- Alguns relacionamentos possuem `UNIQUE` para impedir duplicidade por empresa ou por data.
+
+## Tabelas
+
+### 1. `company`
+
+Tabela principal de empresas atendidas pelo sistema.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `company_id` | `SERIAL` | Identificador da empresa. |
+| `company_name` | `VARCHAR(100)` | Nome da empresa. |
+| `company_cnpj` | `VARCHAR(14)` | CNPJ da empresa. Único. |
+| `company_logo` | `TEXT` | Logo da empresa. |
+| `company_created_at` | `TIMESTAMP` | Data de criação. |
+| `company_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 2. `user`
+
+Usuários do sistema vinculados a uma empresa.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `user_id` | `SERIAL` | Identificador do usuário. |
+| `company_id` | `INT` | FK para `company.company_id`. |
+| `user_name` | `VARCHAR(100)` | Nome do usuário. |
+| `user_phone` | `VARCHAR(20)` | Telefone. |
+| `user_email` | `VARCHAR(100)` | E-mail. Único. |
+| `user_role` | `VARCHAR(10)` | Papel/perfil do usuário. |
+| `user_password` | `VARCHAR(255)` | Senha criptografada. |
+| `user_created_at` | `TIMESTAMP` | Data de criação. |
+| `user_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 3. `operator`
+
+Operadores/atendentes vinculados à empresa.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `operator_id` | `SERIAL` | Identificador do operador. |
+| `company_id` | `INT` | FK para `company`. |
+| `operator_name` | `VARCHAR(100)` | Nome do operador. |
+| `operator_initials` | `VARCHAR(5)` | Iniciais do operador. |
+| `operator_email` | `VARCHAR(100)` | E-mail do operador. |
+| `operator_phone` | `VARCHAR(20)` | Telefone. |
+| `operator_department` | `VARCHAR(20)` | Departamento. |
+| `operator_status` | `VARCHAR(30)` | Status atual. Default: `'Disponível'`. |
+| `operator_available_for_escalation` | `BOOLEAN` | Indica disponibilidade para escalonamento. |
+| `operator_created_at` | `TIMESTAMP` | Data de criação. |
+| `operator_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 4. `operator_channel`
+
+Canais vinculados a cada operador.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `operator_channel_id` | `SERIAL` | Identificador do registro. |
+| `operator_id` | `INT` | FK para `operator`. |
+| `operator_channel` | `VARCHAR(20)` | Nome do canal. |
+| `operator_channel_created_at` | `TIMESTAMP` | Data de criação. |
+| `operator_channel_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 5. `sla_config`
+
+Configuração de SLA por empresa e departamento.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `sla_config_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `sla_config_department` | `VARCHAR(20)` | Departamento. |
+| `sla_config_response_hours` | `INT` | Tempo de resposta em horas. Default: `2`. |
+| `sla_config_resolution_hours` | `INT` | Tempo de resolução em horas. Default: `24`. |
+| `sla_config_updated_at` | `TIMESTAMP` | Data de atualização. |
+| `sla_config_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+**Restrição:**
+- `UNIQUE (company_id, sla_config_department)`
+
+### 6. `sku`
+
+Cadastro de produtos/serviços da empresa.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `sku_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `sku_name` | `VARCHAR(100)` | Nome do item. |
+| `sku_value` | `DECIMAL(12,2)` | Valor. |
+| `sku_sold` | `INT` | Quantidade vendida. Default: `0`. |
+| `sku_line` | `VARCHAR(50)` | Linha/categoria. |
+| `sku_created_at` | `TIMESTAMP` | Data de criação. |
+| `sku_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 7. `integration`
+
+Integrações externas da empresa.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `integration_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `integration_provider` | `VARCHAR(30)` | Provedor da integração. |
+| `integration_account_id` | `TEXT` | ID da conta. |
+| `integration_external_account_id` | `TEXT` | ID externo da conta. |
+| `integration_account_name` | `VARCHAR(100)` | Nome da conta. |
+| `integration_status` | `VARCHAR(20)` | Status da integração. |
+| `integration_access_token` | `TEXT` | Token de acesso. |
+| `integration_refresh_token` | `TEXT` | Refresh token. |
+| `integration_is_connected` | `BOOLEAN` | Indica se está conectada. Default: `TRUE`. |
+| `integration_last_sync_at` | `TIMESTAMP` | Última sincronização. |
+| `integration_last_error` | `TEXT` | Último erro. |
+| `integration_created_at` | `TIMESTAMP` | Data de criação. |
+| `integration_updated_at` | `TIMESTAMP` | Data de atualização. |
+| `integration_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 8. `client`
+
+Clientes que interagem com a empresa.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `client_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `client_provider_id` | `TEXT` | ID do cliente no provedor. |
+| `client_phone` | `VARCHAR(20)` | Telefone. |
+| `client_name` | `VARCHAR(100)` | Nome. |
+| `client_email` | `VARCHAR(100)` | E-mail. |
+| `client_segment` | `VARCHAR(100)` | Segmento. |
+| `client_is_registered` | `BOOLEAN` | Indica se o cliente está cadastrado. Default: `FALSE`. |
+| `client_created_at` | `TIMESTAMP` | Data de criação. |
+| `client_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+**Restrição:**
+- `UNIQUE (company_id, client_phone)`
+
+### 9. `conversation`
+
+Conversas entre clientes e operadores/integrações.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `conversation_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `integration_id` | `INT` | FK para `integration`. |
+| `operator_id` | `INT` | FK para `operator`. |
+| `client_id` | `INT` | FK para `client`. |
+| `conversation_external_id` | `TEXT` | ID externo da conversa. |
+| `conversation_provider_id` | `TEXT` | ID no provedor. |
+| `conversation_channel` | `VARCHAR(20)` | Canal. |
+| `conversation_status` | `VARCHAR(20)` | Status da conversa. Default: `'open'`. |
+| `conversation_is_automated` | `BOOLEAN` | Indica automação. Default: `FALSE`. |
+| `conversation_started_at` | `TIMESTAMP` | Início. |
+| `conversation_closed_at` | `TIMESTAMP` | Encerramento. |
+| `conversation_sla_deadline` | `TIMESTAMP` | Prazo de SLA. |
+| `conversation_last_message_at` | `TIMESTAMP` | Data da última mensagem. |
+| `conversation_last_message_preview` | `TEXT` | Prévia da última mensagem. |
+| `conversation_last_message_from` | `VARCHAR(10)` | Origem da última mensagem. |
+| `conversation_impact_value` | `DECIMAL(12,2)` | Valor de impacto. |
+| `conversation_ticket_value` | `DECIMAL(12,2)` | Valor do ticket. |
+| `conversation_conversion_chance` | `INT` | Chance de conversão. |
+| `conversation_optimum_window` | `VARCHAR(20)` | Janela ideal. |
+| `conversation_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 10. `message`
+
+Mensagens associadas a uma conversa.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `message_id` | `SERIAL` | Identificador. |
+| `conversation_id` | `INT` | FK para `conversation`. |
+| `quoted_message_id` | `INT` | FK auto-referenciada para `message`. |
+| `message_external_id` | `TEXT` | ID externo da mensagem. |
+| `message_provider_id` | `TEXT` | ID no provedor. |
+| `message_sender_provider_id` | `TEXT` | Remetente no provedor. |
+| `message_is_operator` | `BOOLEAN` | Indica se foi enviada por operador. |
+| `message_type` | `VARCHAR(20)` | Tipo da mensagem. Default: `'text'`. |
+| `message_content` | `TEXT` | Conteúdo. |
+| `message_seen` | `BOOLEAN` | Visualizada. |
+| `message_delivered` | `BOOLEAN` | Entregue. |
+| `message_edited` | `BOOLEAN` | Editada. Default: `FALSE`. |
+| `message_deleted` | `BOOLEAN` | Excluída. Default: `FALSE`. |
+| `message_hidden` | `BOOLEAN` | Oculta. Default: `FALSE`. |
+| `message_is_event` | `BOOLEAN` | Indica evento. Default: `FALSE`. |
+| `message_event_type` | `INT` | Tipo do evento. |
+| `message_sent_at` | `TIMESTAMP` | Data de envio. |
+| `message_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 11. `message_attachment`
+
+Anexos de mensagens.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `attachment_id` | `SERIAL` | Identificador. |
+| `message_id` | `INT` | FK para `message`. |
+| `attachment_external_id` | `TEXT` | ID externo. |
+| `attachment_url` | `TEXT` | URL do arquivo. |
+| `attachment_type` | `VARCHAR(50)` | Tipo do anexo. |
+| `attachment_mime_type` | `VARCHAR(100)` | MIME type. |
+| `attachment_file_name` | `VARCHAR(255)` | Nome do arquivo. |
+| `attachment_size` | `BIGINT` | Tamanho em bytes. |
+| `attachment_created_at` | `TIMESTAMP` | Data de criação. |
+| `attachment_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 12. `message_reaction`
+
+Reações vinculadas a mensagens.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `reaction_id` | `SERIAL` | Identificador. |
+| `message_id` | `INT` | FK para `message`. |
+| `reaction_sender_provider_id` | `TEXT` | Remetente da reação no provedor. |
+| `reaction_value` | `VARCHAR(20)` | Valor da reação. |
+| `reaction_is_sender` | `BOOLEAN` | Indica se foi enviada pelo remetente. Default: `FALSE`. |
+| `reaction_created_at` | `TIMESTAMP` | Data de criação. |
+| `reaction_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 13. `conversation_participant`
+
+Participantes de uma conversa.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `participant_id` | `SERIAL` | Identificador. |
+| `conversation_id` | `INT` | FK para `conversation`. |
+| `participant_provider_id` | `TEXT` | ID do participante no provedor. |
+| `participant_name` | `VARCHAR(100)` | Nome do participante. |
+| `participant_is_admin` | `BOOLEAN` | Indica se é administrador. Default: `FALSE`. |
+| `participant_created_at` | `TIMESTAMP` | Data de criação. |
+| `participant_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 14. `webhook_event`
+
+Eventos recebidos por webhook.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `webhook_event_id` | `SERIAL` | Identificador. |
+| `integration_id` | `INT` | FK para `integration`. |
+| `webhook_event_type` | `VARCHAR(50)` | Tipo do evento. |
+| `webhook_event_external_id` | `TEXT` | ID externo do evento. |
+| `webhook_event_payload` | `JSONB` | Payload bruto do evento. |
+| `webhook_event_processed` | `BOOLEAN` | Processado. Default: `FALSE`. |
+| `webhook_event_received_at` | `TIMESTAMP` | Data de recebimento. |
+| `webhook_event_processed_at` | `TIMESTAMP` | Data de processamento. |
+| `webhook_event_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 15. `conversation_analysis`
+
+Resultado de análise de conversa por IA.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `analysis_id` | `SERIAL` | Identificador. |
+| `conversation_id` | `INT` | FK para `conversation`. Única. |
+| `company_id` | `INT` | FK para `company`. |
+| `conversation_analysis_sentiment` | `VARCHAR(20)` | Sentimento detectado. |
+| `conversation_analysis_sentiment_score` | `NUMERIC(3,2)` | Score do sentimento. |
+| `conversation_analysis_aspect` | `VARCHAR(50)` | Aspecto principal. |
+| `conversation_analysis_sub_aspect` | `VARCHAR(100)` | Subaspecto. |
+| `conversation_analysis_analyzed_at` | `TIMESTAMP` | Data da análise. |
+| `conversation_analysis_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 16. `aspect_feedback`
+
+Feedback de aspecto da conversa.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `aspect_feedback_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `conversation_id` | `INT` | FK para `conversation`. |
+| `aspect_feedback_aspect` | `VARCHAR(50)` | Aspecto. |
+| `aspect_feedback_sentiment` | `VARCHAR(20)` | Sentimento. |
+| `aspect_feedback_text` | `TEXT` | Texto do feedback. |
+| `aspect_feedback_created_at` | `TIMESTAMP` | Data de criação. |
+| `aspect_feedback_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 17. `emotion_snapshot`
+
+Snapshot de emoções por empresa e data.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `emotion_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `emotion_snapshot_date` | `DATE` | Data do snapshot. |
+| `emotion_happiness` | `NUMERIC(5,2)` | Felicidade. Default: `0`. |
+| `emotion_sadness` | `NUMERIC(5,2)` | Tristeza. Default: `0`. |
+| `emotion_anger` | `NUMERIC(5,2)` | Raiva. Default: `0`. |
+| `emotion_fear` | `NUMERIC(5,2)` | Medo. Default: `0`. |
+| `emotion_anticipation` | `NUMERIC(5,2)` | Antecipação. Default: `0`. |
+| `emotion_confidence` | `NUMERIC(5,2)` | Confiança. Default: `0`. |
+| `emotion_surprise` | `NUMERIC(5,2)` | Surpresa. Default: `0`. |
+| `emotion_total_analyzed` | `INT` | Total analisado. Default: `0`. |
+
+**Restrição:**
+- `UNIQUE (company_id, emotion_snapshot_date)`
+
+### 18. `public_opinion`
+
+Opiniões públicas classificadas por conversa.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `opinion_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `conversation_id` | `INT` | FK para `conversation`. |
+| `opinion_is_positive` | `BOOLEAN` | Indica se a opinião é positiva. |
+| `opinion_classified_at` | `TIMESTAMP` | Data da classificação. |
+| `opinion_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 19. `alert`
+
+Alertas gerados pelo sistema.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `alert_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `client_id` | `INT` | FK para `client`. |
+| `alert_type` | `VARCHAR(20)` | Tipo do alerta. |
+| `alert_priority` | `VARCHAR(10)` | Prioridade. |
+| `alert_title` | `TEXT` | Título. |
+| `alert_description` | `TEXT` | Descrição. |
+| `alert_tips` | `TEXT` | Dicas. |
+| `alert_is_resolved` | `BOOLEAN` | Indica se foi resolvido. Default: `FALSE`. |
+| `alert_created_at` | `TIMESTAMP` | Data de criação. |
+| `alert_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 20. `ai_action`
+
+Sugestões/ações geradas por IA.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `ai_action_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `ai_action_idea` | `TEXT` | Ideia gerada. |
+| `ai_action_is_accepted` | `BOOLEAN` | Indica aceitação. |
+| `ai_action_created_at` | `TIMESTAMP` | Data de criação. |
+| `ai_action_responded_at` | `TIMESTAMP` | Data de resposta. |
+| `ai_action_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 21. `persona`
+
+Perfis/personas da empresa.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `persona_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `persona_name` | `VARCHAR(100)` | Nome da persona. |
+| `persona_type` | `VARCHAR(50)` | Tipo da persona. Default: `'O PERFIL'`. |
+| `persona_description` | `TEXT` | Descrição. |
+| `persona_details` | `TEXT` | Detalhes. |
+| `persona_risk_level` | `VARCHAR(10)` | Nível de risco. Default: `'Médio'`. |
+| `persona_churn_risk_pct` | `NUMERIC(5,2)` | Percentual de risco de churn. |
+| `persona_expansion_potential` | `VARCHAR(20)` | Potencial de expansão. |
+| `persona_expansion_strategy` | `TEXT` | Estratégia de expansão. |
+| `persona_engagement_strategy` | `TEXT` | Estratégia de engajamento. |
+| `persona_created_at` | `TIMESTAMP` | Data de criação. |
+| `persona_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 22. `client_persona`
+
+Relacionamento entre cliente e persona.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `client_persona_id` | `SERIAL` | Identificador. |
+| `client_id` | `INT` | FK para `client`. |
+| `persona_id` | `INT` | FK para `persona`. |
+| `client_persona_assigned_at` | `TIMESTAMP` | Data de associação. |
+| `client_persona_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 23. `best_action`
+
+Melhor ação sugerida por empresa e persona.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `best_action_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `persona_id` | `INT` | FK para `persona`. |
+| `best_action_type` | `VARCHAR(20)` | Tipo da ação. |
+| `best_action_idea` | `TEXT` | Ideia da ação. |
+| `best_action_created_at` | `TIMESTAMP` | Data de criação. |
+| `best_action_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+### 24. `volume_snapshot`
+
+Snapshot de volume por canal, empresa e data.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `volume_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `volume_snapshot_date` | `DATE` | Data do snapshot. |
+| `volume_channel` | `VARCHAR(20)` | Canal. |
+| `volume_message_count` | `INT` | Quantidade de mensagens. Default: `0`. |
+| `volume_conversation_count` | `INT` | Quantidade de conversas. Default: `0`. |
+
+**Restrição:**
+- `UNIQUE (company_id, volume_snapshot_date, volume_channel)`
+
+### 25. `sentiment_evolution`
+
+Evolução de sentimento por empresa e data.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `evolution_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `evolution_snapshot_date` | `DATE` | Data do snapshot. |
+| `evolution_sentiment_score` | `NUMERIC(3,2)` | Score do sentimento. |
+
+**Restrição:**
+- `UNIQUE (company_id, evolution_snapshot_date)`
+
+### 26. `playbooks_monitor`
+
+Monitoramento de playbooks por data.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `playbook_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `playbook_snapshot_date` | `DATE` | Data do snapshot. |
+| `playbook_new_detected` | `INT` | Quantidade de novos detectados. Default: `0`. |
+| `playbook_converted` | `INT` | Quantidade convertida. Default: `0`. |
+| `playbook_client_type` | `VARCHAR(10)` | Tipo de cliente. Default: `'new'`. |
+
+**Restrição:**
+- `UNIQUE (company_id, playbook_snapshot_date)`
+
+### 27. `operator_daily_stats`
+
+Estatísticas diárias por operador.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `stat_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `operator_id` | `INT` | FK para `operator`. |
+| `operator_stat_date` | `DATE` | Data da estatística. |
+| `operator_attendances_count` | `INT` | Quantidade de atendimentos. Default: `0`. |
+| `operator_avg_response_seconds` | `INT` | Tempo médio de resposta em segundos. Default: `0`. |
+| `operator_sla_compliance_pct` | `NUMERIC(5,2)` | Percentual de SLA. Default: `0`. |
+| `operator_sales_amount` | `DECIMAL(12,2)` | Valor de vendas. Default: `0`. |
+
+**Restrição:**
+- `UNIQUE (operator_id, operator_stat_date)`
+
+### 28. `kpi_snapshot`
+
+Snapshot consolidado de KPIs da empresa.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `kpi_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `kpi_snapshot_date` | `DATE` | Data do snapshot. |
+| `kpi_current_sales` | `DECIMAL(12,2)` | Vendas atuais. Default: `0`. |
+| `kpi_avg_ticket` | `DECIMAL(12,2)` | Ticket médio. Default: `0`. |
+| `kpi_lifetime_at_risk` | `DECIMAL(12,2)` | Valor em risco. Default: `0`. |
+| `kpi_critical_risk_clients` | `INT` | Clientes em risco crítico. Default: `0`. |
+| `kpi_sla_compliance_pct` | `NUMERIC(5,2)` | Percentual de compliance do SLA. Default: `0`. |
+| `kpi_churn_low_pct` | `NUMERIC(5,2)` | Churn baixo. Default: `0`. |
+| `kpi_churn_moderate_pct` | `NUMERIC(5,2)` | Churn moderado. Default: `0`. |
+| `kpi_churn_high_pct` | `NUMERIC(5,2)` | Churn alto. Default: `0`. |
+| `kpi_churn_critical_pct` | `NUMERIC(5,2)` | Churn crítico. Default: `0`. |
+| `kpi_ltv_total` | `DECIMAL(12,2)` | LTV total. Default: `0`. |
+| `kpi_ltv_at_risk` | `DECIMAL(12,2)` | LTV em risco. Default: `0`. |
+| `kpi_total_registered_clients` | `INT` | Total de clientes cadastrados. Default: `0`. |
+| `kpi_total_active_operators` | `INT` | Total de operadores ativos. Default: `0`. |
+| `kpi_general_emotion` | `VARCHAR(20)` | Emoção geral. |
+
+**Restrição:**
+- `UNIQUE (company_id, kpi_snapshot_date)`
+
+## Relacionamentos principais
+
+- **`user.company_id` → `company.company_id`**
+- **`operator.company_id` → `company.company_id`**
+- **`operator_channel.operator_id` → `operator.operator_id`**
+- **`sla_config.company_id` → `company.company_id`**
+- **`sku.company_id` → `company.company_id`**
+- **`integration.company_id` → `company.company_id`**
+- **`client.company_id` → `company.company_id`**
+- **`conversation.company_id` → `company.company_id`**
+- **`conversation.integration_id` → `integration.integration_id`**
+- **`conversation.operator_id` → `operator.operator_id`**
+- **`conversation.client_id` → `client.client_id`**
+- **`message.conversation_id` → `conversation.conversation_id`**
+- **`message.quoted_message_id` → `message.message_id`**
+- **`message_attachment.message_id` → `message.message_id`**
+- **`message_reaction.message_id` → `message.message_id`**
+- **`conversation_participant.conversation_id` → `conversation.conversation_id`**
+- **`webhook_event.integration_id` → `integration.integration_id`**
+- **`conversation_analysis.conversation_id` → `conversation.conversation_id`**
+- **`conversation_analysis.company_id` → `company.company_id`**
+- **`aspect_feedback.company_id` → `company.company_id`**
+- **`aspect_feedback.conversation_id` → `conversation.conversation_id`**
+- **`emotion_snapshot.company_id` → `company.company_id`**
+- **`public_opinion.company_id` → `company.company_id`**
+- **`public_opinion.conversation_id` → `conversation.conversation_id`**
+- **`alert.company_id` → `company.company_id`**
+- **`alert.client_id` → `client.client_id`**
+- **`ai_action.company_id` → `company.company_id`**
+- **`persona.company_id` → `company.company_id`**
+- **`client_persona.client_id` → `client.client_id`**
+- **`client_persona.persona_id` → `persona.persona_id`**
+- **`best_action.company_id` → `company.company_id`**
+- **`best_action.persona_id` → `persona.persona_id`**
+- **`volume_snapshot.company_id` → `company.company_id`**
+- **`sentiment_evolution.company_id` → `company.company_id`**
+- **`playbooks_monitor.company_id` → `company.company_id`**
+- **`operator_daily_stats.company_id` → `company.company_id`**
+- **`operator_daily_stats.operator_id` → `operator.operator_id`**
+- **`kpi_snapshot.company_id` → `company.company_id`**
+
+## Observações importantes
+
+- O campo `*_deleted_at = 'infinity'` indica registro ativo.
+- A lógica da aplicação deve considerar esse valor para filtros de registros válidos.
+- O setup do projeto recria o banco PostgreSQL antes de aplicar a migration.
+- A migration atual é a fonte de verdade da estrutura do banco.
+
+## Resumo
+
+O schema foi projetado para armazenar:
+
+- cadastro de empresas e usuários,
+- operação de atendimento e conversas,
+- mensagens e anexos,
+- processamento de IA,
+- estratégia comercial,
+- métricas por dia e por empresa.

+ 0 - 16
README.md

@@ -1,16 +0,0 @@
-```
-fpm -s dir -t deb \
-  -n php-api -v 1.0.0 \
-  -C build/php-api \
-  --prefix / \
-  --description "API PHP php-api (SmartPay)" \
-  --license "Proprietary" \
-  --vendor "SmartPay" \
-  --maintainer "lucas.joaquim@smartpay.com.vc" \
-  --architecture all \
-  --deb-no-default-config-files \
-  --config-files /etc/php-api \
-  --depends "php8.2-cli | php-cli (>= 8.2)" \
-  opt/php-api etc/php-api var/log/php-api
-Created package {:path=>"php-api_1.0.0_all.deb"}
-```

+ 60 - 44
bin/setup

@@ -1,44 +1,60 @@
-#!/bin/bash
-
-# Nome do arquivo do banco de dados
-DB_FILE="test.db"
-
-# Executa comandos SQL no SQLite
-sqlite3 "$DB_FILE" <<EOF
--- Cria tabela 'user' se não existir, com coluna 'user_password'
-CREATE TABLE IF NOT EXISTS user (
-    user_id INTEGER PRIMARY KEY AUTOINCREMENT,
-    user_name TEXT NOT NULL,
-    user_flag TEXT NOT NULL,
-    user_password TEXT NOT NULL  -- Nova coluna para senha hasheada
-);
-
--- Cria tabela 'api_key' se não existir
-CREATE TABLE IF NOT EXISTS api_key (
-    api_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
-    user_id INTEGER NOT NULL,
-    api_key_user TEXT NOT NULL,
-    api_key_secret TEXT NOT NULL,
-    FOREIGN KEY (user_id) REFERENCES user(user_id)
-);
-
--- Insere usuário de exemplo ('admin') com senha hasheada se não existir
--- Hash de 'pass' (gere com user_password_hash em PHP e substitua)
-INSERT OR IGNORE INTO user (user_name, user_flag, user_password) VALUES ('admin', 'a', '\$2y\$10\$K.0XhB3kXjZfZfZfZfZfZfZfZfZfZfZfZfZfZfZfZfZfZfZfZfZ');
-
--- Insere chave API para o usuário 'admin' se não existir
-INSERT OR IGNORE INTO api_key (user_id, api_key_user, api_key_secret)
-SELECT user_id, 'myapikey', 'myapisecret' FROM user WHERE user_name = 'admin';
-
--- Opcional: Insere mais um usuário de teste com senha hasheada
--- Hash de 'testpass' (substitua pelo real)
-INSERT OR IGNORE INTO user (user_name, user_flag, user_password) VALUES ('testuser', 'a', '\$2y\$10\$AnotherHashHereForTestPass');
-INSERT OR IGNORE INTO api_key (user_id, api_key_user, api_key_secret)
-SELECT user_id, 'testapikey', 'testapisecret' FROM user WHERE user_name = 'testuser';
-
--- Exibe os dados inseridos para verificação (sem mostrar hash real por segurança)
-SELECT user_id, user_name, user_flag FROM user;
-SELECT * FROM api_key;
-EOF
-
-echo "Banco de dados '$DB_FILE' criado e populado com sucesso! Senhas estão hasheadas."
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
+ENV_FILE="$PROJECT_ROOT/.env"
+MIGRATION_FILE="$PROJECT_ROOT/migrations/migrations_v1.sql"
+
+# Carrega as variáveis de ambiente do arquivo .env se ele existir
+if [[ -f "$ENV_FILE" ]]; then
+    set -a
+    # shellcheck disable=SC1090
+    source "$ENV_FILE"
+    set +a
+fi
+
+# Fallbacks caso não estejam definidas no .env
+DB_HOST="${DB_HOST:-127.0.0.1}"
+DB_PORT="${DB_PORT:-5432}"
+DB_NAME="${DB_NAME:-}"
+DB_USER="${DB_USER:-}"
+DB_PASSWORD="${DB_PASSWORD:-${DB_PASS:-}}"
+
+# Validações básicas
+if [[ -z "$DB_NAME" ]]; then
+    echo "Erro: DB_NAME não está configurado no .env."
+    exit 1
+fi
+
+if [[ -z "$DB_USER" ]]; then
+    echo "Erro: DB_USER não está configurado no .env."
+    exit 1
+fi
+
+# Exporta a senha temporariamente para o psql não pedir interativamente
+export PGPASSWORD="$DB_PASSWORD"
+
+echo "Conectando ao PostgreSQL para recriar o banco '$DB_NAME'..."
+
+# Conecta ao banco padrão 'postgres' para conseguir derrubar e recriar o seu banco
+psql "host=$DB_HOST port=$DB_PORT user=$DB_USER dbname=postgres" -v ON_ERROR_STOP=1 <<SQL
+-- Derruba conexões ativas para evitar o erro de "database is being accessed by other users"
+SELECT pg_terminate_backend(pid)
+FROM pg_stat_activity
+WHERE datname = '$DB_NAME'
+  AND pid <> pg_backend_pid();
+
+DROP DATABASE IF EXISTS "$DB_NAME";
+CREATE DATABASE "$DB_NAME" OWNER "$DB_USER";
+SQL
+
+echo "Aplicando as migrações do arquivo: $(basename "$MIGRATION_FILE")..."
+
+# Conecta diretamente no banco novo e roda o arquivo de SQL
+psql "host=$DB_HOST port=$DB_PORT user=$DB_USER dbname=$DB_NAME" -v ON_ERROR_STOP=1 -f "$MIGRATION_FILE"
+
+echo "--------------------------------------------------------"
+echo "Banco de dados '$DB_NAME' recriado com sucesso e migration aplicada!"
+echo "--------------------------------------------------------"

Разлика између датотеке није приказан због своје велике величине
+ 2604 - 451
composer.lock


+ 564 - 0
controllers/DashboardOverviewController.php

@@ -0,0 +1,564 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Database;
+use Libs\ResponseLib;
+use Psr\Http\Message\ServerRequestInterface;
+
+class DashboardOverviewController
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    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->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),
+            ]);
+        } 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';
+    }
+}

+ 332 - 0
controllers/InteractionDetailsController.php

@@ -0,0 +1,332 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Database;
+use Libs\ResponseLib;
+use Psr\Http\Message\ServerRequestInterface;
+
+class InteractionDetailsController
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        $conversationId = (int) (($request->getQueryParams()['conversation_id'] ?? 0));
+
+        if ($userId <= 0) {
+            return ResponseLib::sendFail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        if ($conversationId <= 0) {
+            return ResponseLib::sendFail('Missing or invalid conversation_id', [], 'E_VALIDATE')->withStatus(400);
+        }
+
+        try {
+            $companyId = $this->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
+            }
+
+            $conversation = $this->getConversation($companyId, $conversationId);
+            if ($conversation === 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,
+            ]);
+        } 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);
+    }
+}

+ 280 - 0
controllers/InteractionsController.php

@@ -0,0 +1,280 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Database;
+use Libs\ResponseLib;
+use Psr\Http\Message\ServerRequestInterface;
+
+class InteractionsController
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        $userEmail = (string) ($request->getAttribute('user_email') ?? '');
+
+        if ($userId <= 0) {
+            return ResponseLib::sendFail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        try {
+            $companyId = $this->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,
+                ],
+            ]);
+        } 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);
+    }
+}

+ 13 - 9
controllers/LoginController.php

@@ -11,30 +11,34 @@ class LoginController
 {
     public function __invoke(ServerRequestInterface $request)
     {
-        $body = json_decode((string) $request->getBody(), true);
-        $username = $body['username'] ?? '';
+        $body = json_decode((string) $request->getBody(), true) ?: [];
+        $email = $body['email'] ?? $body['user_email'] ?? '';
         $password = $body['password'] ?? '';
 
-        if (empty($username) || empty($password)) {
-            return ResponseLib::sendFail("Missing username or password", [], "E_VALIDATE")->withStatus(401);
+        if (empty($email) || empty($password)) {
+            return ResponseLib::sendFail("Missing email or password", [], "E_VALIDATE")->withStatus(400);
         }
 
         $userModel = new UserModel();
-        $user = $userModel->validateLogin($username, $password);
+        $user = $userModel->validateLogin($email, $password);
 
         if (!$user) {
             return ResponseLib::sendFail("Invalid credentials", [], "E_VALIDATE")->withStatus(401);
         }
 
-        // Gera JWT
         $payload = [
             'sub' => $user['user_id'],
-            'username' => $user['user_name'],
+            'email' => $user['user_email'],
+            'company_id' => $user['company_id'],
+            'role' => $user['user_role'],
             'iat' => time(),
-            'exp' => time() + 3600  // 1 hora
+            'exp' => time() + 3600
         ];
         $jwt = JWT::encode($payload, $_ENV['JWT_SECRET'], 'HS256');
 
-        return ResponseLib::sendOk(['token' => $jwt, 'user_id' => $user['user_id']]);
+        return ResponseLib::sendOk([
+            'token' => $jwt,
+            'user' => $user,
+        ]);
     }
 }   

+ 66 - 0
controllers/MeController.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Database;
+use Libs\ResponseLib;
+use Psr\Http\Message\ServerRequestInterface;
+
+class MeController
+{
+    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);
+        }
+
+        $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) {
+            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);
+    }
+}

+ 10 - 7
controllers/RegisterController.php

@@ -10,24 +10,27 @@ class RegisterController
 {
     public function __invoke(ServerRequestInterface $request)
     {
-        $body = json_decode((string) $request->getBody(), true);
-        $username = $body['username'] ?? '';
+        $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 (empty($username) || empty($password)) {
-            return ResponseLib::sendFail("Missing username or password", [], "E_VALIDATE")->withStatus(400);
+        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);
         }
 
-        // Validação básica (ex: comprimento mínimo)
         if (strlen($password) < 8) {
             return ResponseLib::sendFail("Password must be at least 8 characters", [], "E_VALIDATE")->withStatus(400);
         }
 
         $userModel = new UserModel();
-        $userData = $userModel->createUser($username, $password);
+        $userData = $userModel->createUser($companyId, $email, $password, $phone, $role, $name);
 
         if (!$userData) {
-            return ResponseLib::sendFail("Username already exists or creation failed", [], "E_VALIDATE")->withStatus(400);
+            return ResponseLib::sendFail("Email already exists, company not found, or creation failed", [], "E_VALIDATE")->withStatus(400);
         }
 
         return ResponseLib::sendOk($userData, "S_CREATED");

+ 26 - 0
libs/Database.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Libs;
+
+final class Database
+{
+    public static function pdo(): \PDO
+    {
+        $host = $_ENV['DB_HOST'] ?? '127.0.0.1';
+        $port = $_ENV['DB_PORT'] ?? '5432';
+        $name = $_ENV['DB_NAME'] ?? '';
+        $user = $_ENV['DB_USER'] ?? '';
+        $pass = $_ENV['DB_PASS'] ?? '';
+
+        if ($name === '') {
+            throw new \RuntimeException('DB_NAME is not configured.');
+        }
+
+        $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,
+        ]);
+    }
+}

+ 0 - 66
middlewares/HmacAuthMiddleware.php

@@ -1,66 +0,0 @@
-<?php
-
-namespace Middlewares;
-
-use Libs\ResponseLib;
-use Models\ApiUserModel;
-use Psr\Http\Message\ServerRequestInterface;
-use React\Http\Message\Response;
-
-class HmacAuthMiddleware
-{
-    private array $api_Key;
-
-    public function __construct()
-    {
-        // Instancia a model e carrega as chaves API
-        $apiUserModel = new ApiUserModel();
-        $this->api_Key = $apiUserModel->getApiKeys();
-    }
-
-    public function __invoke(ServerRequestInterface $request, callable $next)
-    {
-        // 1. Extrai headers
-        $signature = $request->getHeaderLine('x-signature');
-        $apiUser = $request->getHeaderLine('x-user');
-        $nonce = $request->getHeaderLine('x-nonce');
-
-        if (empty($signature) || empty($apiUser) || empty($nonce)) {
-            return ResponseLib::sendFail("Unauthorized: Missing signature or headers", [], "E_VALIDATE")->withStatus(401);
-        }
-
-        // 2. Verifica se nonce está dentro do intervalo
-        $currentTime = time();
-        if (abs($currentTime - (int) $nonce) > 2) {
-            return ResponseLib::sendFail("Unauthorized: Invalid or expired nonce", [], "E_VALIDATE")->withStatus(401);
-        }
-
-        // 3. Verifica se o usuário é válido
-        if (!isset($this->api_Key[$apiUser])) {
-            return ResponseLib::sendFail("Unauthorized: Invalid API User", [], "E_VALIDATE")->withStatus(401);
-        }
-
-        $apiKey = $this->api_Key[$apiUser]['user_apikey'];
-        $apiSecret = $this->api_Key[$apiUser]['user_apisecret'];
-        $secret = $apiKey . "::" . $apiSecret;
-
-        // 4. Monta mensagem para HMAC: <jsonBody>::<nonce>
-        $rawBody = (string) $request->getBody();
-        $message = $rawBody . "::" . $nonce;
-
-        // 5. Calcula assinatura esperada
-        $expectedSignature = hash_hmac('sha256', $message, $secret);
-
-        // 6. Verifica assinatura
-        if (!hash_equals($expectedSignature, $signature)) {
-            return ResponseLib::sendFail("Unauthorized: Signature mismatch", [], "E_VALIDATE")->withStatus(401);
-        }
-
-        // 7. Tudo certo, adiciona atributos ao request e segue
-        $request = $request
-            ->withAttribute('api_user', $apiUser)
-            ->withAttribute('api_user_id', $this->api_Key[$apiUser]['user_id']);
-
-        return $next($request);
-    }
-}

+ 10 - 10
middlewares/JwtAuthMiddleware.php

@@ -4,6 +4,7 @@ namespace Middlewares;
 
 use Firebase\JWT\JWT;
 use Firebase\JWT\Key;
+use Libs\Database;
 use Libs\ResponseLib;
 use Psr\Http\Message\ServerRequestInterface;
 use React\Http\Message\Response;
@@ -30,19 +31,16 @@ class JwtAuthMiddleware
         try {
             $decoded = JWT::decode($token, new Key($this->jwtSecret, 'HS256'));
             $userId = $decoded->sub ?? null;
-            $apiUser = $decoded->username ?? null;
+            $userEmail = $decoded->email ?? $decoded->username ?? null;
 
-            if (empty($userId) || empty($apiUser)) {
+            if (empty($userId) || empty($userEmail)) {
                 return ResponseLib::sendFail("Unauthorized: Invalid JWT claims", [], "E_VALIDATE")->withStatus(401);
             }
 
-            $dbFile = $_ENV['DB_FILE'] ?? 'bridge.db';
-            $dbPath = __DIR__ . '/../' . $dbFile;
-            $pdo = new \PDO("sqlite:" . $dbPath);
-            $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
+            $pdo = Database::pdo();
 
-            $stmt = $pdo->prepare("SELECT user_id FROM user WHERE user_id = :user_id AND user_name = :user_name AND user_flag = 'a'");
-            $stmt->execute(['user_id' => $userId, 'user_name' => $apiUser]);
+            $stmt = $pdo->prepare("SELECT user_id, user_email FROM \"user\" WHERE user_id = :user_id AND user_email = :user_email AND user_deleted_at = 'infinity'");
+            $stmt->execute(['user_id' => $userId, 'user_email' => mb_strtolower(trim($userEmail))]);
             $user = $stmt->fetch(\PDO::FETCH_ASSOC);
 
             if (!$user) {
@@ -50,8 +48,10 @@ class JwtAuthMiddleware
             }
 
             $request = $request
-                ->withAttribute('api_user', $apiUser)
-                ->withAttribute('api_user_id', $userId);
+                ->withAttribute('api_user', $user['user_email'])
+                ->withAttribute('api_user_id', $user['user_id'])
+                ->withAttribute('user_email', $user['user_email'])
+                ->withAttribute('user_id', $user['user_id']);
 
             return $next($request);
 

+ 414 - 16
migrations/migrations_v1.sql

@@ -1,16 +1,414 @@
-PRAGMA foreign_keys=ON;
-
-CREATE TABLE user (
-    user_id INTEGER PRIMARY KEY AUTOINCREMENT,
-    user_name TEXT NOT NULL,
-    user_flag TEXT NOT NULL,
-    user_password TEXT NOT NULL  -- Nova coluna para senha hasheada
-);
-
-CREATE TABLE api_key (
-    api_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
-    user_id INTEGER NOT NULL,
-    api_key_user TEXT NOT NULL,
-    api_key_secret TEXT NOT NULL,
-    FOREIGN KEY (user_id) REFERENCES user(user_id)
-);
+-- ----------------------------------------------------------------
+-- 1. CORE ENTITIES
+-- ----------------------------------------------------------------
+
+CREATE TABLE company (
+    company_id                         SERIAL PRIMARY KEY,
+    company_name                       VARCHAR(100) NOT NULL,
+    company_cnpj                       VARCHAR(14)  NOT NULL UNIQUE,
+    company_logo                       TEXT NOT NULL,
+    company_created_at                 TIMESTAMP NOT NULL DEFAULT NOW(),
+    company_deleted_at                 TIMESTAMP NOT NULL DEFAULT 'infinity'
+);
+
+CREATE TABLE "user" (
+    user_id                            SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
+    user_name                          VARCHAR(100) NOT NULL,
+    user_phone                         VARCHAR(20) NOT NULL,
+    user_email                         VARCHAR(100) NOT NULL UNIQUE,
+    user_role                          VARCHAR(10) NOT NULL,
+    user_password                      VARCHAR(255) NOT NULL,
+    user_created_at                    TIMESTAMP NOT NULL DEFAULT NOW(),
+    user_deleted_at                    TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_users_company FOREIGN KEY (company_id) REFERENCES company(company_id)
+);
+
+CREATE TABLE operator (
+    operator_id                        SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
+    operator_name                      VARCHAR(100) NOT NULL,
+    operator_initials                  VARCHAR(5) NOT NULL,
+    operator_email                     VARCHAR(100) NOT NULL,
+    operator_phone                     VARCHAR(20) NOT NULL,
+    operator_department                VARCHAR(20) NOT NULL,
+    operator_status                    VARCHAR(30) NOT NULL DEFAULT 'Disponível',
+    operator_available_for_escalation  BOOLEAN NOT NULL DEFAULT TRUE,
+    operator_created_at                TIMESTAMP NOT NULL DEFAULT NOW(),
+    operator_deleted_at                TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_operators_company FOREIGN KEY (company_id) REFERENCES company(company_id)
+);
+
+CREATE TABLE operator_channel (
+    operator_channel_id                SERIAL PRIMARY KEY,
+    operator_id                        INT NOT NULL,
+    operator_channel                   VARCHAR(20) NOT NULL,
+    operator_channel_created_at        TIMESTAMP NOT NULL DEFAULT NOW(),
+    operator_channel_deleted_at        TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_op_channel_operator FOREIGN KEY (operator_id) REFERENCES operator(operator_id)
+);
+
+CREATE TABLE sla_config (
+    sla_config_id                      SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
+    sla_config_department              VARCHAR(20) NOT NULL,
+    sla_config_response_hours          INT NOT NULL DEFAULT 2,
+    sla_config_resolution_hours        INT NOT NULL DEFAULT 24,
+    sla_config_updated_at              TIMESTAMP NOT NULL DEFAULT NOW(),
+    sla_config_deleted_at              TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_sla_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT uq_sla_dept UNIQUE (company_id, sla_config_department)
+);
+
+CREATE TABLE sku (
+    sku_id                             SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
+    sku_name                           VARCHAR(100) NOT NULL,
+    sku_value                          DECIMAL(12,2) NOT NULL,
+    sku_sold                           INT NOT NULL DEFAULT 0,
+    sku_line                           VARCHAR(50) NOT NULL,
+    sku_created_at                     TIMESTAMP NOT NULL DEFAULT NOW(),
+    sku_deleted_at                     TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_skus_company FOREIGN KEY (company_id) REFERENCES company(company_id)
+);
+
+CREATE TABLE integration (
+    integration_id                     SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
+    integration_provider               VARCHAR(30) NOT NULL,
+    integration_account_id             TEXT NOT NULL,
+    integration_external_account_id    TEXT NOT NULL,
+    integration_account_name           VARCHAR(100) NOT NULL,
+    integration_status                 VARCHAR(20) NOT NULL,
+    integration_access_token           TEXT NOT NULL,
+    integration_refresh_token          TEXT NOT NULL,
+    integration_is_connected           BOOLEAN NOT NULL DEFAULT TRUE,
+    integration_last_sync_at           TIMESTAMP NOT NULL,
+    integration_last_error             TEXT NOT NULL,
+    integration_created_at             TIMESTAMP NOT NULL DEFAULT NOW(),
+    integration_updated_at             TIMESTAMP NOT NULL DEFAULT NOW(),
+    integration_deleted_at             TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_integrations_company FOREIGN KEY (company_id) REFERENCES company(company_id)
+);
+
+
+-- ----------------------------------------------------------------
+-- 2. CLIENTS & CONVERSATIONS
+-- ----------------------------------------------------------------
+
+CREATE TABLE client (
+    client_id                          SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
+    client_provider_id                 TEXT NOT NULL,
+    client_phone                       VARCHAR(20) NOT NULL,
+    client_name                        VARCHAR(100) NOT NULL,
+    client_email                       VARCHAR(100) NOT NULL,
+    client_segment                     VARCHAR(100) NOT NULL,
+    client_is_registered               BOOLEAN NOT NULL DEFAULT FALSE,
+    client_created_at                  TIMESTAMP NOT NULL DEFAULT NOW(),
+    client_deleted_at                  TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_clients_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT uq_client_phone_company UNIQUE (company_id, client_phone)
+);
+
+CREATE TABLE conversation (
+    conversation_id                    SERIAL PRIMARY KEY,
+    company_id                         INT NOT NULL,
+    integration_id                     INT NOT NULL,
+    operator_id                        INT NOT NULL,
+    client_id                          INT NOT NULL,
+    conversation_external_id           TEXT NOT NULL,
+    conversation_provider_id           TEXT NOT NULL,
+    conversation_channel               VARCHAR(20) NOT NULL,
+    conversation_status                VARCHAR(20) NOT NULL DEFAULT 'open',
+    conversation_is_automated          BOOLEAN NOT NULL DEFAULT FALSE,
+    conversation_started_at            TIMESTAMP NOT NULL DEFAULT NOW(),
+    conversation_closed_at             TIMESTAMP NOT NULL,
+    conversation_sla_deadline          TIMESTAMP NOT NULL,
+    conversation_last_message_at       TIMESTAMP NOT NULL,
+    conversation_last_message_preview  TEXT NOT NULL,
+    conversation_last_message_from     VARCHAR(10) NOT NULL,
+    conversation_impact_value          DECIMAL(12,2) NOT NULL,
+    conversation_ticket_value          DECIMAL(12,2) NOT NULL,
+    conversation_conversion_chance     INT NOT NULL,
+    conversation_optimum_window        VARCHAR(20) NOT NULL,
+    conversation_deleted_at            TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_conv_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT fk_conv_integration FOREIGN KEY (integration_id) REFERENCES integration(integration_id),
+    CONSTRAINT fk_conv_operator FOREIGN KEY (operator_id) REFERENCES operator(operator_id),
+    CONSTRAINT fk_conv_client FOREIGN KEY (client_id) REFERENCES client(client_id)
+);
+
+CREATE TABLE message (
+    message_id                         SERIAL PRIMARY KEY,
+    conversation_id                    INT NOT NULL,
+    quoted_message_id                  INT NOT NULL,
+    message_external_id                TEXT NOT NULL,
+    message_provider_id                TEXT NOT NULL,
+    message_sender_provider_id         TEXT NOT NULL,
+    message_is_operator                BOOLEAN NOT NULL,
+    message_type                       VARCHAR(20) NOT NULL DEFAULT 'text',
+    message_content                    TEXT NOT NULL,
+    message_seen                       BOOLEAN NOT NULL,
+    message_delivered                  BOOLEAN NOT NULL,
+    message_edited                     BOOLEAN NOT NULL DEFAULT FALSE,
+    message_deleted                    BOOLEAN NOT NULL DEFAULT FALSE,
+    message_hidden                     BOOLEAN NOT NULL DEFAULT FALSE,
+    message_is_event                   BOOLEAN NOT NULL DEFAULT FALSE,
+    message_event_type                 INT NOT NULL,
+    message_sent_at                    TIMESTAMP NOT NULL DEFAULT NOW(),
+    message_deleted_at                 TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_msg_conversation FOREIGN KEY (conversation_id) REFERENCES conversation(conversation_id),
+    CONSTRAINT fk_quoted_message FOREIGN KEY (quoted_message_id) REFERENCES message(message_id)
+);
+
+CREATE TABLE message_attachment (
+    attachment_id                      SERIAL PRIMARY KEY,
+    message_id                         INT NOT NULL,
+    attachment_external_id             TEXT NOT NULL,
+    attachment_url                     TEXT NOT NULL,
+    attachment_type                    VARCHAR(50) NOT NULL,
+    attachment_mime_type               VARCHAR(100) NOT NULL,
+    attachment_file_name               VARCHAR(255) NOT NULL,
+    attachment_size                    BIGINT NOT NULL,
+    attachment_created_at              TIMESTAMP NOT NULL DEFAULT NOW(),
+    attachment_deleted_at              TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_attachment_message FOREIGN KEY (message_id) REFERENCES message(message_id)
+);
+
+CREATE TABLE message_reaction (
+    reaction_id                        SERIAL PRIMARY KEY,
+    message_id                         INT NOT NULL,
+    reaction_sender_provider_id        TEXT NOT NULL,
+    reaction_value                     VARCHAR(20) NOT NULL,
+    reaction_is_sender                 BOOLEAN NOT NULL DEFAULT FALSE,
+    reaction_created_at                TIMESTAMP NOT NULL DEFAULT NOW(),
+    reaction_deleted_at                TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_reaction_message FOREIGN KEY (message_id) REFERENCES message(message_id)
+);
+
+CREATE TABLE conversation_participant (
+    participant_id                     SERIAL PRIMARY KEY,
+    conversation_id                    INT NOT NULL,
+    participant_provider_id            TEXT NOT NULL,
+    participant_name                   VARCHAR(100) NOT NULL,
+    participant_is_admin               BOOLEAN NOT NULL DEFAULT FALSE,
+    participant_created_at             TIMESTAMP NOT NULL DEFAULT NOW(),
+    participant_deleted_at             TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_participant_conversation FOREIGN KEY (conversation_id) REFERENCES conversation(conversation_id)
+);
+
+CREATE TABLE webhook_event (
+    webhook_event_id                   SERIAL PRIMARY KEY,
+    integration_id                     INT NOT NULL,
+    webhook_event_type                 VARCHAR(50) NOT NULL,
+    webhook_event_external_id          TEXT NOT NULL,
+    webhook_event_payload              JSONB NOT NULL,
+    webhook_event_processed            BOOLEAN NOT NULL DEFAULT FALSE,
+    webhook_event_received_at          TIMESTAMP NOT NULL DEFAULT NOW(),
+    webhook_event_processed_at         TIMESTAMP NOT NULL,
+    webhook_event_deleted_at           TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_webhook_integration FOREIGN KEY (integration_id) REFERENCES integration(integration_id)
+);
+
+
+-- ----------------------------------------------------------------
+-- 3. AI-PROCESSED DATA
+-- ----------------------------------------------------------------
+
+CREATE TABLE conversation_analysis (
+    analysis_id                                SERIAL PRIMARY KEY,
+    conversation_id                            INT NOT NULL UNIQUE,
+    company_id                                 INT NOT NULL,
+    conversation_analysis_sentiment            VARCHAR(20) NOT NULL,
+    conversation_analysis_sentiment_score      NUMERIC(3,2) NOT NULL,
+    conversation_analysis_aspect               VARCHAR(50) NOT NULL,
+    conversation_analysis_sub_aspect           VARCHAR(100) NOT NULL,
+    conversation_analysis_analyzed_at          TIMESTAMP NOT NULL DEFAULT NOW(),
+    conversation_analysis_deleted_at           TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_analysis_conv FOREIGN KEY (conversation_id) REFERENCES conversation(conversation_id),
+    CONSTRAINT fk_analysis_company FOREIGN KEY (company_id) REFERENCES company(company_id)
+);
+
+CREATE TABLE aspect_feedback (
+    aspect_feedback_id                         SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
+    conversation_id                            INT NOT NULL,
+    aspect_feedback_aspect                     VARCHAR(50) NOT NULL,
+    aspect_feedback_sentiment                  VARCHAR(20) NOT NULL,
+    aspect_feedback_text                       TEXT NOT NULL,
+    aspect_feedback_created_at                 TIMESTAMP NOT NULL DEFAULT NOW(),
+    aspect_feedback_deleted_at                 TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_af_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT fk_af_conv FOREIGN KEY (conversation_id) REFERENCES conversation(conversation_id)
+);
+
+CREATE TABLE emotion_snapshot (
+    emotion_id                                 SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
+    emotion_snapshot_date                      DATE NOT NULL,
+    emotion_happiness                          NUMERIC(5,2) NOT NULL DEFAULT 0,
+    emotion_sadness                            NUMERIC(5,2) NOT NULL DEFAULT 0,
+    emotion_anger                              NUMERIC(5,2) NOT NULL DEFAULT 0,
+    emotion_fear                               NUMERIC(5,2) NOT NULL DEFAULT 0,
+    emotion_anticipation                       NUMERIC(5,2) NOT NULL DEFAULT 0,
+    emotion_confidence                         NUMERIC(5,2) NOT NULL DEFAULT 0,
+    emotion_surprise                           NUMERIC(5,2) NOT NULL DEFAULT 0,
+    emotion_total_analyzed                     INT NOT NULL DEFAULT 0,
+    CONSTRAINT fk_emotion_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT uq_emotion_date UNIQUE (company_id, emotion_snapshot_date)
+);
+
+CREATE TABLE public_opinion (
+    opinion_id                                 SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
+    conversation_id                            INT NOT NULL,
+    opinion_is_positive                        BOOLEAN NOT NULL,
+    opinion_classified_at                      TIMESTAMP NOT NULL DEFAULT NOW(),
+    opinion_deleted_at                         TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_opinion_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT fk_opinion_conv FOREIGN KEY (conversation_id) REFERENCES conversation(conversation_id)
+);
+
+CREATE TABLE alert (
+    alert_id                                   SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
+    client_id                                  INT NOT NULL,
+    alert_type                                 VARCHAR(20) NOT NULL,
+    alert_priority                             VARCHAR(10) NOT NULL,
+    alert_title                                TEXT NOT NULL,
+    alert_description                          TEXT NOT NULL,
+    alert_tips                                 TEXT NOT NULL,
+    alert_is_resolved                          BOOLEAN NOT NULL DEFAULT FALSE,
+    alert_created_at                           TIMESTAMP NOT NULL DEFAULT NOW(),
+    alert_deleted_at                           TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_alert_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT fk_alert_client FOREIGN KEY (client_id) REFERENCES client(client_id)
+);
+
+CREATE TABLE ai_action (
+    ai_action_id                               SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
+    ai_action_idea                             TEXT NOT NULL,
+    ai_action_is_accepted                      BOOLEAN NOT NULL,
+    ai_action_created_at                       TIMESTAMP NOT NULL DEFAULT NOW(),
+    ai_action_responded_at                     TIMESTAMP NOT NULL,
+    ai_action_deleted_at                       TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_ai_action_company FOREIGN KEY (company_id) REFERENCES company(company_id)
+);
+
+
+-- ----------------------------------------------------------------
+-- 4. PERSONAS & STRATEGIES
+-- ----------------------------------------------------------------
+
+CREATE TABLE persona (
+    persona_id                                 SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
+    persona_name                               VARCHAR(100) NOT NULL,
+    persona_type                               VARCHAR(50) NOT NULL DEFAULT 'O PERFIL',
+    persona_description                        TEXT NOT NULL,
+    persona_details                            TEXT NOT NULL,
+    persona_risk_level                         VARCHAR(10) NOT NULL DEFAULT 'Médio',
+    persona_churn_risk_pct                     NUMERIC(5,2) NOT NULL,
+    persona_expansion_potential                VARCHAR(20) NOT NULL,
+    persona_expansion_strategy                 TEXT NOT NULL,
+    persona_engagement_strategy                TEXT NOT NULL,
+    persona_created_at                         TIMESTAMP NOT NULL DEFAULT NOW(),
+    persona_deleted_at                         TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_personas_company FOREIGN KEY (company_id) REFERENCES company(company_id)
+);
+
+CREATE TABLE client_persona (
+    client_persona_id                          SERIAL PRIMARY KEY,
+    client_id                                  INT NOT NULL,
+    persona_id                                 INT NOT NULL,
+    client_persona_assigned_at                 TIMESTAMP NOT NULL DEFAULT NOW(),
+    client_persona_deleted_at                  TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_cp_client FOREIGN KEY (client_id) REFERENCES client(client_id),
+    CONSTRAINT fk_cp_persona FOREIGN KEY (persona_id) REFERENCES persona(persona_id)
+);
+
+CREATE TABLE best_action (
+    best_action_id                             SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
+    persona_id                                 INT NOT NULL,
+    best_action_type                           VARCHAR(20) NOT NULL,
+    best_action_idea                           TEXT NOT NULL,
+    best_action_created_at                     TIMESTAMP NOT NULL DEFAULT NOW(),
+    best_action_deleted_at                     TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_ba_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT fk_ba_persona FOREIGN KEY (persona_id) REFERENCES persona(persona_id)
+);
+
+
+-- ----------------------------------------------------------------
+-- 5. TIME-SERIES / SNAPSHOTS
+-- ----------------------------------------------------------------
+
+CREATE TABLE volume_snapshot (
+    volume_id                                  SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
+    volume_snapshot_date                       DATE NOT NULL,
+    volume_channel                             VARCHAR(20) NOT NULL,
+    volume_message_count                       INT NOT NULL DEFAULT 0,
+    volume_conversation_count                  INT NOT NULL DEFAULT 0,
+    CONSTRAINT fk_volume_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT uq_volume_date_channel UNIQUE (company_id, volume_snapshot_date, volume_channel)
+);
+
+CREATE TABLE sentiment_evolution (
+    evolution_id                               SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
+    evolution_snapshot_date                    DATE NOT NULL,
+    evolution_sentiment_score                  NUMERIC(3,2) NOT NULL,
+    CONSTRAINT fk_evo_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT uq_evo_date UNIQUE (company_id, evolution_snapshot_date)
+);
+
+CREATE TABLE playbooks_monitor (
+    playbook_id                                SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
+    playbook_snapshot_date                     DATE NOT NULL,
+    playbook_new_detected                      INT NOT NULL DEFAULT 0,
+    playbook_converted                         INT NOT NULL DEFAULT 0,
+    playbook_client_type                       VARCHAR(10) NOT NULL DEFAULT 'new',
+    CONSTRAINT fk_pb_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT uq_pb_date UNIQUE (company_id, playbook_snapshot_date)
+);
+
+CREATE TABLE operator_daily_stats (
+    stat_id                                    SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
+    operator_id                                INT NOT NULL,
+    operator_stat_date                         DATE NOT NULL,
+    operator_attendances_count                 INT NOT NULL DEFAULT 0,
+    operator_avg_response_seconds              INT NOT NULL DEFAULT 0,
+    operator_sla_compliance_pct                NUMERIC(5,2) NOT NULL DEFAULT 0,
+    operator_sales_amount                      DECIMAL(12,2) NOT NULL DEFAULT 0,
+    CONSTRAINT fk_stats_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT fk_stats_operator FOREIGN KEY (operator_id) REFERENCES operator(operator_id),
+    CONSTRAINT uq_stats_op_date UNIQUE (operator_id, operator_stat_date)
+);
+
+CREATE TABLE kpi_snapshot (
+    kpi_id                                     SERIAL PRIMARY KEY,
+    company_id                                 INT NOT NULL,
+    kpi_snapshot_date                          DATE NOT NULL,
+    kpi_current_sales                          DECIMAL(12,2) NOT NULL DEFAULT 0,
+    kpi_avg_ticket                             DECIMAL(12,2) NOT NULL DEFAULT 0,
+    kpi_lifetime_at_risk                       DECIMAL(12,2) NOT NULL DEFAULT 0,
+    kpi_critical_risk_clients                  INT NOT NULL DEFAULT 0,
+    kpi_sla_compliance_pct                     NUMERIC(5,2) NOT NULL DEFAULT 0,
+    kpi_churn_low_pct                          NUMERIC(5,2) NOT NULL DEFAULT 0,
+    kpi_churn_moderate_pct                     NUMERIC(5,2) NOT NULL DEFAULT 0,
+    kpi_churn_high_pct                         NUMERIC(5,2) NOT NULL DEFAULT 0,
+    kpi_churn_critical_pct                     NUMERIC(5,2) NOT NULL DEFAULT 0,
+    kpi_ltv_total                              DECIMAL(12,2) NOT NULL DEFAULT 0,
+    kpi_ltv_at_risk                            DECIMAL(12,2) NOT NULL DEFAULT 0,
+    kpi_total_registered_clients               INT NOT NULL DEFAULT 0,
+    kpi_total_active_operators                 INT NOT NULL DEFAULT 0,
+    kpi_general_emotion                        VARCHAR(20) NOT NULL,
+    CONSTRAINT fk_kpi_company FOREIGN KEY (company_id) REFERENCES company(company_id),
+    CONSTRAINT uq_kpi_date UNIQUE (company_id, kpi_snapshot_date)
+);

+ 0 - 58
models/ApiUserModel.php

@@ -1,58 +0,0 @@
-<?php
-
-namespace Models;
-
-class ApiUserModel
-{
-    private \PDO $pdo;
-    private array $api_Key = [];
-
-    public function __construct()
-    {
-        // Conecta ao DB usando variável do .env
-        $dbFile = $_ENV['DB_FILE'];
-        $dbPath = __DIR__ . '/../' . $dbFile;
-        $this->pdo = new \PDO("sqlite:" . $dbPath);
-        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
-
-        $this->loadApiKeys();
-    }
-
-    /**
-     * Carrega as chaves API dos usuários ativos do banco de dados.
-     */
-    private function loadApiKeys(): void
-    {
-        try {
-            $stmt = $this->pdo->query("SELECT user_id, user_name, api_key_user, api_key_secret FROM user NATURAL JOIN api_key WHERE user_flag = 'a'");
-            $users = $stmt->fetchAll(\PDO::FETCH_ASSOC);
-
-            foreach ($users as $user) {
-                $this->api_Key[$user['user_name']] = [
-                    'user_apikey' => $user['api_key_user'],
-                    'user_apisecret' => $user['api_key_secret'],
-                    'user_id' => $user['user_id']
-                ];
-            }
-        } catch (\PDOException $e) {
-            error_log("Erro ao carregar chaves API: " . $e->getMessage());
-        }
-    }
-
-    /**
-     * Retorna o array de chaves API carregadas.
-     *
-     * @return array
-     */
-    public function getApiKeys(): array
-    {
-        return $this->api_Key;
-    }
-
-    // Opcional: Método para recarregar as chaves se necessário
-    public function reloadApiKeys(): void
-    {
-        $this->api_Key = [];
-        $this->loadApiKeys();
-    }
-}

+ 42 - 28
models/UserModel.php

@@ -2,17 +2,15 @@
 
 namespace Models;
 
+use Libs\Database;
+
 class UserModel
 {
     private \PDO $pdo;
 
     public function __construct()
     {
-        // Conecta ao DB usando variável do .env
-        $dbFile = $_ENV['DB_FILE'];
-        $dbPath = __DIR__ . '/../' . $dbFile;
-        $this->pdo = new \PDO("sqlite:" . $dbPath);
-        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
+        $this->pdo = Database::pdo();
     }
 
     /**
@@ -22,14 +20,14 @@ class UserModel
      * @param string $password Plain-text password para verificar
      * @return array|null Dados do usuário (user_id, user_name, etc.) ou null se inválido
      */
-    public function validateLogin(string $username, string $password): ?array
+    public function validateLogin(string $email, string $password): ?array
     {
-        $stmt = $this->pdo->prepare("SELECT user_id, user_name, user_password FROM user WHERE user_name = :username AND user_flag = 'a'");
-        $stmt->execute(['username' => $username]);
+        $stmt = $this->pdo->prepare("SELECT user_id, company_id, user_name, user_phone, user_email, user_role, user_password FROM \"user\" WHERE user_email = :email AND user_deleted_at = 'infinity'");
+        $stmt->execute(['email' => mb_strtolower(trim($email))]);
         $user = $stmt->fetch(\PDO::FETCH_ASSOC);
 
         if ($user && password_verify($password, $user['user_password'])) {
-            unset($user['password']);  // Remove hash por segurança
+            unset($user['user_password']);
             return $user;
         }
 
@@ -44,38 +42,54 @@ class UserModel
      * @param string $flag Default 'a' para ativo
      * @return array|bool Dados do usuário criado (incluindo api_key) ou false em erro
      */
-    public function createUser(string $username, string $password, string $flag = 'a')
+    public function createUser(int $companyId, string $email, string $password, string $phone, string $role, ?string $name = null)
     {
-        // Verifica se username já existe
-        $stmt = $this->pdo->prepare("SELECT user_id FROM user WHERE user_name = :username");
-        $stmt->execute(['username' => $username]);
+        $normalizedEmail = mb_strtolower(trim($email));
+        $normalizedPhone = trim($phone);
+        $normalizedRole = trim($role);
+        $normalizedName = $name !== null ? trim($name) : null;
+
+        if ($normalizedName === '') {
+            $normalizedName = null;
+        }
+
+        $stmt = $this->pdo->prepare('SELECT user_id FROM "user" WHERE user_email = :email');
+        $stmt->execute(['email' => $normalizedEmail]);
         if ($stmt->fetch()) {
-            return false;  // Já existe
+            return false;
         }
 
         $hash = password_hash($password, PASSWORD_DEFAULT);
 
-        // Insere usuário
-        $stmt = $this->pdo->prepare("INSERT INTO user (user_name, user_flag, user_password) VALUES (:username, :flag, :hash)");
-        if (!$stmt->execute(['username' => $username, 'flag' => $flag, 'hash' => $hash])) {
+        try {
+            $stmt = $this->pdo->prepare("INSERT INTO \"user\" (company_id, user_name, user_phone, user_email, user_role, user_password) VALUES (:company_id, :user_name, :user_phone, :user_email, :user_role, :user_password) RETURNING user_id, company_id, user_name, user_phone, user_email, user_role, user_created_at");
+
+            $stmt->execute([
+                'company_id' => $companyId,
+                'user_name' => $normalizedName,
+                'user_phone' => $normalizedPhone,
+                'user_email' => $normalizedEmail,
+                'user_role' => $normalizedRole,
+                'user_password' => $hash,
+            ]);
+
+            $createdUser = $stmt->fetch(\PDO::FETCH_ASSOC);
+        } catch (\PDOException $e) {
             return false;
         }
 
-        $userId = $this->pdo->lastInsertId();
-
-        // Gera e insere chaves API (random para HMAC)
-        $apiKey = bin2hex(random_bytes(16));  // Ex: 32 chars hex
-        $apiSecret = bin2hex(random_bytes(32));  // Mais longo para secret
-        $stmt = $this->pdo->prepare("INSERT INTO api_key (user_id, api_key_user, api_key_secret) VALUES (:user_id, :api_key, :api_secret)");
-        if (!$stmt->execute(['user_id' => $userId, 'api_key' => $apiKey, 'api_secret' => $apiSecret])) {
+        if (!$createdUser) {
             return false;
         }
 
         return [
-            'user_id' => $userId,
-            'user_name' => $username,
-            'api_key_user' => $apiKey,
-            'api_key_secret' => $apiSecret  // Retorne para o usuário (apenas uma vez!)
+            'user_id' => (int) $createdUser['user_id'],
+            'company_id' => (int) $createdUser['company_id'],
+            'user_name' => $createdUser['user_name'],
+            'user_phone' => $createdUser['user_phone'],
+            'user_email' => $createdUser['user_email'],
+            'user_role' => $createdUser['user_role'],
+            'user_created_at' => $createdUser['user_created_at'],
         ];
     }
 }

+ 8 - 7
public/index.php

@@ -1,5 +1,8 @@
 <?php
 
+use FrameworkX\App;
+use Middlewares\JwtAuthMiddleware;
+
 require __DIR__ . '/../vendor/autoload.php';
 
 $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
@@ -18,16 +21,14 @@ if (class_exists(Dotenv\Dotenv::class) && file_exists(__DIR__ . '/../.env')) {
 
 error_reporting(E_ALL);
 
-use FrameworkX\App;
-use Middlewares\HmacAuthMiddleware;
-use Middlewares\JWTAuthMiddleware;
-
 $app = new App();
-$authHmac = new HmacAuthMiddleware();
-$authJwt = new JWTAuthMiddleware();
+$authJwt = new JwtAuthMiddleware();
 
-$app->get('/hmachelloworld', $authHmac,\Controllers\HelloController::class);
 $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->post('/login', \Controllers\LoginController::class);
 $app->post('/register', \Controllers\RegisterController::class);

Неке датотеке нису приказане због велике количине промена