perf(cache): implement event-driven long TTL cache optimization & fix contact renaming edge-case
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* chat.cache.ts — Invalidação centralizada do cache da listagem de chats.
|
||||
*
|
||||
* O cache é populado em GET /api/chats com TTL de 30s.
|
||||
* Esta função invalida todas as variações (archived, limit) para um par
|
||||
* tenant+instance — uma nova mensagem ou alteração no chat força refetch.
|
||||
*
|
||||
* Usa SCAN ao invés de KEYS para não bloquear o DragonflyDB em prod.
|
||||
*/
|
||||
import { dragonfly } from '../../infra/cache/dragonfly'
|
||||
import { logger } from '../../config/logger'
|
||||
|
||||
/**
|
||||
* Remove todas as chaves de cache da listagem de chats para um instance.
|
||||
* Pattern: chats:list:{tenantId}:{instanceId}:*
|
||||
* Operação fire-and-forget: erros são logados mas não propagados.
|
||||
*/
|
||||
export async function invalidateChatListCache(tenantId: string, instanceId: string): Promise<void> {
|
||||
if (!dragonfly.isAlive()) return
|
||||
try {
|
||||
const client = dragonfly.raw()
|
||||
const pattern = `chats:list:${tenantId}:${instanceId}:*`
|
||||
const stream = client.scanStream({ match: pattern, count: 100 })
|
||||
const keys: string[] = []
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on('data', (batch: string[]) => {
|
||||
for (const k of batch) keys.push(k)
|
||||
})
|
||||
stream.on('end', () => resolve())
|
||||
stream.on('error', (err) => reject(err))
|
||||
})
|
||||
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err, tenantId, instanceId }, '[chat.cache] Falha ao invalidar cache (ignorado)')
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,33 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
|
||||
interface ContactSlim {
|
||||
id: string
|
||||
name: string | null
|
||||
notify: string | null
|
||||
verifiedName: string | null
|
||||
phone: string | null
|
||||
avatarUrl: string | null
|
||||
scoreReputacao: number
|
||||
flagRestricao: boolean
|
||||
}
|
||||
|
||||
interface ProtocolSlim {
|
||||
chatId: string
|
||||
number: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface LastMsgSlim {
|
||||
chatId: string
|
||||
body: string | null
|
||||
fromMe: boolean
|
||||
status: string
|
||||
timestamp: Date
|
||||
type: string
|
||||
pushName: string | null
|
||||
}
|
||||
|
||||
export class ChatRepository {
|
||||
async findAllByInstance(tenantId: string, instanceId: string, opts: {
|
||||
limit?: number
|
||||
@@ -9,8 +36,8 @@ export class ChatRepository {
|
||||
} = {}) {
|
||||
const { limit = 200, search, archived = false } = opts
|
||||
|
||||
return prisma.chat.findMany({
|
||||
relationLoadStrategy: 'join',
|
||||
// ── Q1: chats (sem includes — bate o N+1) ────────────────────────────────
|
||||
const chats = await prisma.chat.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
@@ -31,9 +58,32 @@ export class ChatRepository {
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
contact: {
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
{ lastMessageAt: { sort: 'desc', nulls: 'last' } },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
take: limit,
|
||||
})
|
||||
|
||||
if (chats.length === 0) {
|
||||
return [] as Array<typeof chats[number] & {
|
||||
contact: ContactSlim | null
|
||||
protocols: Array<{ number: string; status: string }>
|
||||
messages: Array<Omit<LastMsgSlim, 'chatId'>>
|
||||
}>
|
||||
}
|
||||
|
||||
const chatIds = chats.map((c) => c.id)
|
||||
const contactIds = Array.from(new Set(chats.map((c) => c.contactId).filter((x): x is string => !!x)))
|
||||
|
||||
// ── Q2/Q3/Q4 em paralelo: contatos, protocols (1 por chat), last msg (1 por chat) ──
|
||||
const [contacts, protocols, lastMsgs] = await Promise.all([
|
||||
contactIds.length > 0
|
||||
? prisma.contact.findMany({
|
||||
where: { id: { in: contactIds } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
notify: true,
|
||||
verifiedName: true,
|
||||
@@ -42,34 +92,63 @@ export class ChatRepository {
|
||||
scoreReputacao: true,
|
||||
flagRestricao: true,
|
||||
},
|
||||
},
|
||||
protocols: {
|
||||
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } },
|
||||
select: { number: true, status: true },
|
||||
take: 1,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
messages: {
|
||||
select: {
|
||||
body: true,
|
||||
fromMe: true,
|
||||
status: true,
|
||||
timestamp: true,
|
||||
type: true,
|
||||
pushName: true,
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
// COALESCE: usa lastMessageAt se existir, senão createdAt (chats novos sem msg)
|
||||
// Garante que chats recém-criados não caiam para o fundo da lista
|
||||
{ lastMessageAt: { sort: 'desc', nulls: 'last' } },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
take: limit,
|
||||
})
|
||||
: Promise.resolve([] as ContactSlim[]),
|
||||
// DISTINCT ON: 1 query batch — pega o protocolo aberto mais recente por chat
|
||||
prisma.$queryRaw<ProtocolSlim[]>`
|
||||
SELECT DISTINCT ON ("chatId") "chatId", number, status::text AS status
|
||||
FROM protocols
|
||||
WHERE "chatId" = ANY(${chatIds}::text[])
|
||||
AND status IN ('OPEN','IN_PROGRESS','WAITING_CLIENT','WAITING_AGENT')
|
||||
ORDER BY "chatId", "createdAt" DESC
|
||||
`,
|
||||
// DISTINCT ON: 1 query batch — pega a última msg por chat
|
||||
prisma.$queryRaw<LastMsgSlim[]>`
|
||||
SELECT DISTINCT ON ("chatId") "chatId", body, "fromMe", status::text AS status, timestamp, type::text AS type, "pushName"
|
||||
FROM messages
|
||||
WHERE "chatId" = ANY(${chatIds}::text[])
|
||||
ORDER BY "chatId", timestamp DESC
|
||||
`,
|
||||
])
|
||||
|
||||
// ── Indexa para lookup O(1) ──────────────────────────────────────────────
|
||||
const contactById = new Map<string, ContactSlim>(contacts.map((c) => [c.id, c]))
|
||||
const protocolByChat = new Map<string, ProtocolSlim>(protocols.map((p) => [p.chatId, p]))
|
||||
const lastMsgByChat = new Map<string, LastMsgSlim>(lastMsgs.map((m) => [m.chatId, m]))
|
||||
|
||||
// ── Re-monta a estrutura no formato esperado pelo chat.routes.ts ─────────
|
||||
return chats.map((c) => {
|
||||
const contact = c.contactId ? contactById.get(c.contactId) ?? null : null
|
||||
const protocol = protocolByChat.get(c.id)
|
||||
const lastMsg = lastMsgByChat.get(c.id)
|
||||
|
||||
return {
|
||||
...c,
|
||||
contact: contact
|
||||
? {
|
||||
name: contact.name,
|
||||
notify: contact.notify,
|
||||
verifiedName: contact.verifiedName,
|
||||
phone: contact.phone,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
scoreReputacao: contact.scoreReputacao,
|
||||
flagRestricao: contact.flagRestricao,
|
||||
}
|
||||
: null,
|
||||
protocols: protocol
|
||||
? [{ number: protocol.number, status: protocol.status }]
|
||||
: [],
|
||||
messages: lastMsg
|
||||
? [{
|
||||
body: lastMsg.body,
|
||||
fromMe: lastMsg.fromMe,
|
||||
status: lastMsg.status,
|
||||
timestamp: lastMsg.timestamp,
|
||||
type: lastMsg.type,
|
||||
pushName: lastMsg.pushName,
|
||||
}]
|
||||
: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,7 @@
|
||||
* 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 ChatRepository — queries otimizadas com batch DISTINCT ON
|
||||
* @see MessageRepository — busca e paginação de mensagens
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
@@ -29,11 +24,15 @@ import { ChatRepository } from './chat.repository'
|
||||
import { MessageRepository } from './message.repository'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { dragonfly } from '../../infra/cache/dragonfly'
|
||||
import { invalidateChatListCache } from './chat.cache'
|
||||
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
|
||||
|
||||
const chatRepo = new ChatRepository()
|
||||
const msgRepo = new 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: Response, err: unknown) {
|
||||
if (err instanceof z.ZodError) {
|
||||
@@ -43,13 +42,11 @@ function handleError(res: Response, err: unknown) {
|
||||
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).
|
||||
*/
|
||||
/** Constrói a chave de cache da listagem. Inclui search/archived/limit. */
|
||||
function chatListCacheKey(tenantId: string, instanceId: string, archived: boolean, limit: number): string {
|
||||
return `chats:list:${tenantId}:${instanceId}:${archived ? '1' : '0'}:${limit}`
|
||||
}
|
||||
|
||||
export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
const router = Router()
|
||||
|
||||
@@ -57,27 +54,6 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): 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({
|
||||
@@ -87,31 +63,36 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
}).parse(req.query)
|
||||
|
||||
const chats = await chatRepo.findAllByInstance(req.tenantId!, instanceId, {
|
||||
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: string | null = null
|
||||
if (useCache) {
|
||||
cacheKey = chatListCacheKey(tenantId, instanceId, archivedFlag, limitVal)
|
||||
const cached = await dragonfly.getJson<unknown[]>(cacheKey)
|
||||
if (cached) {
|
||||
res.setHeader('X-Cache', 'HIT')
|
||||
res.json(cached)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const chats = await chatRepo.findAllByInstance(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
|
||||
@@ -130,7 +111,7 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
createdAt: c.createdAt,
|
||||
contact: contact
|
||||
? {
|
||||
name: resolvedName, // null quando não há nome real
|
||||
name: resolvedName,
|
||||
phone: contact.phone,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
scoreReputacao: contact.scoreReputacao,
|
||||
@@ -155,6 +136,11 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
}
|
||||
})
|
||||
|
||||
if (useCache && cacheKey) {
|
||||
// fire-and-forget — não bloqueia a resposta
|
||||
dragonfly.setJson(cacheKey, result, CHAT_LIST_CACHE_TTL_S).catch(() => {})
|
||||
}
|
||||
res.setHeader('X-Cache', 'MISS')
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
@@ -162,18 +148,8 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/search/messages — Busca full-text em mensagens
|
||||
// GET /api/chats/search/messages
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 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({
|
||||
@@ -190,18 +166,8 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/stats/overview — Contagens para o dashboard
|
||||
// GET /api/chats/stats/overview
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@@ -210,7 +176,6 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
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({
|
||||
@@ -224,14 +189,13 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
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
|
||||
await dragonfly.setJson(cacheKey, stats, 30)
|
||||
|
||||
res.json(stats)
|
||||
} catch (err) {
|
||||
@@ -240,9 +204,8 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/:id — Detalhe de um chat
|
||||
// GET /api/chats/:id
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params['id'] as string
|
||||
@@ -255,14 +218,15 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/chats/:id/read — Zera contador de não-lidas
|
||||
// POST /api/chats/:id/read
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** 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!)
|
||||
const tenantId = req.tenantId!
|
||||
const chat = await prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } })
|
||||
await chatRepo.clearUnread(id, tenantId)
|
||||
if (chat) invalidateChatListCache(tenantId, chat.instanceId).catch(() => {})
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
@@ -270,15 +234,16 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PATCH /api/chats/:id/archive — Arquivar/desarquivar
|
||||
// PATCH /api/chats/:id/archive
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** 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)
|
||||
const tenantId = req.tenantId!
|
||||
const chat = await prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } })
|
||||
await chatRepo.setArchived(id, tenantId, archived)
|
||||
if (chat) invalidateChatListCache(tenantId, chat.instanceId).catch(() => {})
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
@@ -286,15 +251,16 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PATCH /api/chats/:id/pin — Fixar/desfixar
|
||||
// PATCH /api/chats/:id/pin
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** 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)
|
||||
const tenantId = req.tenantId!
|
||||
const chat = await prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } })
|
||||
await chatRepo.setPinned(id, tenantId, pinned)
|
||||
if (chat) invalidateChatListCache(tenantId, chat.instanceId).catch(() => {})
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
@@ -302,14 +268,8 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DELETE /api/chats/:id — Remove chat e mensagens
|
||||
// DELETE /api/chats/:id
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -317,6 +277,7 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
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 } })
|
||||
invalidateChatListCache(req.tenantId!, chat.instanceId).catch(() => {})
|
||||
res.status(204).send()
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
@@ -324,22 +285,8 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/:id/messages — Histórico paginado
|
||||
// GET /api/chats/:id/messages
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -349,7 +296,6 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
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 }
|
||||
|
||||
@@ -366,13 +312,8 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PROTOCOLOS DE ATENDIMENTO
|
||||
// PROTOCOLOS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -395,16 +336,6 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -417,7 +348,6 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
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}`
|
||||
@@ -434,24 +364,13 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
},
|
||||
})
|
||||
|
||||
invalidateChatListCache(req.tenantId!, chat.instanceId).catch(() => {})
|
||||
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
|
||||
@@ -470,6 +389,9 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
},
|
||||
})
|
||||
|
||||
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId: req.tenantId! }, select: { instanceId: true } })
|
||||
if (chat) invalidateChatListCache(req.tenantId!, chat.instanceId).catch(() => {})
|
||||
|
||||
res.json({ updated: protocol.count })
|
||||
} catch (err) {
|
||||
handleError(res, err)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router, type Request, type Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { normalizeJid } from '../../shared/utils/whatsapp'
|
||||
import { invalidateChatListCache } from '../chats/chat.cache'
|
||||
|
||||
export function buildContactRoutes(): Router {
|
||||
const router = Router()
|
||||
@@ -132,6 +133,11 @@ export function buildContactRoutes(): Router {
|
||||
}
|
||||
|
||||
const updated = await prisma.contact.update({ where: { id }, data: body })
|
||||
|
||||
if (body.name && existing.instanceId) {
|
||||
invalidateChatListCache(tenantId, existing.instanceId).catch(() => {})
|
||||
}
|
||||
|
||||
res.json(updated)
|
||||
})
|
||||
|
||||
@@ -153,6 +159,11 @@ export function buildContactRoutes(): Router {
|
||||
})
|
||||
|
||||
await prisma.contact.delete({ where: { id } })
|
||||
|
||||
if (existing.instanceId) {
|
||||
invalidateChatListCache(tenantId, existing.instanceId).catch(() => {})
|
||||
}
|
||||
|
||||
res.status(204).send()
|
||||
})
|
||||
|
||||
|
||||
+4
@@ -51,6 +51,7 @@ import { ContactHandler } from '../handlers/ContactHandler'
|
||||
import { ChatbotService } from '../../chatbot/chatbot.service'
|
||||
import type { Server as SocketIOServer } from 'socket.io'
|
||||
import { hookBus } from '../../../core/hook-bus'
|
||||
import { invalidateChatListCache } from "../../chats/chat.cache"
|
||||
|
||||
// ─── Chaves de cache no DragonflyDB (Redis-compatible) ──────────────────────
|
||||
const CACHE_KEY_STATUS = (instanceId: string) => `instance:${instanceId}:status`
|
||||
@@ -1193,6 +1194,8 @@ export class WhatsAppConnectionManager {
|
||||
logger.error({ err, jid }, '[WhatsApp] Erro ao sincronizar chat')
|
||||
}
|
||||
}
|
||||
// Invalida cache da listagem de chats — refletir alterações imediatamente na inbox
|
||||
invalidateChatListCache(tenantId, instanceId).catch(() => {})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -1275,6 +1278,7 @@ export class WhatsAppConnectionManager {
|
||||
logger.error({ err, jid }, '[WhatsApp] Erro ao aplicar chats.update')
|
||||
}
|
||||
}
|
||||
invalidateChatListCache(tenantId, instanceId).catch(() => {})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -36,6 +36,7 @@ import { storageProvider } from '../../../core/StorageProvider'
|
||||
import type { Server as SocketIOServer } from 'socket.io'
|
||||
import type { ChatbotService } from '../../../modules/chatbot/chatbot.service'
|
||||
import { hookBus } from '../../../core/hook-bus'
|
||||
import { invalidateChatListCache } from "../../chats/chat.cache"
|
||||
|
||||
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
|
||||
const MEDIA_DIR = path.resolve('./media')
|
||||
@@ -793,6 +794,8 @@ export class MessageHandler {
|
||||
: null,
|
||||
lastMessage,
|
||||
})
|
||||
// Invalida cache da listagem — força refetch na próxima request da inbox
|
||||
invalidateChatListCache(this.tenantId, this.instanceId).catch(() => {})
|
||||
} catch (err) {
|
||||
logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert')
|
||||
}
|
||||
|
||||
@@ -2788,7 +2788,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -140,6 +140,44 @@ function sortChats(chats: Chat[]): Chat[] {
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Cache local (localStorage) — UX instantâneo no reload ─────────────────────
|
||||
// Persiste os primeiros N chats por instância para hidratar a UI antes do fetch.
|
||||
// Chave: `inboxCache:v1:${instanceId}:${archived ? '1' : '0'}`
|
||||
// TTL implícito: validamos pela data salva e descartamos se > 7 dias.
|
||||
const INBOX_CACHE_PREFIX = 'inboxCache:v1'
|
||||
const INBOX_CACHE_LIMIT = 10
|
||||
const INBOX_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
interface InboxCacheEntry {
|
||||
savedAt: number
|
||||
chats: Chat[]
|
||||
}
|
||||
|
||||
function inboxCacheKey(instanceId: string, archived: boolean): string {
|
||||
return `${INBOX_CACHE_PREFIX}:${instanceId}:${archived ? '1' : '0'}`
|
||||
}
|
||||
|
||||
function readInboxCache(instanceId: string, archived: boolean): Chat[] | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = localStorage.getItem(inboxCacheKey(instanceId, archived))
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as InboxCacheEntry
|
||||
if (!parsed || !Array.isArray(parsed.chats)) return null
|
||||
if (Date.now() - parsed.savedAt > INBOX_CACHE_MAX_AGE_MS) return null
|
||||
return parsed.chats
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function writeInboxCache(instanceId: string, archived: boolean, chats: Chat[]): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const slim = chats.slice(0, INBOX_CACHE_LIMIT)
|
||||
const entry: InboxCacheEntry = { savedAt: Date.now(), chats: slim }
|
||||
localStorage.setItem(inboxCacheKey(instanceId, archived), JSON.stringify(entry))
|
||||
} catch { /* quota exceeded — ignora */ }
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
immer((set, get) => ({
|
||||
chats: [],
|
||||
@@ -217,11 +255,34 @@ export const useChatStore = create<ChatState>()(
|
||||
// ── Async ─────────────────────────────────────────────────────────────────
|
||||
|
||||
loadChats: async (instanceId, opts = {}) => {
|
||||
// Hidratação instantânea: se temos cache local válido para esta instância,
|
||||
// mostra os 10 chats salvos imediatamente enquanto a request real roda.
|
||||
// Evita tela em branco ou skeleton de 15s+ em redes lentas.
|
||||
const archivedFlag = opts.archived ?? false
|
||||
const hasSearch = !!opts.search
|
||||
const current = get().chats
|
||||
if (!hasSearch) {
|
||||
const sameInstance = current.length > 0 && current[0]?.instanceId === instanceId
|
||||
if (!sameInstance) {
|
||||
const cached = readInboxCache(instanceId, archivedFlag)
|
||||
if (cached && cached.length > 0) {
|
||||
set((s) => { s.chats = sortChats(cached); s.chatsLoading = true })
|
||||
} else {
|
||||
set((s) => { s.chatsLoading = true })
|
||||
}
|
||||
} else {
|
||||
set((s) => { s.chatsLoading = true })
|
||||
}
|
||||
} else {
|
||||
set((s) => { s.chatsLoading = true })
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await chatApi.list(instanceId, opts)
|
||||
const data: Chat[] = (Array.isArray(raw) ? raw : []).map(mapBackendChat)
|
||||
set((s) => { s.chats = sortChats(data); s.chatsLoading = false })
|
||||
const sorted = sortChats(data)
|
||||
set((s) => { s.chats = sorted; s.chatsLoading = false })
|
||||
if (!hasSearch) writeInboxCache(instanceId, archivedFlag, sorted)
|
||||
} catch {
|
||||
set((s) => { s.chatsLoading = false })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user