481 lines
21 KiB
TypeScript
481 lines
21 KiB
TypeScript
/**
|
|
* 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
|
|
}
|