From 10c906b61dab547e155d29deb39a16fa22ca14a9 Mon Sep 17 00:00:00 2001 From: VPS 4 Deploy Agent Date: Sun, 10 May 2026 21:50:31 +0200 Subject: [PATCH] perf(cache): implement event-driven long TTL cache optimization & fix contact renaming edge-case --- .../backend/src/modules/chats/chat.cache.ts | 40 ++++ .../src/modules/chats/chat.repository.ts | 155 ++++++++++---- .../backend/src/modules/chats/chat.routes.ts | 200 ++++++------------ .../src/modules/contacts/contact.routes.ts | 11 + .../connection/WhatsAppConnectionManager.ts | 4 + .../whatsapp/handlers/MessageHandler.ts | 3 + .../newwhats.local/frontend/package-lock.json | 1 - .../frontend/store/chatStore.ts | 65 +++++- 8 files changed, 299 insertions(+), 180 deletions(-) create mode 100644 clube67/newwhats.local/backend/src/modules/chats/chat.cache.ts diff --git a/clube67/newwhats.local/backend/src/modules/chats/chat.cache.ts b/clube67/newwhats.local/backend/src/modules/chats/chat.cache.ts new file mode 100644 index 0000000..1e9a60a --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/chats/chat.cache.ts @@ -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 { + 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((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)') + } +} diff --git a/clube67/newwhats.local/backend/src/modules/chats/chat.repository.ts b/clube67/newwhats.local/backend/src/modules/chats/chat.repository.ts index 0414872..9545be7 100644 --- a/clube67/newwhats.local/backend/src/modules/chats/chat.repository.ts +++ b/clube67/newwhats.local/backend/src/modules/chats/chat.repository.ts @@ -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 + messages: Array> + }> + } + + 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` + 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` + 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(contacts.map((c) => [c.id, c])) + const protocolByChat = new Map(protocols.map((p) => [p.chatId, p])) + const lastMsgByChat = new Map(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) { @@ -117,9 +196,9 @@ export class ChatRepository { async countUnreadByInstance(tenantId: string, instanceId: string): Promise { const result = await prisma.chat.aggregate({ - where: { - tenantId, - instanceId, + where: { + tenantId, + instanceId, isArchived: false, NOT: { OR: [ { jid: { endsWith: '@lid' } }, diff --git a/clube67/newwhats.local/backend/src/modules/chats/chat.routes.ts b/clube67/newwhats.local/backend/src/modules/chats/chat.routes.ts index 482a533..d539bbc 100644 --- a/clube67/newwhats.local/backend/src/modules/chats/chat.routes.ts +++ b/clube67/newwhats.local/backend/src/modules/chats/chat.routes.ts @@ -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(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) diff --git a/clube67/newwhats.local/backend/src/modules/contacts/contact.routes.ts b/clube67/newwhats.local/backend/src/modules/contacts/contact.routes.ts index 1283141..18bd422 100644 --- a/clube67/newwhats.local/backend/src/modules/contacts/contact.routes.ts +++ b/clube67/newwhats.local/backend/src/modules/contacts/contact.routes.ts @@ -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() }) diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.ts index b9159dd..cf56298 100644 --- a/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.ts +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.ts @@ -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(() => {}) } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts index 165f4c3..d2a06be 100644 --- a/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts @@ -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') } diff --git a/clube67/newwhats.local/frontend/package-lock.json b/clube67/newwhats.local/frontend/package-lock.json index 75a9211..c9fa78f 100644 --- a/clube67/newwhats.local/frontend/package-lock.json +++ b/clube67/newwhats.local/frontend/package-lock.json @@ -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" diff --git a/clube67/newwhats.local/frontend/store/chatStore.ts b/clube67/newwhats.local/frontend/store/chatStore.ts index b5eb7f7..7ff584c 100644 --- a/clube67/newwhats.local/frontend/store/chatStore.ts +++ b/clube67/newwhats.local/frontend/store/chatStore.ts @@ -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()( immer((set, get) => ({ chats: [], @@ -217,11 +255,34 @@ export const useChatStore = create()( // ── 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 }) }