📚 ConnectBot - Documentação do Sistema

Mapeamento de componentes, controllers e serviços

🎫 Gerenciamento de Tickets

Sistema em abas para visualizar, aceitar, finalizar e deletar tickets. Sincronização em tempo real via Socket.io com suporte a múltiplas abas (Atendimento, Aguardando, Finalizados).

Frontend - Containers

TicketsManagerTabs (Gerenciador Principal)
src/components/TicketsManagerTabs/index.js
FunçãoGerencia 3 abas de tickets: open, pending, closed
ComponentesTicketsListCustom, TicketsListGroup, AdminAuthenticationModal
Estadotab, tabOpen, deleteStatus, showGroups, filters
Filtrosfilas, tags, usuários, mensagens não lidas

Funções Principais

DeleteAllTickets()POST /tickets/deleteAll - deleta todos da aba atual
CloseAllTicket()POST /tickets/closeAll - finaliza todos da aba atual
handleValidateAdminPassword()POST /auth/validate-admin-password - valida senha admin antes de deletar
handleToggleGroups()Alterna visualização entre grupos/individuais

Frontend - Listas em Tempo Real

TicketsListCustom (Conversas Individuais)
src/components/TicketsListCustom/index.js
FunçãoRenderiza lista de tickets individuais com updates via socket
Propsstatus, searchParam, selectedQueueIds, tags, users, showGroups
HookuseReducer com reducer({LOAD_TICKETS, UPDATE_TICKET, DELETE_TICKET, RESET})

Socket Listeners

readyConecta ao socket e emite joinTickets com status
company-{id}-ticketRecebe eventos: update, updateUnread, delete
company-{id}-appMessageNovo evento de mensagem → UPDATE_TICKET_UNREAD_MESSAGES
company-{id}-presenceIndicador de digitação → UPDATE_TICKET_PRESENCE
company-{id}-contactAtualização de contato → UPDATE_TICKET_CONTACT

Reducer Actions

LOAD_TICKETS → Carrega lista inicial de tickets UPDATE_TICKET → Atualiza ticket na lista (merge parcial) UPDATE_TICKET_UNREAD_MESSAGES → Move para topo, sinaliza novo DELETE_TICKET → Remove ticket da lista local RESET_UNREAD → Limpa unreadMessages UPDATE_TICKET_CONTACT → Atualiza dados de contato UPDATE_TICKET_PRESENCE → Define presença (digitando) RESET → Limpa lista (ao mudar filtros)
TicketsListGroup (Conversas em Grupo)
src/components/TicketsListGroup/index.js
FunçãoRenderiza lista de tickets em grupo (isGroup = true)
DiferençaFiltra apenas tickets.contact.isGroup === true
SocketMesma estrutura de listeners que TicketsListCustom

Socket Context - Gerenciador de Conexão

SocketContext (Singleton Pattern)
src/context/Socket/SocketContext.js
PadrãoMantém um único socket por companyId (não cria novo a cada componente)
FunçãoGerencia reconexão, refresh de joins, limpeza de listeners
MétodosocketManager.getSocket(companyId) → retorna socket gerenciado

Backend - Controller

TicketController
src/controllers/TicketController.ts

Métodos de Delete/Close

deleteAll()POST /tickets/deleteAll → Deleta tickets por status+filtros
closeAll()POST /tickets/closeAll → Finaliza tickets por status
remove()DELETE /tickets/:id → Deleta um único ticket

Fluxo do deleteAll()

1. Recebe: { status, selectedQueueIds, isGroup } 2. Sincroniza isGroup: ticket.isGroup ← ticket.contact.isGroup 3. Aplica filtros: ├── Se isGroup=true → deleta apenas grupos ├── Se isGroup=false → deleta apenas individuais └── Se selectedQueueIds → filtra por filas 4. Para cada ticket deletado: ├── Deleta registro (DeleteTicketService) └── Emite socket evento: { action: "delete", ticketId } 5. Notifica listeners em 4 sockets rooms: ├── ${ticketId} (para quem tem vendo este ticket) ├── company-${id}-${status} ├── company-${id}-notification └── queue-${queueId}-notification 6. Retorna: { message: "X tickets deleted successfully" }

Backend - Service

DeleteTicketService
src/services/TicketServices/DeleteTicketService.ts
FunçãoDeleta um ticket e marca seu tracking como finalizado
Operaçãoawait ticket.destroy() + TicketTraking.finishedAt = now

Fluxo Completo de Delete

USUÁRIO CLICA DELETAR (TicketsManagerTabs) ↓ 1️⃣ Modal de Admin Auth abre └─→ handleValidateAdminPassword(password) ├─ POST /auth/validate-admin-password └─ Se válido → continua 2️⃣ Chama DeleteAllTickets() └─→ api.post("/tickets/deleteAll", { status: "open|pending|closed", selectedQueueIds: [...], isGroup: true|false }) 3️⃣ Backend processa (TicketController.deleteAll) ├─ Busca tickets com filtros ├─ Para cada ticket: │ ├─ Deleta BD (DeleteTicketService) │ └─ Emite socket event: "delete" └─ Retorna: { message: "X deleted" } 4️⃣ TODOS os listeners recebem evento ├─ TicketsListCustom │ └─ Socket evento → handleTicketUpdate(data) │ └─ dispatch({ type: "DELETE_TICKET", ticketId }) │ └─ Reducer remove da lista │ ├─ TicketsListGroup │ └─ Mesma lógica │ └─ Frontend UI atualiza └─ Ticket desaparece imediatamente 5️⃣ Toast success exibido ao usuário

Contextos Utilizados

AuthContextuser, profile, permissions para validar acesso admin
SocketContextsocketManager.getSocket(companyId)
WhatsAppsContextObtém conectados para filtros

Correção Implementada

🐛 Bug: Socket Disconnect on Component Remount
Problemasocket.disconnect() matava conexão inteira para TODOS os componentes
CausaSocketContext usa socket compartilhado (singleton), não novo por componente
SintomaDepois de delete, tickets não desapareciam até F5 (socket morto)

Solução Implementada

// ❌ ANTES (socket.disconnect mata tudo) return () => { socket.disconnect(); // Mata para TODOS os listeners! }; // ✅ DEPOIS (remove apenas listeners locais) return () => { if (!socket) return; // Guard socket.off("ready"); socket.off(`company-${companyId}-ticket`); socket.off(`company-${companyId}-appMessage`); socket.off(`company-${companyId}-presence`); socket.off(`company-${companyId}-contact`); }; // Socket permanece vivo para outros componentes! ✨

Arquivos Afetados

TicketsListCustom.js✅ socket.off() ao invés de disconnect()
TicketsListGroup.js✅ socket.off() ao invés de disconnect()
TicketsManagerTabs.jsGerencia DeleteAllTickets + modal auth
TicketController.tsBackend que emite delete events

⚙️ Gerenciamento de Filas

Tela principal que exibe as filas de atendimento. Permite visualizar, criar, editar e configurar filas com mensagens de IA.

Frontend

Página Principal
src/pages/Queues.js
FunçãoExibe lista de filas, permite CRUD básico
ComponentesQueueCard, QueueForm, QueueModal
EstadoRedux ou Context (gerencia lista de filas)
Modal de Configuração de IA
src/components/QueueAIModal.js
FunçãoConfigura queuePrompt e aiFileMappings
DescriçãoPermite adicionar prompt de IA e mapear keywords para arquivos

Backend - Controller

