perf(cache): implement event-driven long TTL cache optimization & fix contact renaming edge-case

This commit is contained in:
VPS 4 Deploy Agent
2026-05-10 21:50:31 +02:00
parent b046f6da7b
commit 10c906b61d
8 changed files with 299 additions and 180 deletions
@@ -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,46 +58,98 @@ export class ChatRepository {
}
: {}),
},
include: {
contact: {
select: {
name: true,
notify: true,
verifiedName: true,
phone: true,
avatarUrl: true,
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,
})
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,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
},
})
: 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,
}]
: [],
}
})
}
async findById(id: string, tenantId: string) {
@@ -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()
})
@@ -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')
}
-1
View File
@@ -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 = {}) => {
set((s) => { s.chatsLoading = true })
// 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 })
}