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 '@prisma/client'
|
||||||
import { prisma } from '../../infra/database/prisma'
|
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 {
|
export class ChatRepository {
|
||||||
async findAllByInstance(tenantId: string, instanceId: string, opts: {
|
async findAllByInstance(tenantId: string, instanceId: string, opts: {
|
||||||
limit?: number
|
limit?: number
|
||||||
@@ -9,8 +36,8 @@ export class ChatRepository {
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
const { limit = 200, search, archived = false } = opts
|
const { limit = 200, search, archived = false } = opts
|
||||||
|
|
||||||
return prisma.chat.findMany({
|
// ── Q1: chats (sem includes — bate o N+1) ────────────────────────────────
|
||||||
relationLoadStrategy: 'join',
|
const chats = await prisma.chat.findMany({
|
||||||
where: {
|
where: {
|
||||||
tenantId,
|
tenantId,
|
||||||
instanceId,
|
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: [
|
orderBy: [
|
||||||
{ isPinned: 'desc' },
|
{ 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' } },
|
{ lastMessageAt: { sort: 'desc', nulls: 'last' } },
|
||||||
{ createdAt: 'desc' },
|
{ createdAt: 'desc' },
|
||||||
],
|
],
|
||||||
take: limit,
|
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) {
|
async findById(id: string, tenantId: string) {
|
||||||
@@ -117,9 +196,9 @@ export class ChatRepository {
|
|||||||
|
|
||||||
async countUnreadByInstance(tenantId: string, instanceId: string): Promise<number> {
|
async countUnreadByInstance(tenantId: string, instanceId: string): Promise<number> {
|
||||||
const result = await prisma.chat.aggregate({
|
const result = await prisma.chat.aggregate({
|
||||||
where: {
|
where: {
|
||||||
tenantId,
|
tenantId,
|
||||||
instanceId,
|
instanceId,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
NOT: { OR: [
|
NOT: { OR: [
|
||||||
{ jid: { endsWith: '@lid' } },
|
{ jid: { endsWith: '@lid' } },
|
||||||
|
|||||||
@@ -15,12 +15,7 @@
|
|||||||
* POST /api/chats/:id/protocol — Abre novo protocolo
|
* POST /api/chats/:id/protocol — Abre novo protocolo
|
||||||
* PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo
|
* PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo
|
||||||
*
|
*
|
||||||
* RESOLUÇÃO DE NOME (displayName):
|
* @see ChatRepository — queries otimizadas com batch DISTINCT ON
|
||||||
* - Grupos: usa Chat.name (subject do grupo, sincronizado por groups.upsert)
|
|
||||||
* - Individuais: Contact.name > Contact.verifiedName > Contact.notify > lastMsg.pushName
|
|
||||||
* - Se nenhum nome disponível: retorna null → frontend formata o telefone
|
|
||||||
*
|
|
||||||
* @see ChatRepository — queries otimizadas com JOINs
|
|
||||||
* @see MessageRepository — busca e paginação de mensagens
|
* @see MessageRepository — busca e paginação de mensagens
|
||||||
*/
|
*/
|
||||||
import { Router, type Request, type Response } from 'express'
|
import { Router, type Request, type Response } from 'express'
|
||||||
@@ -29,11 +24,15 @@ import { ChatRepository } from './chat.repository'
|
|||||||
import { MessageRepository } from './message.repository'
|
import { MessageRepository } from './message.repository'
|
||||||
import { prisma } from '../../infra/database/prisma'
|
import { prisma } from '../../infra/database/prisma'
|
||||||
import { dragonfly } from '../../infra/cache/dragonfly'
|
import { dragonfly } from '../../infra/cache/dragonfly'
|
||||||
|
import { invalidateChatListCache } from './chat.cache'
|
||||||
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
|
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
|
||||||
|
|
||||||
const chatRepo = new ChatRepository()
|
const chatRepo = new ChatRepository()
|
||||||
const msgRepo = new MessageRepository()
|
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) */
|
/** Handler genérico de erros: diferencia ZodError (400) de erros internos (500) */
|
||||||
function handleError(res: Response, err: unknown) {
|
function handleError(res: Response, err: unknown) {
|
||||||
if (err instanceof z.ZodError) {
|
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' })
|
res.status(500).json({ error: (err as Error).message ?? 'Erro interno' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Constrói a chave de cache da listagem. Inclui search/archived/limit. */
|
||||||
* Constrói as rotas de chat.
|
function chatListCacheKey(tenantId: string, instanceId: string, archived: boolean, limit: number): string {
|
||||||
*
|
return `chats:list:${tenantId}:${instanceId}:${archived ? '1' : '0'}:${limit}`
|
||||||
* Montadas em: /api/chats
|
}
|
||||||
* O WhatsAppConnectionManager é injetado mas não usado diretamente aqui
|
|
||||||
* (reservado para futuras features como typing indicator).
|
|
||||||
*/
|
|
||||||
export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -57,27 +54,6 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
|||||||
// GET /api/chats — Lista chats com snapshot da última mensagem
|
// 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) => {
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { instanceId, search, archived, limit } = z.object({
|
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(),
|
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||||
}).parse(req.query)
|
}).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,
|
search,
|
||||||
archived,
|
archived,
|
||||||
limit,
|
limit,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Serializa cada chat com nome resolvido e dados formatados para o frontend
|
|
||||||
const result = chats.map((c) => {
|
const result = chats.map((c) => {
|
||||||
const contact = c.contact
|
const contact = c.contact
|
||||||
const isGroup = c.jid.endsWith('@g.us')
|
const isGroup = c.jid.endsWith('@g.us')
|
||||||
|
|
||||||
// Última mensagem (já vem do JOIN no repository, limitado a 1)
|
|
||||||
const lastMsg = c.messages[0]
|
const lastMsg = c.messages[0]
|
||||||
// Protocolo ativo mais recente (OPEN ou IN_PROGRESS)
|
|
||||||
const activeProtocol = c.protocols[0] ?? null
|
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
|
const resolvedName = isGroup
|
||||||
? (c.name ?? null)
|
? (c.name ?? null)
|
||||||
: (contact?.name ?? contact?.verifiedName ?? contact?.notify
|
: (contact?.name ?? contact?.verifiedName ?? contact?.notify
|
||||||
@@ -130,7 +111,7 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
|||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
contact: contact
|
contact: contact
|
||||||
? {
|
? {
|
||||||
name: resolvedName, // null quando não há nome real
|
name: resolvedName,
|
||||||
phone: contact.phone,
|
phone: contact.phone,
|
||||||
avatarUrl: contact.avatarUrl,
|
avatarUrl: contact.avatarUrl,
|
||||||
scoreReputacao: contact.scoreReputacao,
|
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)
|
res.json(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, 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) => {
|
router.get('/search/messages', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { instanceId, q, limit } = z.object({
|
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) => {
|
router.get('/stats/overview', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { instanceId } = z.object({ instanceId: z.string().uuid() }).parse(req.query)
|
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)
|
const cached = await dragonfly.getJson(cacheKey)
|
||||||
if (cached) { res.json(cached); return }
|
if (cached) { res.json(cached); return }
|
||||||
|
|
||||||
// Executa as 3 queries em paralelo para minimizar latência
|
|
||||||
const [totalUnread, openProtocols, totalChats] = await Promise.all([
|
const [totalUnread, openProtocols, totalChats] = await Promise.all([
|
||||||
chatRepo.countUnreadByInstance(req.tenantId!, instanceId),
|
chatRepo.countUnreadByInstance(req.tenantId!, instanceId),
|
||||||
prisma.protocol.count({
|
prisma.protocol.count({
|
||||||
@@ -224,14 +189,13 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
|||||||
tenantId: req.tenantId!,
|
tenantId: req.tenantId!,
|
||||||
instanceId,
|
instanceId,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
// Exclui chats @lid (artefatos internos do WhatsApp)
|
|
||||||
NOT: { jid: { endsWith: '@lid' } }
|
NOT: { jid: { endsWith: '@lid' } }
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const stats = { totalUnread, openProtocols, totalChats }
|
const stats = { totalUnread, openProtocols, totalChats }
|
||||||
await dragonfly.setJson(cacheKey, stats, 30) // cache 30s
|
await dragonfly.setJson(cacheKey, stats, 30)
|
||||||
|
|
||||||
res.json(stats)
|
res.json(stats)
|
||||||
} catch (err) {
|
} 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) => {
|
router.get('/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params['id'] as string
|
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) => {
|
router.post('/:id/read', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params['id'] as string
|
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 })
|
res.json({ ok: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, 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) => {
|
router.patch('/:id/archive', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params['id'] as string
|
const id = req.params['id'] as string
|
||||||
const { archived } = z.object({ archived: z.boolean() }).parse(req.body)
|
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 })
|
res.json({ ok: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, 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) => {
|
router.patch('/:id/pin', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params['id'] as string
|
const id = req.params['id'] as string
|
||||||
const { pinned } = z.object({ pinned: z.boolean() }).parse(req.body)
|
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 })
|
res.json({ ok: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, 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) => {
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params['id'] as string
|
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 }
|
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||||
await prisma.message.deleteMany({ where: { chatId: id } })
|
await prisma.message.deleteMany({ where: { chatId: id } })
|
||||||
await prisma.chat.delete({ where: { id } })
|
await prisma.chat.delete({ where: { id } })
|
||||||
|
invalidateChatListCache(req.tenantId!, chat.instanceId).catch(() => {})
|
||||||
res.status(204).send()
|
res.status(204).send()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, 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) => {
|
router.get('/:id/messages', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params['id'] as string
|
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(),
|
limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||||
}).parse(req.query)
|
}).parse(req.query)
|
||||||
|
|
||||||
// Valida que o chat pertence ao tenant (segurança multi-tenant)
|
|
||||||
const chat = await chatRepo.findById(id, req.tenantId!)
|
const chat = await chatRepo.findById(id, req.tenantId!)
|
||||||
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
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) => {
|
router.get('/:id/protocol', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params['id'] as string
|
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) => {
|
router.post('/:id/protocol', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params['id'] as string
|
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) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||||
if (!chat.contactId) { res.status(400).json({ error: 'Chat sem contato associado' }); 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 count = await prisma.protocol.count({ where: { chatId: id } })
|
||||||
const year = new Date().getFullYear()
|
const year = new Date().getFullYear()
|
||||||
const number = `#${String(count + 1).padStart(4, '0')}-${year}`
|
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)
|
res.status(201).json(protocol)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, 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) => {
|
router.patch('/:chatId/protocol/:protocolId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const chatId = req.params['chatId'] as string
|
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 })
|
res.json({ updated: protocol.count })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, err)
|
handleError(res, err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router, type Request, type Response } from 'express'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { prisma } from '../../infra/database/prisma'
|
import { prisma } from '../../infra/database/prisma'
|
||||||
import { normalizeJid } from '../../shared/utils/whatsapp'
|
import { normalizeJid } from '../../shared/utils/whatsapp'
|
||||||
|
import { invalidateChatListCache } from '../chats/chat.cache'
|
||||||
|
|
||||||
export function buildContactRoutes(): Router {
|
export function buildContactRoutes(): Router {
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@@ -132,6 +133,11 @@ export function buildContactRoutes(): Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.contact.update({ where: { id }, data: body })
|
const updated = await prisma.contact.update({ where: { id }, data: body })
|
||||||
|
|
||||||
|
if (body.name && existing.instanceId) {
|
||||||
|
invalidateChatListCache(tenantId, existing.instanceId).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
res.json(updated)
|
res.json(updated)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -153,6 +159,11 @@ export function buildContactRoutes(): Router {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await prisma.contact.delete({ where: { id } })
|
await prisma.contact.delete({ where: { id } })
|
||||||
|
|
||||||
|
if (existing.instanceId) {
|
||||||
|
invalidateChatListCache(tenantId, existing.instanceId).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
res.status(204).send()
|
res.status(204).send()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+4
@@ -51,6 +51,7 @@ import { ContactHandler } from '../handlers/ContactHandler'
|
|||||||
import { ChatbotService } from '../../chatbot/chatbot.service'
|
import { ChatbotService } from '../../chatbot/chatbot.service'
|
||||||
import type { Server as SocketIOServer } from 'socket.io'
|
import type { Server as SocketIOServer } from 'socket.io'
|
||||||
import { hookBus } from '../../../core/hook-bus'
|
import { hookBus } from '../../../core/hook-bus'
|
||||||
|
import { invalidateChatListCache } from "../../chats/chat.cache"
|
||||||
|
|
||||||
// ─── Chaves de cache no DragonflyDB (Redis-compatible) ──────────────────────
|
// ─── Chaves de cache no DragonflyDB (Redis-compatible) ──────────────────────
|
||||||
const CACHE_KEY_STATUS = (instanceId: string) => `instance:${instanceId}:status`
|
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')
|
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')
|
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 { Server as SocketIOServer } from 'socket.io'
|
||||||
import type { ChatbotService } from '../../../modules/chatbot/chatbot.service'
|
import type { ChatbotService } from '../../../modules/chatbot/chatbot.service'
|
||||||
import { hookBus } from '../../../core/hook-bus'
|
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) */
|
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
|
||||||
const MEDIA_DIR = path.resolve('./media')
|
const MEDIA_DIR = path.resolve('./media')
|
||||||
@@ -793,6 +794,8 @@ export class MessageHandler {
|
|||||||
: null,
|
: null,
|
||||||
lastMessage,
|
lastMessage,
|
||||||
})
|
})
|
||||||
|
// Invalida cache da listagem — força refetch na próxima request da inbox
|
||||||
|
invalidateChatListCache(this.tenantId, this.instanceId).catch(() => {})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert')
|
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",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
@@ -140,6 +140,44 @@ function sortChats(chats: Chat[]): Chat[] {
|
|||||||
|
|
||||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
// ─── 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>()(
|
export const useChatStore = create<ChatState>()(
|
||||||
immer((set, get) => ({
|
immer((set, get) => ({
|
||||||
chats: [],
|
chats: [],
|
||||||
@@ -217,11 +255,34 @@ export const useChatStore = create<ChatState>()(
|
|||||||
// ── Async ─────────────────────────────────────────────────────────────────
|
// ── Async ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
loadChats: async (instanceId, opts = {}) => {
|
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 {
|
try {
|
||||||
const raw = await chatApi.list(instanceId, opts)
|
const raw = await chatApi.list(instanceId, opts)
|
||||||
const data: Chat[] = (Array.isArray(raw) ? raw : []).map(mapBackendChat)
|
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 {
|
} catch {
|
||||||
set((s) => { s.chatsLoading = false })
|
set((s) => { s.chatsLoading = false })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user