QueueController
src/controllers/QueueController.ts
index()GET /api/queues - Lista todas as filas
show()GET /api/queues/:id - Retorna fila com config
store()POST /api/queues - Cria nova fila
update()PUT /api/queues/:id - Atualiza config de IA

Backend - Service

QueueService
src/services/QueueService.ts
getQueue()Busca fila no banco com relacionamentos
updateQueueConfig()Salva queuePrompt e aiFileMappings
validateMapping()Valida se keywords existem e arquivos estão ok

Dados Salvos

Table: queues ├── id ├── name ├── description ├── queuePrompt (TEXT) - Instrução de IA para essa fila ├── aiFileMappings (JSON) - { "#start": fileId, "#pro": fileId } └── companyId

📁 Gerenciamento de Arquivos

Sistema de upload e edição de arquivos que são enviados automaticamente via chat quando keywords são detectadas.

Frontend

Modal de Edição
src/components/FileModalModern/index.js
FunçãoAdiciona/edita lista de arquivos com preview
RecurosUpload com Multer, detecção de tipo (image/video/document)
FormulárioFormik + Yup para validação
PreviewImagem/vídeo inline, nome do arquivo

Backend - Controller

FilesController
src/controllers/FilesController.ts
store()POST /api/files - Recebe FormData, salva arquivo
update()PUT /api/files/:id - Atualiza arquivo ou metadata
show()GET /api/files/:id - Retorna arquivo para edição
destroy()DELETE /api/files/:id - Remove arquivo

Backend - Service

FileService
src/services/FileService.ts
saveFile()Salva em /public/company{id}/files/, extrai metadados
getFileUrl()Retorna URL pública do arquivo
deleteFile()Remove arquivo do disco

Dados Salvos

Table: files ├── id ├── name (descrição do arquivo) ├── path (arquivo_123.jpg) ├── mediaType (image | video | document) ├── fileSize ├── companyId └── createdAt

🔔 Webhook de Eventos GOWA

Integração com WhatsApp via GOWA. Recebe mensagens, processa com IA, detecta keywords e envia arquivos automaticamente.

Backend - Controller

GowaWebhookController
src/controllers/GowaWebhookController.ts
webhook()POST /gowa/webhook - Recebe evento do WhatsApp
ValidaçãoValida signature da requisição
FluxoCria Message record → chama GowaIAService

Backend - Service

