"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.buildChatRoutes = buildChatRoutes; /** * 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 * * @see ChatRepository — queries otimizadas com batch DISTINCT ON * @see MessageRepository — busca e paginação de mensagens */ const express_1 = require("express"); const crypto_1 = require("crypto"); const zod_1 = require("zod"); const chat_repository_1 = require("./chat.repository"); const message_repository_1 = require("./message.repository"); const prisma_1 = require("../../infra/database/prisma"); const dragonfly_1 = require("../../infra/cache/dragonfly"); const chat_cache_1 = require("./chat.cache"); const chatRepo = new chat_repository_1.ChatRepository(); const msgRepo = new message_repository_1.MessageRepository(); /** TTL do cache de listagem de chats — longo para estabilidade, invalidado por eventos e mutações */ const CHAT_LIST_CACHE_TTL_S = 86400; /** Handler genérico de erros: diferencia ZodError (400) de erros internos (500) */ function handleError(res, err) { if (err instanceof zod_1.z.ZodError) { res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors }); return; } res.status(500).json({ error: err.message ?? 'Erro interno' }); } /** Constrói a chave de cache da listagem. Inclui search/archived/limit. */ function chatListCacheKey(tenantId, instanceId, archived, limit) { return `chats:list:${tenantId}:${instanceId}:${archived ? '1' : '0'}:${limit}`; } function buildChatRoutes(manager) { const router = (0, express_1.Router)(); // ═══════════════════════════════════════════════════════════════════════════ // GET /api/chats — Lista chats com snapshot da última mensagem // ═══════════════════════════════════════════════════════════════════════════ router.get('/', async (req, res) => { try { const { instanceId, search, archived, limit } = zod_1.z.object({ instanceId: zod_1.z.string().uuid(), search: zod_1.z.string().optional(), archived: zod_1.z.coerce.boolean().optional(), limit: zod_1.z.coerce.number().int().min(1).max(200).optional(), }).parse(req.query); const archivedFlag = archived ?? false; const limitVal = limit ?? 200; const tenantId = req.tenantId; // ── Cache hit: só quando não houver search (busca tem termos únicos) ── const useCache = !search; let cacheKey = null; if (useCache) { cacheKey = chatListCacheKey(tenantId, instanceId, archivedFlag, limitVal); const cached = await dragonfly_1.dragonfly.getJson(cacheKey); if (cached) { res.setHeader('X-Cache', 'HIT'); res.json(cached); return; } } const chats = await chatRepo.findAllByInstance(tenantId, instanceId, { search, archived, limit, }); const result = chats.map((c) => { const contact = c.contact; const isGroup = c.jid.endsWith('@g.us'); const lastMsg = c.messages[0]; const activeProtocol = c.protocols[0] ?? null; 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, phone: contact.phone, avatarUrl: contact.avatarUrl, // Hash curto da URL armazenada — muda quando o avatar é atualizado no banco. // Usado pelo frontend como chave de cache IndexedDB (invalida ao trocar foto). avatarVersion: contact.avatarUrl ? (0, crypto_1.createHash)('md5').update(contact.avatarUrl).digest('hex').slice(0, 8) : null, scoreReputacao: contact.scoreReputacao, flagRestricao: contact.flagRestricao, } : isGroup ? { name: resolvedName, phone: null, avatarUrl: null, avatarVersion: 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, }; }); if (useCache && cacheKey) { // fire-and-forget — não bloqueia a resposta dragonfly_1.dragonfly.setJson(cacheKey, result, CHAT_LIST_CACHE_TTL_S).catch(() => { }); } res.setHeader('X-Cache', 'MISS'); res.json(result); } catch (err) { handleError(res, err); } }); // ═══════════════════════════════════════════════════════════════════════════ // GET /api/chats/search/messages // ═══════════════════════════════════════════════════════════════════════════ router.get('/search/messages', async (req, res) => { try { const { instanceId, q, limit } = zod_1.z.object({ instanceId: zod_1.z.string().uuid(), q: zod_1.z.string().min(2), limit: zod_1.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 // ═══════════════════════════════════════════════════════════════════════════ router.get('/stats/overview', async (req, res) => { try { const { instanceId } = zod_1.z.object({ instanceId: zod_1.z.string().uuid() }).parse(req.query); const cacheKey = `stats:${req.tenantId}:${instanceId}`; const cached = await dragonfly_1.dragonfly.getJson(cacheKey); if (cached) { res.json(cached); return; } const [totalUnread, openProtocols, totalChats] = await Promise.all([ chatRepo.countUnreadByInstance(req.tenantId, instanceId), prisma_1.prisma.protocol.count({ where: { tenantId: req.tenantId, status: { in: ['OPEN', 'IN_PROGRESS'] }, }, }), prisma_1.prisma.chat.count({ where: { tenantId: req.tenantId, instanceId, isArchived: false, NOT: { jid: { endsWith: '@lid' } } }, }), ]); const stats = { totalUnread, openProtocols, totalChats }; await dragonfly_1.dragonfly.setJson(cacheKey, stats, 30); res.json(stats); } catch (err) { handleError(res, err); } }); // ═══════════════════════════════════════════════════════════════════════════ // GET /api/chats/:id // ═══════════════════════════════════════════════════════════════════════════ router.get('/:id', async (req, res) => { try { const id = req.params['id']; 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 // ═══════════════════════════════════════════════════════════════════════════ router.post('/:id/read', async (req, res) => { try { const id = req.params['id']; const tenantId = req.tenantId; const chat = await prisma_1.prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } }); await chatRepo.clearUnread(id, tenantId); if (chat) (0, chat_cache_1.invalidateChatListCache)(tenantId, chat.instanceId).catch(() => { }); res.json({ ok: true }); } catch (err) { handleError(res, err); } }); // ═══════════════════════════════════════════════════════════════════════════ // PATCH /api/chats/:id/archive // ═══════════════════════════════════════════════════════════════════════════ router.patch('/:id/archive', async (req, res) => { try { const id = req.params['id']; const { archived } = zod_1.z.object({ archived: zod_1.z.boolean() }).parse(req.body); const tenantId = req.tenantId; const chat = await prisma_1.prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } }); await chatRepo.setArchived(id, tenantId, archived); if (chat) (0, chat_cache_1.invalidateChatListCache)(tenantId, chat.instanceId).catch(() => { }); res.json({ ok: true }); } catch (err) { handleError(res, err); } }); // ═══════════════════════════════════════════════════════════════════════════ // PATCH /api/chats/:id/pin // ═══════════════════════════════════════════════════════════════════════════ router.patch('/:id/pin', async (req, res) => { try { const id = req.params['id']; const { pinned } = zod_1.z.object({ pinned: zod_1.z.boolean() }).parse(req.body); const tenantId = req.tenantId; const chat = await prisma_1.prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } }); await chatRepo.setPinned(id, tenantId, pinned); if (chat) (0, chat_cache_1.invalidateChatListCache)(tenantId, chat.instanceId).catch(() => { }); res.json({ ok: true }); } catch (err) { handleError(res, err); } }); // ═══════════════════════════════════════════════════════════════════════════ // DELETE /api/chats/:id // ═══════════════════════════════════════════════════════════════════════════ router.delete('/:id', async (req, res) => { try { const id = req.params['id']; const chat = await chatRepo.findById(id, req.tenantId); if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return; } await prisma_1.prisma.message.deleteMany({ where: { chatId: id } }); await prisma_1.prisma.chat.delete({ where: { id } }); (0, chat_cache_1.invalidateChatListCache)(req.tenantId, chat.instanceId).catch(() => { }); res.status(204).send(); } catch (err) { handleError(res, err); } }); // ═══════════════════════════════════════════════════════════════════════════ // GET /api/chats/:id/messages // ═══════════════════════════════════════════════════════════════════════════ router.get('/:id/messages', async (req, res) => { try { const id = req.params['id']; const { before, after, limit } = zod_1.z.object({ before: zod_1.z.string().datetime().optional(), after: zod_1.z.string().datetime().optional(), limit: zod_1.z.coerce.number().int().min(1).max(100).optional(), }).parse(req.query); 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 // ═══════════════════════════════════════════════════════════════════════════ router.get('/:id/protocol', async (req, res) => { try { const id = req.params['id']; const chat = await chatRepo.findById(id, req.tenantId); if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return; } const protocols = await prisma_1.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); } }); router.post('/:id/protocol', async (req, res) => { try { const id = req.params['id']; const { sectorId, teamId } = zod_1.z.object({ sectorId: zod_1.z.string().uuid().optional(), teamId: zod_1.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; } const count = await prisma_1.prisma.protocol.count({ where: { chatId: id } }); const year = new Date().getFullYear(); const number = `#${String(count + 1).padStart(4, '0')}-${year}`; const protocol = await prisma_1.prisma.protocol.create({ data: { tenantId: req.tenantId, number, chatId: id, contactId: chat.contactId, sectorId, teamId, status: 'OPEN', }, }); (0, chat_cache_1.invalidateChatListCache)(req.tenantId, chat.instanceId).catch(() => { }); res.status(201).json(protocol); } catch (err) { handleError(res, err); } }); router.patch('/:chatId/protocol/:protocolId', async (req, res) => { try { const chatId = req.params['chatId']; const protocolId = req.params['protocolId']; const { status, summary } = zod_1.z.object({ status: zod_1.z.enum(['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED']).optional(), summary: zod_1.z.string().max(500).optional(), }).parse(req.body); const protocol = await prisma_1.prisma.protocol.updateMany({ where: { id: protocolId, chatId, tenantId: req.tenantId }, data: { ...(status && { status }), ...(summary && { summary }), ...(status === 'RESOLVED' && { resolvedAt: new Date() }), }, }); const chat = await prisma_1.prisma.chat.findFirst({ where: { id: chatId, tenantId: req.tenantId }, select: { instanceId: true } }); if (chat) (0, chat_cache_1.invalidateChatListCache)(req.tenantId, chat.instanceId).catch(() => { }); res.json({ updated: protocol.count }); } catch (err) { handleError(res, err); } }); return router; }