🎫 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ção | Gerencia 3 abas de tickets: open, pending, closed |
| Componentes | TicketsListCustom, TicketsListGroup, AdminAuthenticationModal |
| Estado | tab, tabOpen, deleteStatus, showGroups, filters |
| Filtros | filas, 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ção | Renderiza lista de tickets individuais com updates via socket |
| Props | status, searchParam, selectedQueueIds, tags, users, showGroups |
| Hook | useReducer com reducer({LOAD_TICKETS, UPDATE_TICKET, DELETE_TICKET, RESET}) |
Socket Listeners
| ready | Conecta ao socket e emite joinTickets com status |
| company-{id}-ticket | Recebe eventos: update, updateUnread, delete |
| company-{id}-appMessage | Novo evento de mensagem → UPDATE_TICKET_UNREAD_MESSAGES |
| company-{id}-presence | Indicador de digitação → UPDATE_TICKET_PRESENCE |
| company-{id}-contact | Atualizaçã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ção | Renderiza lista de tickets em grupo (isGroup = true) |
| Diferença | Filtra apenas tickets.contact.isGroup === true |
| Socket | Mesma estrutura de listeners que TicketsListCustom |
Socket Context - Gerenciador de Conexão
SocketContext (Singleton Pattern)
src/context/Socket/SocketContext.js
| Padrão | Mantém um único socket por companyId (não cria novo a cada componente) |
| Função | Gerencia reconexão, refresh de joins, limpeza de listeners |
| Método | socketManager.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ção | Deleta um ticket e marca seu tracking como finalizado |
| Operação | await 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
| AuthContext | user, profile, permissions para validar acesso admin |
| SocketContext | socketManager.getSocket(companyId) |
| WhatsAppsContext | Obtém conectados para filtros |
Correção Implementada
🐛 Bug: Socket Disconnect on Component Remount
| Problema | socket.disconnect() matava conexão inteira para TODOS os componentes |
| Causa | SocketContext usa socket compartilhado (singleton), não novo por componente |
| Sintoma | Depois 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.js | Gerencia DeleteAllTickets + modal auth |
| TicketController.ts | Backend 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ção | Exibe lista de filas, permite CRUD básico |
| Componentes | QueueCard, QueueForm, QueueModal |
| Estado | Redux ou Context (gerencia lista de filas) |
Modal de Configuração de IA
src/components/QueueAIModal.js
| Função | Configura queuePrompt e aiFileMappings |
| Descrição | Permite 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ção | Adiciona/edita lista de arquivos com preview |
| Recuros | Upload com Multer, detecção de tipo (image/video/document) |
| Formulário | Formik + Yup para validação |
| Preview | Imagem/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ção | Valida signature da requisição |
| Fluxo | Cria 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ção | Renderiza página de configurações com múltiplas seções |
| Rota | /settings |
| Acesso | Admin apenas |
| Layout | Grid 2 colunas (responsivo) |
Componentes
Subcomponentes
| OptionsNew | Painel com toggles de configurações do sistema |
| SchedulesForm | Formulário para definir horários de funcionamento |
| FinanceiroManager | Gerenciador 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
| schedules | Array de horários por dia |
| company | Dados da empresa |
| loading | Estado de carregamento |
| currentUser | Usuário logado |
| settings | Configurações do sistema |
| schedulesEnabled | Flag para exibir seção de horários |
Validação e Segurança
| Acesso Admin | Apenas usuários admin podem acessar /settings |
| SuperUser Check | FinanceiroManager exibido apenas se user.super = true |
| Company ID | Validado 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)
| medias | Array de File objects - mídias selecionadas |
| onAddMore | Function - callback para adicionar mais mídias |
| onRemoveMedia | Function - callback para remover mídia por índice |
| onClear | Function - callback para limpar todas as mídias |
| onSend | Function - callback para enviar (recebe captionsMap) |
| loading | Boolean - estado de carregamento |
Estados Internos
| selectedIndex | Índice da mídia atualmente visualizada |
| thumbnailStartIndex | Índice inicial dos thumbnails visíveis |
| captions | Object {[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 Principal | maxHeight: 550px, minHeight: 300px, paddingTop: 20px |
| Thumbnails | w-16 h-16 (64px x 64px) |
| Botão Enviar | w-12 h-12 (48px x 48px) |
| Container Thumb | flex-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
| ticketId | ID do ticket/conversa atual |
| ticketStatus | Status do ticket (open/closed/pending) |
| ticket | Object com dados completos do ticket |
| onOpenContactDrawer | Callback para abrir drawer de contato |
| onCloseContactDrawer | Callback para fechar drawer |
| isContactDrawerOpen | Estado do drawer |
Estados Principais
| inputMessage | Texto da mensagem sendo digitada |
| selectedMedias | Array de mídias selecionadas para envio |
| showEmoji | Picker de emoji visível |
| showAttachmentMenu | Menu de anexos visível |
| recording | Estado de gravação de áudio |
| loading | Estado 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 Framework | Material-UI (vs Tailwind em MessageInputWorking) |
| Versão | Legado mantida para compatibilidade |
| MediaPreview | Usa MediaPreviewModern (atualizado recentemente) |
| Comportamento | Idêntico ao MessageInputWorking para mídias |
Props
| ticketStatus | Status do ticket (open/closed/pending) |
| ticketId | ID do ticket/conversa atual |
| ticket | Object com dados completos |
| onOpenContactDrawer | Callback 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
| url | string | URL do arquivo de áudio a ser reproduzido |
States Principais
| isPlaying | Indica se o áudio está em reprodução |
| currentTime | Tempo atual de reprodução (segundos) |
| duration | Duração total do arquivo (segundos) |
| audioRate | Velocidade de reprodução (0.5x, 1x, 1.5x, 2x) |
| volume | Volume atual (0-1) |
| isMuted | Estado de mute |
| isTranscribing | Indica se transcrição está em andamento |
| transcription | Texto da transcrição obtida |
| showTranscription | Controla 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 estado | Array de IDs de tickets fixados |
| Carregar na montagem | GET /pinnedtickets (restaura lista do servidor) |
| Toggle pin | POST /pinnedtickets e DELETE /pinnedtickets/:id |
| Check pinned | isPinned(ticketId) → boolean |
| Sincronização | Recarrega 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
| Hook | const { pinnedTicketIds, isPinned, togglePin } = usePinnedTickets() |
| Função | Gerencia estado centralizado de tickets fixados |
| Props | Passa togglePin e isPinned para TicketListItemCustom |
| Filtragem | Renderiza fixadas antes de não-fixadas |
TicketListItemCustom
| Props | { ticket, togglePin, isPinned } |
| Botão | Menu com opção Fixar/Desafixar |
| Ícone | PlusIcon (de heroicons/outline) |
| Cores | Azul ao fixar, Âmbar ao desafixar |
| Função | handleTogglePin() chama togglePin(ticket.id) |
Backend - Rotas e Controller
Endpoints
| GET /pinnedtickets | Retorna array de ticketIds fixados do usuário |
| POST /pinnedtickets | { ticketId } - Fixa um ticket |
| DELETE /pinnedtickets/:ticketId | Desfixa 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
| url | string | URL 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 Param | url (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) |
| ⚠️ Bloqueado por restrição (sem og:image) | |
| ⚠️ 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
| minWidth | 100px |
| maxWidth | 600px |
| Padding (lateral) | 12px |
| Padding (vertical) | 16px |
| Margin (vertical) | 12px (entre mensagens) |
| Raio de borda | 18px (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 Inline | Renderizado dentro do ContactDrawer como aba |
| Modo Drawer | Renderizado como overlay full drawer (standalone) |
| Estilos | Idênticos em ambos os modos |
Props
| open | boolean - Controla abertura do drawer |
| handleClose | function - Callback para fechar |
| ticketId | number - ID do ticket para buscar favoritos |
| inline | boolean - 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
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
| currentChat | Objeto da conversa selecionada (id, users, unreads) |
| messages | Array de mensagens do chat aberto |
| messagesPage | Número de página para paginação (infinite scroll) |
| tab | 0=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 /chats | Lista conversas do usuário com paginação |
| POST /chats | Cria nova conversa com participantes |
| GET /chats/:id/messages | Lista mensagens com paginação |
| POST /chats/:id/messages | Envia mensagem para conversa |
| POST /chats/:id/read | Marca como lida (zera unreads) |
| DELETE /chats/:id | Deleta 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