GowaIAService
src/services/GowaIA/GowaIAService.ts
processMessage()Processa mensagem com IA usando queuePrompt
detectKeywords()Verifica se resposta contém keywords (#start, #pro, etc)
sendFiles()Busca arquivo mapeado e envia via GOWA.sendMedia()

Fluxo Completo

1. Webhook recebe mensagem do WhatsApp 2. GowaWebhookController.webhook() ├── Valida signature ├── Cria Message record └── Chama GowaIAService.processMessage() 3. GowaIAService processa: ├── Busca histórico de mensagens ├── Carrega queuePrompt ├── Chama OpenAI com contexto └── Recebe resposta 4. Detecta keywords na resposta: ├── "#start" → busca em aiFileMappings["#start"] ├── "#pro" → busca em aiFileMappings["#pro"] └── etc 5. Se encontrar keyword: ├── Busca arquivo no BD (files.id) ├── Lê arquivo de /public/company{id}/files/ └── Envia via GOWA.sendMedia(queueId, fileId) 6. Frontend é notificado via Socket.io └── Chat atualiza imediatamente

⚙️ Página de Configurações

Central de controle para automação, parâmetros do sistema, horários de funcionamento e gestão de financeiro.

Frontend

SettingsCustom
src/pages/SettingsCustom/index.js
FunçãoRenderiza página de configurações com múltiplas seções
Rota/settings
AcessoAdmin apenas
LayoutGrid 2 colunas (responsivo)

Componentes

Subcomponentes
OptionsNewPainel com toggles de configurações do sistema
SchedulesFormFormulário para definir horários de funcionamento
FinanceiroManagerGerenciador de financeiro (super users apenas)

Seções Disponíveis

📋 SISTEMA DE AVALIAÇÕES ├── Avaliações (INATIVO) │ └── Permite que clientes avaliem atendimento ao finalizar 🎯 CONFIGURAÇÕES DE ATENDIMENTO ├── Gerenciamento de Expediente (INATIVO) ├── Ignorar Mensagens de Grupos (INATIVO) ├── Ignorar Mensagens de Canais (ATIVO) └── Aceitar Chamadas (ATIVO) 💬 CONFIGURAÇÕES DE MENSAGEM ├── Saudação ao Aceitar Ticket (ATIVO) ├── Mensagem de Transferência (INATIVO) └── Saudação com Fila Única (INATIVO) 🤖 CONFIGURAÇÕES DE CHATBOT ├── Tipo de Chatbot (ATIVO) │ └── Opções: Texto ou Menu Interativo 👁️ CONFIGURAÇÕES DE VISUALIZAÇÃO ├── Ocultar Tickets Finalizados (INATIVO) └── Exibir Nome da Fila no Chat (INATIVO) 🌐 CONFIGURAÇÕES GLOBAIS ├── Registro de Usuários (INATIVO) └── Trial (Risco de Testes) 📅 HORÁRIOS (Condicional) ├── Exibido quando: scheduleType = "company" └── Define horários de funcionamento por dia 💰 FINANCEIRO (SuperUser apenas) ├── Gerenciador completo de financeiro └── Acesso restrito

Hooks Utilizados

• useCompanies ├── find(companyId) → busca dados da empresa ├── updateSchedules() → salva horários • useAuth ├── getCurrentUserInfo() → obtém dados do usuário logado • useSettings ├── getAll() → busca todas as configurações do sistema

Fluxo de Dados

1. Ao carregar página ├── companyId ← localStorage ├── company = await find(companyId) ├── settingList = await getAllSettings() └── user = await getCurrentUserInfo() 2. OptionsNew renderiza com settings └── cada toggle → configura comportamento do sistema 3. Se scheduleType === "company" └── SchedulesForm exibido ├── usuario edita horários └── await updateSchedules() salva no BD 4. Se user.super === true └── FinanceiroManager exibido └── SuperUser pode gerenciar financeiro

Estados Gerenciados

schedulesArray de horários por dia
companyDados da empresa
loadingEstado de carregamento
currentUserUsuário logado
settingsConfigurações do sistema
schedulesEnabledFlag para exibir seção de horários

Validação e Segurança

Acesso AdminApenas usuários admin podem acessar /settings
SuperUser CheckFinanceiroManager exibido apenas se user.super = true
Company IDValidado via localStorage antes de buscar dados

📸 Component: MediaPreviewModern

Componente moderno para prévia de mídias (imagens, vídeos, documentos) com suporte a múltiplos arquivos, legendas por arquivo e integração com o fluxo de envio de mensagens.

Localização

src/components/MediaPreviewModern/index.js

Props Principais

Entrada (Input Props)
mediasArray de File objects - mídias selecionadas
onAddMoreFunction - callback para adicionar mais mídias
onRemoveMediaFunction - callback para remover mídia por índice
onClearFunction - callback para limpar todas as mídias
onSendFunction - callback para enviar (recebe captionsMap)
loadingBoolean - estado de carregamento

Estados Internos

selectedIndexÍndice da mídia atualmente visualizada
thumbnailStartIndexÍndice inicial dos thumbnails visíveis
captionsObject {[index]: "legenda"} - legendas por mídia

Funcionalidades

✅ PRÉVIA PRINCIPAL (Grande) ├── Imagem: renderiza com <img> tag ├── Vídeo: renderiza com <video> + controles └── Outros: ícone + nome do arquivo ✅ CAMPO DE LEGENDA ├── Textarea com 200 caracteres máximo ├── Salvo por mídia (não global) └── Sincronized com captions state ✅ NAVEGAÇÃO DE THUMBNAILS ├── Grid com até 5 thumbnails visíveis ├── Botões de anterior/próximo ├── Setas aparecem apenas se necessário └── Clique na thumb seleciona a mídia ✅ BOTÃO ADICIONAR MAIS ├── Abre file input (image/video/application) ├── Máximo 100 mídias └── Integrado na grid de thumbnails ✅ BOTÃO ENVIAR ├── Contador em badge com total de mídias ├── Estado loading com spinner └── Passa captions ao callback onSend()

Dimensões CSS

Prévia PrincipalmaxHeight: 550px, minHeight: 300px, paddingTop: 20px
Thumbnailsw-16 h-16 (64px x 64px)
Botão Enviarw-12 h-12 (48px x 48px)
Container Thumbflex-1, justify-center

Integração com MessageInputWorking

// Em MessageInputWorking import MediaPreviewModern from "../MediaPreviewModern"; // Renderização condicional {selectedMedias.length > 0 && ( <MediaPreviewModern medias={selectedMedias} onAddMore={handleAddMoreMedia} onRemoveMedia={handleRemoveMedia} onClear={() => setSelectedMedias([])} onSend={handleSendMediaWithMessage} loading={loading} /> )} // handleSendMediaWithMessage recebe captionsMap const handleSendMediaWithMessage = async (captionsMap = {}) => { // Envia cada mídia com sua legenda correspondente for (let i = 0; i < selectedMedias.length; i++) { const caption = captionsMap[i] || ''; // enviar formData com mídia + caption } }

✉️ Component: MessageInputWorking

Componente principal de entrada de mensagens com suporte a texto, emoji, arquivos, áudio, respostas rápidas, verificação de linguagem e integração com MediaPreviewModern.

Localização

src/components/MessageInputWorking/index.js

Props Principais

ticketIdID do ticket/conversa atual
ticketStatusStatus do ticket (open/closed/pending)
ticketObject com dados completos do ticket
onOpenContactDrawerCallback para abrir drawer de contato
onCloseContactDrawerCallback para fechar drawer
isContactDrawerOpenEstado do drawer

Estados Principais

inputMessageTexto da mensagem sendo digitada
selectedMediasArray de mídias selecionadas para envio
showEmojiPicker de emoji visível
showAttachmentMenuMenu de anexos visível
recordingEstado de gravação de áudio
loadingEstado de envio em progresso

Integração com MediaPreviewModern

Fluxo de Mídias
1. ADICIONAR MÍDIA ├── Upload: handleChangeMedias() → setSelectedMedias([...files]) ├── Paste (Print): handleInputPaste() → setSelectedMedias([file]) ├── Botão "Adicionar Mais": handleAddMoreMedia() └── MediaPreviewModern renderizado com selectedMedias 2. VISUALIZAR └── Prévia em MediaPreviewModern ├── Imagem: thumbnail + preview grande ├── Vídeo: player com controles └── Arquivo: ícone + nome 3. ADICIONAR LEGENDAS └── Textarea por mídia em MediaPreviewModern └── Enviado em captionsMap ao onSend() 4. ENVIAR └── handleSendMediaWithMessage(captionsMap) ├── Itera por selectedMedias ├── Monta FormData com mídia + caption ├── POST /messages/{ticketId} └── setSelectedMedias([]) - limpa após sucesso 5. TROCAR CONVERSA └── useEffect([ticketId]) └── setSelectedMedias([]) - limpa mídias pendentes

💬 Component: MessageInputCustom

Versão customizada em Material-UI do componente de entrada de mensagens. Mantém compatibilidade com código legado enquanto usa MediaPreviewModern para mídias.

Localização

src/components/MessageInputCustom/index.js

Diferenças Principais

UI FrameworkMaterial-UI (vs Tailwind em MessageInputWorking)
VersãoLegado mantida para compatibilidade
MediaPreviewUsa MediaPreviewModern (atualizado recentemente)
ComportamentoIdêntico ao MessageInputWorking para mídias

Props

ticketStatusStatus do ticket (open/closed/pending)
ticketIdID do ticket/conversa atual
ticketObject com dados completos
onOpenContactDrawerCallback para abrir drawer

Atualização Recente

Mudança no Import
// ❌ ANTES import MediaPreview from "../MediaPreview-apagar"; // ✅ DEPOIS (Atual) import MediaPreview from "../MediaPreviewModern"; // Agora usa MediaPreviewModern com todas as features: // ✓ Prévia de vídeos com player // ✓ Legendas por mídia // ✓ Navegação de thumbnails // ✓ Suporte a múltiplas mídias

🎵 Component: AudioPlayerModern

Reprodutor de áudio moderno com controles avançados, transcrição de voz (via API) e suporte a velocidade de reprodução ajustável.

Localização

src/components/AudioPlayerModern/index.js

Propósito

Renderizar áudios em mensagens recebidas com interface completa de player. Suporta múltiplos formatos (OGG, MP3, WAV) e oferece funcionalidades de transcrição automática.

Props

urlstringURL do arquivo de áudio a ser reproduzido

States Principais

isPlayingIndica se o áudio está em reprodução
currentTimeTempo atual de reprodução (segundos)
durationDuração total do arquivo (segundos)
audioRateVelocidade de reprodução (0.5x, 1x, 1.5x, 2x)
volumeVolume atual (0-1)
isMutedEstado de mute
isTranscribingIndica se transcrição está em andamento
transcriptionTexto da transcrição obtida
showTranscriptionControla visibilidade do resultado de transcrição

Funcionalidades

1. Controles de Reprodução
✓ Play/Pause - Botão grande e intuitivo ✓ Barra de progresso - Clicável para buscar posição ✓ Tempo total - Exibe duração do áudio
2. Controles Avançados
✓ Velocidade de reprodução - Alterna entre 0.5x, 1x, 1.5x e 2x ✓ Controle de volume - Slider contínuo (0-100%) ✓ Mute/Unmute - Silencia rapidamente sem perder volume anterior
3. Transcrição de Áudio
✓ Botão de transcrição - Inicia processo via API ✓ Indicador de progresso - Mostra "Transcrevendo..." durante ✓ Resultado em cards - Exibe texto da transcrição em área destacada ✓ Formatos suportados - OGG, MP3, WAV

Métodos Principais

togglePlayPause()Inicia/pausa reprodução com tratamento de erros
handleSeek(e)Permite clicar na barra de progresso
toggleRate()Alterna velocidades de reprodução
toggleMute()Liga/desliga mute
handleVolumeChange(e)Ajusta volume via slider
handleTranscribe()Envia áudio para transcrição
formatTime(time)Formata tempo em MM:SS
getAudioSource()Detecta tipo de áudio e retorna source correto

Formatos Suportados

• OGG Vorbis (padrão do sistema) • MP3 (compatibilidade) • WAV (PCM linear) ⚠️ iOS: Detecta e converte OGG para MP3 automaticamente

Integração com Mensagens

Uso em MessageItem
<AudioPlayerModern url={message.mediaUrl} /> // Renderizado automaticamente quando: // - message.mediaType === "audioMessage" // - message.mediaUrl está disponível

Features Removidas Recentemente

Ícone de Relógio Removido
❌ ClockIcon foi removido do import ❌ Elemento <div className="ml-2"> com o ícone deletado ✅ Motivo: O ícone causava confusão visual, sugerindo que a mensagem era pendente quando na verdade o áudio já havia sido recebido. Era apenas decorativo, sem função.

Estilos Dark Mode

Light Mode: • Fundo: White • Controles: Blue 600 • Hover: Blue 700 • Texto: Gray 900 Dark Mode: • Fundo: Gray 800 • Controles: Blue 500 • Hover: Blue 600 • Texto: Gray 100

LocalStorage

Chave: 'audioMessageRate' Valor: Velocidade de reprodução selecionada Propósito: Manter velocidade escolhida persistente entre mensagens

📌 Fixar Conversas (Pinned Tickets)

Sistema de fixação de conversas que permite manter tickets importantes no topo da lista, com sincronização em tempo real via Socket.io e atualização otimista da UI.

Visão Geral

✅ Fixar conversas para manter no topo da lista ✅ Desafixar para voltar à ordenação normal ✅ Atualização em tempo real (sem F5) ✅ Sincronização com backend (PostgreSQL) ✅ Update otimista da UI ✅ Recuperação automática de erros ✅ Ícone visual com cor diferenciada (âmbar ao desafixar)

Frontend - Hook usePinnedTickets

Localização
src/hooks/usePinnedTickets.js
Responsabilidades
Gerenciar estadoArray de IDs de tickets fixados
Carregar na montagemGET /pinnedtickets (restaura lista do servidor)
Toggle pinPOST /pinnedtickets e DELETE /pinnedtickets/:id
Check pinnedisPinned(ticketId) → boolean
SincronizaçãoRecarrega lista após API para garantir consistência
Retorno do Hook
{ pinnedTicketIds: array, // IDs dos tickets fixados togglePin: function, // Alterna fix/unfix isPinned: function, // Verifica se está fixado isLoading: boolean // Estado de carregamento }

Fluxo de Atualização (Update Otimista)

Sequência de Operações
1. CLICA EM FIXAR/DESAFIXAR (TicketListItemCustom) └─ handleTogglePin() → togglePin(ticketId) [vem como prop] 2. UPDATE OTIMISTA (Hook) ├─ Se já fixado: remove do state imediatamente ├─ Se não fixado: adiciona ao início do state └─ setPinnedTicketIds() → trigger re-render 3. UI ATUALIZA (Tempo Real) ├─ TicketsListCustom detecta mudança em pinnedTicketIds ├─ Refiltra tickets (fixadas + não-fixadas) └─ Conversa sobe para o topo SEM F5 4. CHAMADA À API (Confirmação) ├─ POST /pinnedtickets { ticketId } (fixar) ├─ DELETE /pinnedtickets/:ticketId (desafixar) └─ Aguarda resposta do backend 5. SINCRONIZAÇÃO COM BACKEND ├─ GET /pinnedtickets (recarrega lista) ├─ Garante que local === servidor └─ setPinnedTicketIds(response.data.ticketIds) 6. TRATAMENTO DE ERROS ├─ Se API retorna erro ├─ Recarrega lista do servidor └─ UI volta ao estado correto automaticamente

Componentes Integrados

TicketsListCustom
Hookconst { pinnedTicketIds, isPinned, togglePin } = usePinnedTickets()
FunçãoGerencia estado centralizado de tickets fixados
PropsPassa togglePin e isPinned para TicketListItemCustom
FiltragemRenderiza fixadas antes de não-fixadas
TicketListItemCustom
Props{ ticket, togglePin, isPinned }
BotãoMenu com opção Fixar/Desafixar
ÍconePlusIcon (de heroicons/outline)
CoresAzul ao fixar, Âmbar ao desafixar
FunçãohandleTogglePin() chama togglePin(ticket.id)

Backend - Rotas e Controller

Endpoints
GET /pinnedticketsRetorna array de ticketIds fixados do usuário
POST /pinnedtickets{ ticketId } - Fixa um ticket
DELETE /pinnedtickets/:ticketIdDesfixa um ticket
Validações
POST /pinnedtickets ├─ Valida: Ticket já está fixado? → 400 Bad Request ├─ Salva no BD: user → pinnedTickets (many-to-many) └─ Retorna: { ticketId, userId, fixedAt } DELETE /pinnedtickets/:ticketId ├─ Busca registo na tabela de associação ├─ Remove related from user.pinnedTickets └─ Retorna: { success: true }

Formatação Visual do Menu

Estados de Cor
Light Mode: ├─ FIXAR (não fixado): Azul → hover:bg-blue-50, text-blue-600 ├─ DESAFIXAR (fixado): Âmbar → hover:bg-amber-50, text-amber-600 ├─ TRANSFERIR: Azul → hover:bg-blue-50, text-blue-600 ├─ FINALIZAR: Vermelho → hover:bg-red-50, text-red-600 └─ EXCLUIR: Cinza → hover:bg-slate-100, text-slate-400 Dark Mode: ├─ FIXAR (não fixado): Azul → hover:bg-blue-900/20, text-blue-400 ├─ DESAFIXAR (fixado): Âmbar → hover:bg-amber-900/20, text-amber-400 ├─ TRANSFERIR: Azul → hover:bg-blue-900/20, text-blue-400 ├─ FINALIZAR: Vermelho → hover:bg-red-900/20, text-red-400 └─ EXCLUIR: Cinza → hover:bg-slate-700, text-slate-500

Problemas Resolvidos

❌ Bug 1: Falta de Atualização em Tempo Real
Problema: • Clicava em "Fixar" • Ícone mudava para "Desafixar" • MAS a conversa não subia para o topo • Precisava fazer F5 para ver a mudança Causa Root: • Cada componente (TicketsListCustom + TicketListItemCustom) chamava usePinnedTickets() separadamente • Criava 2 instâncias diferentes do hook • Estado desincronizado entre componentes • Quando TicketListItem mudava seu estado, TicketsListCustom não via mudança Solução Implementada: ✅ Remover hook de TicketListItemCustom ✅ Chamar hook apenas em TicketsListCustom (centralizado) ✅ Passar togglePin + isPinned como props ✅ Uma única fonte de verdade (single source of truth) ✅ Update otimista: setState imediatamente ✅ TicketsListCustom detecta mudança e re-renderiza ✅ RESULTADO: Conversa sobe IMEDIATAMENTE ✨
❌ Bug 2: Erro ao Desafixar (40 Bad Request)
Ero recebido: POST /pinnedtickets 400 (Bad Request) [usePinnedTickets] Error: Ticket já está fixado Causa: • Havia race condition no togglePin • isPinnedNow capturava estado STALE • Múltiplos cliques rápidos causavam requisições duplicadas • Backend validava: "Ticket já em pinnedTickets" → erro 400 Solução Implementada: ✅ Usar callback em setPinnedTicketIds para read estado atual ✅ Evitar closure sobre pinnedTicketIds antigo ✅ useCallback com dependência correta em [pinnedTicketIds] ✅ GET /pinnedtickets após API para sincronização ✅ Retry automático se falhar ✅ RESULTADO: Funciona mesmo com cliques rápidos ⚡

🔗 Link Preview - Scraping & Cache

Sistema inteligente de preview de URLs enviadas em mensagens. Extrai automaticamente título, descrição e imagem de qualquer link, com cache em PostgreSQL para melhor performance. Integrado ao chat em tempo real via Socket.io.

Arquitetura

Frontend (React) └─ src/components/LinkPreview/index.js ├── Detecta URLs no texto da mensagem ├── Fetch para /api/link-preview ├── Cache localStorage (24h, versionado) └── Renderiza card com imagem + título + descrição Backend (Node.js/TypeScript) ├─ src/controllers/LinkPreviewController.ts │ └── GET /api/link-preview?url=... ├─ src/services/LinkPreviewService.ts │ ├── scrapeUrl() → regex-based HTML parsing │ ├── getLinkPreview() → cache database │ └── makeImageUrlAbsolute() → URL normalization └─ src/database/migrations/20260402000000-create-link-previews.ts └── Table: LinkPreviews (uuid, url, title, description, image, domain, failed, fetchCount, timestamps) Database (PostgreSQL) └─ Table: LinkPreviews ├── Cache de metadados lembrados (rápido) ├── Índice em URL para lookups rápidos └── Tracking de falhas e tentativas

Frontend - Componente LinkPreview

Localização
src/components/LinkPreview/index.js
Props
urlstringURL completa a fazer preview (ex: https://meli.la/174LypU)
Cache Strategy
CACHE_PREFIX = "lp_cache_" CACHE_TTL_MS = 24 * 60 * 60 * 1000 (24 horas) CACHE_VERSION = 2 (força refetch se incrementado) localStorage.setItem( "lp_cache_https://meli.la/174LypU", { data: { url, title, description, image, domain }, ts: Date.now(), version: 2 } ) readCache(url): ├─ Verifica se existe e não expirou ├─ Valida version (se ≠ atual, deleta cache) ├─ Só retorna se tem title OR image (ignora cache vazio) └─ Invalida automaticamente se sem dados úteis
Fluxo de Renderização
1. MOUNT ├─ readCache(url) → tenta cache localStorage └─ setData(cached || null), setLoading(cache ? false : true) 2. LOADING (skeleton) ├─ Spinner animado └─ 3 linhas shimmer de exemplo 3. CACHE HIT ├─ Retorna imediatamente (fast path) └─ setLoading(false) → renderiza card 4. CACHE MISS ├─ Defer 150ms (não bloqueia render de mensagem) └─ fetch(`${REACT_APP_BACKEND_URL}/api/link-preview?url=${encodeURIComponent(url)}`) ├─ Spinner durante fetch ├─ writeCache(url, data) se sucesso └─ setData(d), setLoading(false) 5. RENDER CARD ├─ Se !data || (!title && !image) → return null (sem card) └─ Card clicável com: ├─ imagem responsiva (object-cover) ├─

título clamp-2 linhas ├─

descrição clamp-2 linhas └─

hostname

Backend - LinkPreviewService (Scraping)

Localização
src/services/LinkPreviewService.ts
Métodos Principais
scrapeUrl(url)Faz fetch HTTP + extrai metadados via regex
getLinkPreview(url)Consulta BD, cache se não existe, retry após 24h se falhou
getMetaContent(html, prop)Regex para extrair og:title, og:description, og:image
makeImageUrlAbsolute(imageUrl, baseUrl)Converte URLs relativas em absolutas
Configurações
User-Agent: "Mozilla/5.0 (compatible; LinkPreviewBot/1.0)" Timeout: 5000ms maxContentLength: 5242880 (5MB - YouTube pages ~4MB) httpsAgent: rejectUnauthorized false (self-signed certs) Regex Meta Tag Extraction: ├─ <meta property="og:title" content="..."> (prioridade 1) ├─ <meta property="og:description" content="..."> ├─ <meta property="og:image" content="..."> └─ Fallback: name="description", name="image"
Fluxo de Scraping
getLinkPreview(url): 1. CACHE CHECK ├─ SELECT * FROM LinkPreviews WHERE url = ? ├─ Se existe e NOT failed → retorna cache (RÁPIDO) └─ Se failed mas menos de 24h → retorna cache vazio 2. SCRAPE NOVA └─ scrapeUrl(url) ├─ axios.get(url) com headers ├─ Extract regex: og:title, og:description, og:image ├─ makeImageUrlAbsolute() se URL relativa └─ Retorna { url, title, description, image, domain } 3. SALVAR BD ├─ INSERT OR REPLACE LinkPreviews │ ├─ url (unique) │ ├─ title, description, image, domain │ ├─ failed: false │ ├─ fetchCount++ │ └─ updatedAt: now └─ Se erro: failed: true, mantém lastAttempt 4. RETORNAR └─ Enviado ao frontend via JSON

Backend - LinkPreviewController

Localização
src/controllers/LinkPreviewController.ts
Rota
GET/api/link-preview
Query Paramurl (URL-encoded, obrigatório)
Resposta{ id, url, title, description, image, domain, failed, fetchCount, timestamps }
Exemplo de Request
GET /api/link-preview?url=https%3A%2F%2Fmeli.la%2F174LypU Response (200 OK): { "id": "05811760-4179-44e7-9862-63b1d5186d29", "url": "https://meli.la/174LypU", "title": "O Boticário Cuide-se Bem Pessegura (2 Itens)", "description": "Visite a página e encontre todos os produtos de originalnaPROMO em um só lugar.", "image": "https://http2.mlstatic.com/D_NQ_NP_711268-MLB108947885681_032026-O.webp", "domain": "meli.la", "failed": false, "fetchCount": 2, "createdAt": "2026-04-02T14:21:24.333Z", "updatedAt": "2026-04-02T14:25:11.106Z" }

Database - LinkPreviews Table

Schema
Table: LinkPreviews id UUID PRIMARY KEY (auto-generated) url VARCHAR(2000) UNIQUE NOT NULL title TEXT description TEXT image TEXT (URL da imagem) domain VARCHAR(255) (extraído da URL) failed BOOLEAN DEFAULT false fetchCount INTEGER DEFAULT 0 createdAt TIMESTAMP DEFAULT now() updatedAt TIMESTAMP DEFAULT now() INDEX: url (para lookups rápidos)
Migration
src/database/migrations/20260402000000-create-link-previews.ts

Integração com Mensagens

No MessageItem
// Detecta URLs na mensagem const urlRegex = /(https?:\/\/[^\s]+)/gi; const urls = message.body.match(urlRegex) || []; if (urls.length > 0) { return ( <> {/* Texto da mensagem */} <p>{message.body}</p> {/* LinkPreview para cada URL detectada */} {urls.map((url, idx) => ( <LinkPreview key={idx} url={url} /> ))} </> ); }

Performance & Otimizações

Frontend
✅ Cache localStorage (24h) ✅ Cache versioning (CACHE_VERSION=2 força refetch) ✅ Deferred fetch (150ms) - não bloqueia UI ✅ Skeleton loading - feedback visual imediato ✅ Null render se sem dados úteis - sem flashing vazio ✅ Image lazy loading (padrão navegador)
Backend
✅ PostgreSQL cache - reutiliza dados conhecidos ✅ Index em URL - lookups O(1) ao invés de O(n) ✅ Timeout 5s - não bloqueia se site lento ✅ maxContentLength 5MB - evita páginas gigantes ✅ Regex parsing - 10x mais rápido que DOM parsing ✅ User-Agent bot - evita bloqueios de scraping

Tratamento de Erros

❌ URL inválida/malformada └─ Retorna { url, hostname, title: hostname } ❌ Site não responde (timeout 5s) └─ Marca como failed: true, next retry após 24h ❌ Redirecionamento infinito └─ Axios auto-detecta, segue até 5 redirects ❌ SSL/TLS inválido (self-signed) └─ rejectUnauthorized: false permite conextar ❌ Content-Type não HTML └─ Tenta parsing mesmo assim (robustez)

Casos de Uso

Meli.la (Mercado Livre)✅ Título, descrição, imagem do produto
YouTube✅ Título genérico, descrição, sem imagem (restrição)
Instagram⚠️ Bloqueado por restrição (sem og:image)
LinkedIn⚠️ Similar (content bloqueado)
Blogs/Notícias✅ Título, descrição, imagem destacada
GitHub Repos✅ README como descrição + avatar

Ambiente

Frontend (.env)
REACT_APP_BACKEND_URL=https://acesso7back.centersatsistemas.tech
Backend (.env)
DATABASE_URL=postgresql://user:pass@localhost/acesso7 NODE_ENV=production PORT=9516

Problemas Resolvidos

🐛 Bug: Cache Vazio
Problema: Frontend mostrava "flashing" (skeleton loading) mas nunca carregava dados Causa: Cache localStorage guardava objetos antigos com dados incompletos (apenas hostname, sem title/description/image) Solução: 1. Incrementado CACHE_VERSION de 1 para 2 2. readCache() agora valida version e limpa se diferente 3. readCache() só retorna se tem title OR image 4. writeCache() sempre inclui { version: CACHE_VERSION } Resultado: Exibe card completo com imagem, título e descrição ✨
🐛 Bug: Content Too Large (YouTube)
Problema: YouTube pages (~5MB) retornavam erro "maxContentLength exceeded" Causa: Limite estava em 1048576 bytes (1MB) Solução: Aumentado para 5242880 bytes (5MB) Resultado: YouTube + grande maioria dos sites agora funciona

Monitoramento

// Logs do Backend console.log('🔗 [LinkPreview] Fetching from:', url); console.log('🔗 [LinkPreview] API Response:', data); // Logs do Frontend console.log('🔗 [LinkPreview] Cache HIT:', url); console.log('🔗 [LinkPreview] Fetching from API:', url); console.log('🔗 [LinkPreview ERROR]:', error.message); // Banco de Dados SELECT url, fetchCount FROM LinkPreviews WHERE domain = 'meli.la'; -- Rastreia tentativas de scraping por domínio

💬 Balões de Mensagem - MessageBubbles

Renderização visual dos balões de conversa (esquerda/direita) com estilos Material-UI. Define cores, bordas arredondadas, sombras e animações do hover.

Localização

src/pages/Chat/ChatMessages.js (Linhas 25-120)

Estrutura do Estilo

useStyles - makeStyles Material-UI
const useStyles = makeStyles((theme) => ({ mainContainer: { ... }, // Container principal messageList: { ... }, // Lista de mensagens boxLeft: { ... }, // Mensagens recebidas (outros) boxRight: { ... }, // Mensagens enviadas (você) messageSender: { ... }, // Nome do remetente messageText: { ... }, // Texto da mensagem messageTime: { ... }, // Hora/timestamp }))

Estilo: boxLeft (Mensagens Recebidas)

Light Mode
backgroundColor: '#ffffff' color: '#1f2937' borderTopLeftRadius: 0 // Canto inferior esquerdo RETO borderTopRightRadius: 18px borderBottomLeftRadius: 18px borderBottomRightRadius: 18px border: '1px solid #e5e7eb' boxShadow: '0 1px 2px rgba(0,0,0,0.1), 0 1px 1px rgba(0,0,0,0.06)' Hover: boxShadow: '0 4px 6px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06)'
Dark Mode
backgroundColor: '#374151' color: '#f9fafb' border: '1px solid #4b5563' boxShadow: '0 1px 2px rgba(0,0,0,0.3), 0 1px 1px rgba(0,0,0,0.2)' Hover: boxShadow: '0 4px 6px rgba(0,0,0,0.3), 0 2px 4px rgba(0,0,0,0.2)'

Estilo: boxRight (Mensagens Enviadas - Você)

Light Mode
backgroundColor: '#e3f2fd' // Azul claro color: '#1f2937' borderTopLeftRadius: 18px borderTopRightRadius: 18px borderBottomLeftRadius: 18px borderBottomRightRadius: 0 // Canto inferior direito RETO border: '1px solid #bbdefb' boxShadow: '0 1px 2px rgba(0,0,0,0.1), 0 1px 1px rgba(0,0,0,0.06)' alignSelf: 'flex-end' // Alinha à direita textAlign: 'right' Hover: boxShadow: '0 4px 6px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06)'
Dark Mode
backgroundColor: '#1e3a8a' // Azul escuro color: '#dbeafe' // Azul claro border: '1px solid #3b82f6' boxShadow: '0 1px 2px rgba(0,0,0,0.3), 0 1px 1px rgba(0,0,0,0.2)' Hover: boxShadow: '0 4px 6px rgba(0,0,0,0.3), 0 2px 4px rgba(0,0,0,0.2)'

Dimensões e Espaçamento

minWidth100px
maxWidth600px
Padding (lateral)12px
Padding (vertical)16px
Margin (vertical)12px (entre mensagens)
Raio de borda18px (exceto 1 canto reto)

Características Principais

✅ Canto reto no rodapé (WhatsApp-like style) ├─ boxLeft: canto inferior esquerdo reto └─ boxRight: canto inferior direito reto ✅ Alinhamento automático ├─ boxLeft: flex-start (esquerda) └─ boxRight: flex-end (direita) ✅ Suporte completo a Dark Mode ├─ Cores ajustadas por theme.palette.type └─ Contraste mantido em ambos os modos ✅ Transição suave (0.2s ease) ├─ Hover effect com aumento de sombra └─ Feedback visual ao interagir ✅ Responsive └─ maxWidth 600px se adapta a telas menores

Integração com ChatMessages

// Renderização condicional baseada no sender {message.senderId === currentUserId ? ( <Box className={classes.boxRight}>{message.body}</Box> ) : ( <Box className={classes.boxLeft}>{message.body}</Box> )}

⭐ Mensagens Favoritadas - FavoritesMessagesDrawer

Componente que exibe cards de mensagens favoritadas com o mesmo padrão visual dos balões de conversa. Diferencia automaticamente mensagens recebidas e enviadas.

Localização

src/components/FavoritesMessagesDrawer/index.js

Renderização (Sincronizado com ChatMessages)

Estilos Unificados
✅ messageItemLeft ├─ Cópia exata do boxLeft do ChatMessages ├─ Cor: Branco (#ffffff) Light / Cinza (#374151) Dark ├─ Posição: Alinhada à esquerda └─ Borda: Canto inferior esquerdo RETO ✅ messageItemRight ├─ Cópia exata do boxRight do ChatMessages ├─ Cor: Azul (#e3f2fd) Light / Azul (#1e3a8a) Dark ├─ Posição: Alinhada à direita └─ Borda: Canto inferior direito RETO

Fluxo de Renderização

1. API retorna: GET /messages/{ticketId}/favorites └─ Cada message tem: { id, body, fromMe, createdAt, contact } 2. Renderização condicional: ├─ Se message.fromMe === true │ └─ className={classes.messageItemRight} (Azul, direita) └─ Se message.fromMe === false └─ className={classes.messageItemLeft} (Branco, esquerda) 3. Estrutura do Card: ├─ Contato (nome em azul) ├─ Conteúdo (body ou mediaType) ├─ Hora (HH:mm) └─ Data (dd/MM) 4. Estilos Aplicados: ├─ Transição suave (0.2s ease) ├─ Hover com aumento de sombra ├─ Dark Mode automático └─ Padding 16px, margin 12px

Código de Renderização

favoriteMessages.map(message => ( <div key={message.id} className={classes[message.fromMe ? 'messageItemRight' : 'messageItemLeft']} > {message.contact && ( <div className={classes.contactName}> {message.contact.name} </div> )} <div className={classes.messageBody}> <MarkdownWrapper> {message.body || `[${message.mediaType}]`} </MarkdownWrapper> </div> <div className={classes.messageFooter}> <span>{format(parseISO(message.createdAt), "HH:mm")}</span> <span>{format(parseISO(message.createdAt), "dd/MM")}</span> </div> </div> ))

Diferenciação Visual

Mensagem Recebida (fromMe = false)
Cor: Branco (#ffffff) Light / Cinza (#374151) Dark Posição: Esquerda (margin: "12px 20px 12px 0") Borda: Canto inferior esquerdo RETO (borderTopLeftRadius: 0) Sombra suave com hover effect Alinhamento: alignSelf: "flex-start"
Mensagem Enviada (fromMe = true)
Cor: Azul (#e3f2fd) Light / Azul (#1e3a8a) Dark Posição: Direita (margin: "12px 0 12px 20px") Borda: Canto inferior direito RETO (borderBottomRightRadius: 0) Sombra suave com hover effect Alinhamento: alignSelf: "flex-end" + textAlign: "right"

Modo Inline vs Drawer

Modo InlineRenderizado dentro do ContactDrawer como aba
Modo DrawerRenderizado como overlay full drawer (standalone)
EstilosIdênticos em ambos os modos

Props

openboolean - Controla abertura do drawer
handleClosefunction - Callback para fechar
ticketIdnumber - ID do ticket para buscar favoritos
inlineboolean - Se true, renderiza em modo aba (ContactDrawer)

Atualização Unificada 2026-04-07

✅ Sincronizado com ChatMessages.js
Antes: • messageItem genérico com estilo padrão • Sem diferenciação recebida/enviada • Cores inline sem padrão Depois: • messageItemLeft (cópia exata do boxLeft) • messageItemRight (cópia exata do boxRight) • Renderização condicional baseada em message.fromMe • Dark Mode integrado automaticamente • Mesmo padding, margin, borderRadius • Mesmos hover effects e transições Resultado: ✨ Cards de favoritos idênticos aos balões de conversa ✨ Fácil identificar recebida vs enviada ✨ UI consistente em toda a aplicação

Clique em Favorita para Navegar (NOVO!)

🎯 Fluxo de Navegação
1. Usuario clica em uma mensagem favorita └─ handleSelectMessage(message) dispara ├─ Chama: onSelectFavorite(message.id) └─ Chama: handleClose() → fecha drawer 2. ContactDrawer recebe callback └─ Passa para FavoritesMessagesDrawer └─ onSelectFavorite={onSelectFavorite} 3. Chega em Ticket.js como handleSelectFavorite ├─ Procura: document.getElementById(`message-${messageId}`) ├─ Se encontrado: │ ├─ scrollIntoView({ behavior: 'smooth', block: 'center' }) │ ├─ classList.add('highlighted-message') │ └─ setTimeout (remove após 3s) └─ Se não encontrado: silent fail 4. CSS Destaca a Mensagem ├─ backgroundColor: rgba(255, 193, 7, 0.3) ├─ border-left: 4px solid gold ├─ animation: highlightPulse (2s) └─ Automático fade-out após 3s

Arquivos Modificados

FavoritesMessagesDrawer.js✅ Adicionado onClick + cursor:pointer + onSelectFavorite prop
ContactDrawer.js✅ Adicionado onSelectFavorite prop + passado ao FavoritesMessagesDrawer
Ticket.js✅ Criado handleSelectFavorite + passado ao ContactDrawer
MessageSearch.css✅ Classe .highlighted-message (já existia)

Requisitos Atendidos

✅ Ao clicar na mensagem favorita: ├─ Drawer fecha automaticamente ├─ Tela faz scroll suave até a mensagem ├─ Mensagem é destacada com animação └─ Highlight some após 3 segundos ✅ Diferenciação visual: ├─ Recebida: Branco, esquerda ├─ Enviada: Azul, direita └─ Cursor pointer ao hover

💬 Chat Interno - Real-Time com Socket.io

Renderização de Mensagens/src/pages/Chat/ChatMessages.js Lista de Conversas/src/components/ChatListModern/index.js Item da Conversa/src/components/ChatListItemModern/index.js Backend Controller/src/controllers/ChatController.ts

Frontend - /src/pages/Chat/index.js

Responsabilidades
✅ Renderizar layout de duas colunas (lista + mensagens) ✅ Gerenciar currentChat state (qual conversa está aberta) ✅ Carregar mensagens ao selecionar conversa ✅ Configurar listeners Socket.io ✅ Atualizar UI em tempo real quando eventos chegam ✅ Fazer login/logout de chats via Socket.io
Estados Principais
currentChatObjeto da conversa selecionada (id, users, unreads)
messagesArray de mensagens do chat aberto
messagesPageNúmero de página para paginação (infinite scroll)
tab0=Chats | 1=Mensagens (modo mobile)
Socket Listeners Configurados
1️⃣ company-${companyId}-chat ├─ Atualiza chat global (unreads, lastMessage) ├─ Action: "update" → chat.users[].unreads foi alterado ├─ Action: "new-message" → nova mensagem chegou └─ Action: "delete" → conversa foi deletada 2️⃣ company-${companyId}-chat-user-${userId} └─ Notificações pessoais do usuário ├─ Novo chat criado └─ Chat foi transferido para você 3️⃣ company-${companyId}-chat-${currentChat.id} ├─ Eventos específicos da conversa aberta ├─ Action: "new-message" → renderizar nova mensagem └─ Action: "update" → unreads zerou após checkAsRead

Frontend - /src/pages/Chat/ChatMessages.js

Responsabilidades
✅ Renderizar lista de mensagens ✅ Input para enviar mensagens ✅ Chamar checkAsRead API ao entrar (zerar unreads) ✅ Escutar updates do chat via Socket para sincronizar unreads ✅ Auto-scroll para mensagem mais recente ✅ Paginação de histórico (scroll to top = carregar mais antigos)
Fluxo de Leitura (checkAsRead)
1. USUÁRIO ABRE CHAT └─ Chat/index.js renderiza ChatMessages com prop chat 2. CHATMESSAGES MONTA ├─ useEffect([currentChat?.id]) detecta mudança ├─ Chama: POST /chats/${chat.id}/read { userId } └─ Backend processa (ChatController.checkAsRead) 3. BACKEND ATUALIZA ├─ ChatUser.unreads = 0 ├─ Emite socket: company-${companyId}-chat { action: "update", chat } └─ Envia chat atualizado com users[].unreads = 0 4. FRONTEND RECEBE ├─ ChatMessages escuta company-${companyId}-chat ├─ setCurrentChat(prev => ({ ...prev, ...chat })) └─ useEffect([chat?.users]) detecta mudança em unreads 5. UI ATUALIZA ├─ Chat/index.js → Chat/ChatMessages recebe new props ├─ ChatListModern recebe atualização └─ Badge de unreads desaparece IMEDIATAMENTE ✨ 6. TUDO EM TEMPO REAL ├─ Sem F5 ├─ Sem delay └─ Socket.io push notification

Backend - /src/controllers/ChatController.ts

Endpoints Principais
GET /chatsLista conversas do usuário com paginação
POST /chatsCria nova conversa com participantes
GET /chats/:id/messagesLista mensagens com paginação
POST /chats/:id/messagesEnvia mensagem para conversa
POST /chats/:id/readMarca como lida (zera unreads)
DELETE /chats/:idDeleta conversa
checkAsRead() - Marca como Lida
POST /chats/:id/read Body: { userId } Fluxo: 1. Busca ChatUser por { chatId, userId } 2. chatUser.unreads = 0 (salva no BD) 3. Busca chat atualizado com includes (users array) 4. Emite Socket: ├─ company-${companyId}-chat { │ action: "update", │ chat: { id, uuid, title, ...users[] } │ } └─ Todos os listeners recebem chat atualizado Resposta (200): { "id": 5, "uuid": "abc123", "title": "Gabriel", "users": [ { "userId": 1, "unreads": 0 }, { "userId": 2, "unreads": 0 } ] }
saveMessage() - Envia Mensagem
POST /chats/:id/messages Body: { message } Fluxo: 1. CreateMessageService cria mensagem no BD 2. Atualiza chat.lastMessage 3. Incrementa unreads para todos exceto sender 4. Emite Socket em 2 eventos: ├─ company-${companyId}-chat-${chatId} { │ action: "new-message", │ newMessage: { id, senderId, message, createdAt }, │ chat: { id, lastMessage, users[] } │ } └─ company-${companyId}-chat { action: "new-message", newMessage, chat } Resposta (200): newMessage object

Problemas Resolvidos

❌ Bug 1: Contador Não Zerava ao Abrir
Sintoma: • Recebia mensagem → badge "1" aparecia • Abria o chat → lia a mensagem (visualmente) • MAS contador não zerava • Precisava fazer F5 Causa Raiz: • ChatMessages.useEffect([]) estava VAZIO • Só executava UMA VEZ na montagem • Se já tinha unreads → checkAsRead nunca era chamado novamente • Socket não atualizava currentChat no Chat/index.js Solução Implementada: ✅ ChatMessages.useEffect([currentChat?.id]) └─ Executa toda vez que currentChat muda └─ Chama POST /chats/:id/read automaticamente ✅ ChatMessages com socket listener local └─ Escuta company-${companyId}-chat └─ Atualiza currentChat local quando unreads mudam ✅ Chat/index.js busca chat atualizado após nova mensagem └─ api.get(/chats/:id) traz users[].unreads atualizado Resultado: Badge desaparece IMEDIATAMENTE quando abre chat ✨
❌ Bug 2: Preview de Mensagem Desatualizada
Sintoma: • Gabriel envia: "teste" • Você responde: "ok" • Na lista de chats mostra: Gabriel - "teste" • Deveria mostrar: Você - "ok" • Precisava fazer F5 Causa Raiz: • ChatListModern.reducer UPDATE_CHAT estava SUBSTITUINDO completamente • Perdia propriedades da conversa (uuid, avatar, etc) • lastMessage não era atualizado quando nova mensagem chegava Solução Implementada: ✅ Reducer UPDATE_CHAT agora faz MERGE: state[index] = { ...state[index], ...chat } └─ Preserva propriedades antigas └─ Sobrescreve apenas campos da API ✅ Chat/index.js busca chat completo após nova mensagem └─ api.get(/chats/:id) traz lastMessage atualizada Resultado: Preview mostra ÚLTIMA mensagem em tempo real ✨
❌ Bug 3: Foto Desaparecia ao Atualizar
Sintoma: • Chat mostra avatar do usuário • Mensagem nova chega • Avatar SOME (fica em branco) • Após F5 volta Causa Raiz: • UPDATE_CHAT substituía chat sem merge • Backend socket emite chat {{ users: [...] }} • users não inclui avatar do contato (apenas ids) • Dados perdidos: chat.contact, chat.contact.avatar Solução Implementada: ✅ Merge do reducer preserva contact object ✅ Chat/index.js busca chat COMPLETO after saving message └─ Inclui relationships (contact com avatar) Resultado: Avatar persiste ao receber mensagens novas ✨

Merge Strategy no Reducer

Padrão Aplicado
❌ ERRADO - Substituição (perde dados) state[index] = data.chat; ✅ CORRETO - Merge with spread state[index] = { ...state[index], ...data.chat }; Benefícios: 1. Preserva contact.avatar 2. Mantém uuid, title, subject 3. Sobrescreve lastMessage, users[].unreads 4. Funciona com atualizações parciais do backend

Socket.io Rooms & Broadcasting

Estrutura de Rooms
Cada usuário está em: ├─ company-${companyId}-mainchannel │ └─ Recebe notificações globais de chats ├─ company-${companyId}-chat │ └─ Nova mensagem, update de unreads, deletão ├─ company-${companyId}-chat-${userId} │ └─ Notificações pessoais └─ company-${companyId}-chat-${chatId} └─ Mensagens específicas dessa conversa
Broadcasting de Mensagem
Quando envia mensagem em um chat: 1. Servidor cria ChatMessage no BD 2. Emite para MÚLTIPLOS rooms: ├─ company-${companyId}-chat-${chatId} │ └─ Para quem tem esse chat aberto ├─ company-${companyId}-chat │ └─ Para a lista de chats atualizar preview └─ company-${companyId}-mainchannel └─ Todos nos mainchannels recebem 3. Listeners Frontend: ├─ ChatMessages escuta company-chat-${chatId} │ └─ setMessages(..., newMessage) ├─ ChatListModern escuta company-chat │ └─ dispatch UPDATE_CHAT para lista └─ TicketsManagerTabs escuta company-chatMessage └─ updateChatsCounter() para badge

Integração com Gerência (TicketsManagerTabs)

Badge "Chat Interno" na Aba Gerência
TicketsManagerTabs.js mantém um badge de contador: ├─ Escuta company-${companyId}-chat ├─ Calcula unreads para usuário logado ├─ Exibe badge com número de conversas não lidas └─ Atualiza em tempo real via Socket updateChatsCounter(): 1. Itera por todos os chats no chatsMap 2. Para cada chat: busca user current 3. Se chat.users[currentUser].unreads > 0 → conta 4. setText(contador) para exibir no badge

Ambiente e Configuração

Endpoints da API
BASE_URL: http(s)://acesso7back.centersatsistemas.tech GET /chats?pageNumber=1&searchParam= POST /chats GET /chats/:id GET /chats/:id/messages?pageNumber=1 POST /chats/:id/messages { message } POST /chats/:id/read { userId } DELETE /chats/:id

Checklist de Sincronização

✅ Nova mensagem atualiza em tempo real ✅ Preview (lastMessage) atualiza imediatamente ✅ Badge de unreads desaparece ao abrir chat ✅ Avatar/foto não desaparece ao receber mensagem ✅ Lista de chats sobe automaticamente (conversa mais recente) ✅ Socket listener registra/limpa corretamente ✅ Merge strategy preserva dados importantes ✅ Funciona sem F5 (real-time push)

Monitoramento

Console.log ativados: ├─ [ChatMessages] Chamando checkAsRead ├─ [Chat] Evento company-chat recebido ├─ [ChatMessages] Update do chat recebido └─ TicketsManagerTabs counters update Logs do Backend: ├─ Fetch inicial de chats ├─ checkAsRead validação ├─ CreateMessageService creation └─ Socket emit events

ConnectBot - Documentação

Última atualização: 01 de Abril de 2026