Files
scoreodonto.com/backend/dist/modules/chats/chat.routes.js
T

404 lines
21 KiB
JavaScript

"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;
}