/** * Rotas de Chats — API REST para a inbox do frontend. * * Endpoints: * GET /api/chats — Lista chats (com filtros e último msg) * GET /api/chats/search/messages — Busca full-text em mensagens * GET /api/chats/stats/overview — Contagens para o dashboard * GET /api/chats/:id — Detalhe de um chat * POST /api/chats/:id/read — Zera unread_count * PATCH /api/chats/:id/archive — Arquiva/desarquiva * PATCH /api/chats/:id/pin — Fixa/desfixa * DELETE /api/chats/:id — Remove chat e mensagens * GET /api/chats/:id/messages — Histórico paginado por cursor * GET /api/chats/:id/protocol — Protocolos do chat * POST /api/chats/:id/protocol — Abre novo protocolo * PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo * * RESOLUÇÃO DE NOME (displayName): * - Grupos: usa Chat.name (subject do grupo, sincronizado por groups.upsert) * - Individuais: Contact.name > Contact.verifiedName > Contact.notify > lastMsg.pushName * - Se nenhum nome disponível: retorna null → frontend formata o telefone * * @see ChatRepository — queries otimizadas com JOINs * @see MessageRepository — busca e paginação de mensagens */ import { Router, type Request, type Response } from 'express' import { z } from 'zod' import { ChatRepository } from './chat.repository' import { MessageRepository } from './message.repository' import { prisma } from '../../infra/database/prisma' import { dragonfly } from '../../infra/cache/dragonfly' import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager' const chatRepo = new ChatRepository() const msgRepo = new MessageRepository() /** Handler genérico de erros: diferencia ZodError (400) de erros internos (500) */ function handleError(res: Response, err: unknown) { if (err instanceof z.ZodError) { res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors }) return } res.status(500).json({ error: (err as Error).message ?? 'Erro interno' }) } /** * Constrói as rotas de chat. * * Montadas em: /api/chats * O WhatsAppConnectionManager é injetado mas não usado diretamente aqui * (reservado para futuras features como typing indicator). */ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router { const router = Router() // ═══════════════════════════════════════════════════════════════════════════ // GET /api/chats — Lista chats com snapshot da última mensagem // ═══════════════════════════════════════════════════════════════════════════ /** * Lista chats do tenant para uma instância, com: * - Último mensagem (preview para a lista de chats) * - Protocolo ativo (se houver) * - Dados do contato (nome resolvido, avatar, reputação) * * Query params: * - instanceId: UUID (obrigatório) * - search?: string (busca por nome/telefone) * - archived?: boolean (filtrar por arquivados) * - limit?: number (1-200) * * REGRA DE RESOLUÇÃO DE NOME (resolvedName): * Grupos: Chat.name (subject do grupo) * Individuais: * 1. Contact.name (agenda do telefone — via contacts.upsert) * 2. Contact.verifiedName (conta business verificada) * 3. Contact.notify (pushname — via contacts.upsert ou mensagem) * 4. lastMsg.pushName (fallback: pushName da última mensagem recebida) * 5. null (frontend formata o telefone como "55 11 99999-9999") */ router.get('/', async (req: Request, res: Response) => { try { const { instanceId, search, archived, limit } = z.object({ instanceId: z.string().uuid(), search: z.string().optional(), archived: z.coerce.boolean().optional(), limit: z.coerce.number().int().min(1).max(200).optional(), }).parse(req.query) const chats = await chatRepo.findAllByInstance(req.tenantId!, instanceId, { search, archived, limit, }) // Serializa cada chat com nome resolvido e dados formatados para o frontend const result = chats.map((c) => { const contact = c.contact const isGroup = c.jid.endsWith('@g.us') // Última mensagem (já vem do JOIN no repository, limitado a 1) const lastMsg = c.messages[0] // Protocolo ativo mais recente (OPEN ou IN_PROGRESS) const activeProtocol = c.protocols[0] ?? null // ── Resolução de nome ───────────────────────────────────────────── // Regra: nunca retorna o JID bruto como nome — retorna null e deixa // o frontend fazer o fallback (telefone formatado ou "Grupo"). // // Para individuais: usa hierarchia Contact > lastMsg.pushName > null // Para grupos: usa Chat.name (subject) ou null // // O fallback lastMsg.pushName cobre contatos que vieram do history // sync sem nome (a maioria — só ~22 de ~3000 têm verifiedName). const resolvedName = isGroup ? (c.name ?? null) : (contact?.name ?? contact?.verifiedName ?? contact?.notify ?? (lastMsg && !lastMsg.fromMe ? lastMsg.pushName : null) ?? null) return { id: c.id, instanceId: c.instanceId, jid: c.jid, name: c.name ?? null, isPinned: c.isPinned, isArchived: c.isArchived, unreadCount: c.unreadCount, lastMessageAt: c.lastMessageAt, createdAt: c.createdAt, contact: contact ? { name: resolvedName, // null quando não há nome real phone: contact.phone, avatarUrl: contact.avatarUrl, scoreReputacao: contact.scoreReputacao, flagRestricao: contact.flagRestricao, } : isGroup ? { name: resolvedName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false } : null, lastMessage: lastMsg ? { body: lastMsg.body, fromMe: lastMsg.fromMe, status: lastMsg.status, type: lastMsg.type, timestamp: lastMsg.timestamp, pushName: lastMsg.fromMe ? null : (lastMsg.pushName ?? null), } : null, protocol: activeProtocol ? { number: activeProtocol.number, status: activeProtocol.status } : null, } }) res.json(result) } catch (err) { handleError(res, err) } }) // ═══════════════════════════════════════════════════════════════════════════ // GET /api/chats/search/messages — Busca full-text em mensagens // ═══════════════════════════════════════════════════════════════════════════ /** * Busca full-text no corpo das mensagens. * MUST ser definido ANTES de /:id para evitar captura de "search" como param. * * Query params: * - instanceId: UUID * - q: string (mínimo 2 chars) * - limit?: number (1-50) */ router.get('/search/messages', async (req: Request, res: Response) => { try { const { instanceId, q, limit } = z.object({ instanceId: z.string().uuid(), q: z.string().min(2), limit: z.coerce.number().int().min(1).max(50).optional(), }).parse(req.query) const results = await msgRepo.search(req.tenantId!, instanceId, q, limit) res.json(results) } catch (err) { handleError(res, err) } }) // ═══════════════════════════════════════════════════════════════════════════ // GET /api/chats/stats/overview — Contagens para o dashboard // ═══════════════════════════════════════════════════════════════════════════ /** * Retorna métricas resumidas para o dashboard do frontend. * MUST ser definido ANTES de /:id para evitar captura de "stats" como param. * * Response: { totalUnread, openProtocols, totalChats } * * Cache DragonflyDB de 30s para não sobrecarregar o banco em dashboards * com refresh automático. */ router.get('/stats/overview', async (req: Request, res: Response) => { try { const { instanceId } = z.object({ instanceId: z.string().uuid() }).parse(req.query) const cacheKey = `stats:${req.tenantId}:${instanceId}` const cached = await dragonfly.getJson(cacheKey) if (cached) { res.json(cached); return } // Executa as 3 queries em paralelo para minimizar latência const [totalUnread, openProtocols, totalChats] = await Promise.all([ chatRepo.countUnreadByInstance(req.tenantId!, instanceId), prisma.protocol.count({ where: { tenantId: req.tenantId!, status: { in: ['OPEN', 'IN_PROGRESS'] }, }, }), prisma.chat.count({ where: { tenantId: req.tenantId!, instanceId, isArchived: false, // Exclui chats @lid (artefatos internos do WhatsApp) NOT: { jid: { endsWith: '@lid' } } }, }), ]) const stats = { totalUnread, openProtocols, totalChats } await dragonfly.setJson(cacheKey, stats, 30) // cache 30s res.json(stats) } catch (err) { handleError(res, err) } }) // ═══════════════════════════════════════════════════════════════════════════ // GET /api/chats/:id — Detalhe de um chat // ═══════════════════════════════════════════════════════════════════════════ router.get('/:id', async (req: Request, res: Response) => { try { const id = req.params['id'] as string const chat = await chatRepo.findById(id, req.tenantId!) if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return } res.json(chat) } catch (err) { handleError(res, err) } }) // ═══════════════════════════════════════════════════════════════════════════ // POST /api/chats/:id/read — Zera contador de não-lidas // ═══════════════════════════════════════════════════════════════════════════ /** Marca todas as mensagens do chat como lidas (zera unreadCount) */ router.post('/:id/read', async (req: Request, res: Response) => { try { const id = req.params['id'] as string await chatRepo.clearUnread(id, req.tenantId!) res.json({ ok: true }) } catch (err) { handleError(res, err) } }) // ═══════════════════════════════════════════════════════════════════════════ // PATCH /api/chats/:id/archive — Arquivar/desarquivar // ═══════════════════════════════════════════════════════════════════════════ /** Body: { archived: boolean } */ router.patch('/:id/archive', async (req: Request, res: Response) => { try { const id = req.params['id'] as string const { archived } = z.object({ archived: z.boolean() }).parse(req.body) await chatRepo.setArchived(id, req.tenantId!, archived) res.json({ ok: true }) } catch (err) { handleError(res, err) } }) // ═══════════════════════════════════════════════════════════════════════════ // PATCH /api/chats/:id/pin — Fixar/desfixar // ═══════════════════════════════════════════════════════════════════════════ /** Body: { pinned: boolean } */ router.patch('/:id/pin', async (req: Request, res: Response) => { try { const id = req.params['id'] as string const { pinned } = z.object({ pinned: z.boolean() }).parse(req.body) await chatRepo.setPinned(id, req.tenantId!, pinned) res.json({ ok: true }) } catch (err) { handleError(res, err) } }) // ═══════════════════════════════════════════════════════════════════════════ // DELETE /api/chats/:id — Remove chat e mensagens // ═══════════════════════════════════════════════════════════════════════════ /** * Remove um chat e TODAS as suas mensagens permanentemente. * Operação irreversível — use com cuidado. * Remove mensagens primeiro (FK constraint) e depois o chat. */ router.delete('/:id', async (req: Request, res: Response) => { try { const id = req.params['id'] as string const chat = await chatRepo.findById(id, req.tenantId!) if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return } await prisma.message.deleteMany({ where: { chatId: id } }) await prisma.chat.delete({ where: { id } }) res.status(204).send() } catch (err) { handleError(res, err) } }) // ═══════════════════════════════════════════════════════════════════════════ // GET /api/chats/:id/messages — Histórico paginado // ═══════════════════════════════════════════════════════════════════════════ /** * Retorna mensagens do chat com paginação por cursor (timestamp ISO). * * Query params: * - before?: ISO datetime (mensagens anteriores a este timestamp) * - after?: ISO datetime (mensagens posteriores a este timestamp) * - limit?: number (1-100, default definido no repository) * * Response: { messages: Message[], hasMore: boolean } * * O frontend usa infinite scroll: carrega as mais recentes primeiro, * depois pede mais antigas passando `before` do timestamp mais antigo. */ router.get('/:id/messages', async (req: Request, res: Response) => { try { const id = req.params['id'] as string const { before, after, limit } = z.object({ before: z.string().datetime().optional(), after: z.string().datetime().optional(), limit: z.coerce.number().int().min(1).max(100).optional(), }).parse(req.query) // Valida que o chat pertence ao tenant (segurança multi-tenant) const chat = await chatRepo.findById(id, req.tenantId!) if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return } const { messages, hasMore } = await msgRepo.findByChatPaginated(id, { before, after, limit, }) res.json({ messages, hasMore }) } catch (err) { handleError(res, err) } }) // ═══════════════════════════════════════════════════════════════════════════ // PROTOCOLOS DE ATENDIMENTO // ═══════════════════════════════════════════════════════════════════════════ /** * GET /api/chats/:id/protocol — Últimos 10 protocolos do chat. * Inclui setor e equipe associados. */ router.get('/:id/protocol', async (req: Request, res: Response) => { try { const id = req.params['id'] as string const chat = await chatRepo.findById(id, req.tenantId!) if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return } const protocols = await prisma.protocol.findMany({ where: { chatId: id }, orderBy: { createdAt: 'desc' }, take: 10, include: { sector: { select: { name: true } }, team: { select: { name: true } }, }, }) res.json(protocols) } catch (err) { handleError(res, err) } }) /** * POST /api/chats/:id/protocol — Abre novo protocolo de atendimento. * * Body (opcional): * - sectorId?: UUID — setor responsável * - teamId?: UUID — equipe responsável * * O número do protocolo é gerado sequencialmente: #0001-2026, #0002-2026, etc. * Exige que o chat tenha um contactId vinculado (não funciona para chats órfãos). */ router.post('/:id/protocol', async (req: Request, res: Response) => { try { const id = req.params['id'] as string const { sectorId, teamId } = z.object({ sectorId: z.string().uuid().optional(), teamId: z.string().uuid().optional(), }).parse(req.body) const chat = await chatRepo.findById(id, req.tenantId!) if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return } if (!chat.contactId) { res.status(400).json({ error: 'Chat sem contato associado' }); return } // Gera número de protocolo sequencial com ano const count = await prisma.protocol.count({ where: { chatId: id } }) const year = new Date().getFullYear() const number = `#${String(count + 1).padStart(4, '0')}-${year}` const protocol = await prisma.protocol.create({ data: { tenantId: req.tenantId!, number, chatId: id, contactId: chat.contactId, sectorId, teamId, status: 'OPEN', }, }) res.status(201).json(protocol) } catch (err) { handleError(res, err) } }) /** * PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo. * * Body: * - status?: enum — novo status do protocolo * - summary?: string (max 500) — resumo do atendimento * * Se status='RESOLVED', preenche resolvedAt automaticamente. * * Status possíveis: * OPEN → IN_PROGRESS → WAITING_CLIENT / WAITING_AGENT → RESOLVED / CANCELLED */ router.patch('/:chatId/protocol/:protocolId', async (req: Request, res: Response) => { try { const chatId = req.params['chatId'] as string const protocolId = req.params['protocolId'] as string const { status, summary } = z.object({ status: z.enum(['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED']).optional(), summary: z.string().max(500).optional(), }).parse(req.body) const protocol = await prisma.protocol.updateMany({ where: { id: protocolId, chatId, tenantId: req.tenantId! }, data: { ...(status && { status }), ...(summary && { summary }), ...(status === 'RESOLVED' && { resolvedAt: new Date() }), }, }) res.json({ updated: protocol.count }) } catch (err) { handleError(res, err) } }) return router }