perf(inbox): cache de avatares no Wasabi e IndexedDB
Solução 3 — Wasabi:
- Após download do CDN do WhatsApp, faz upload fire-and-forget para
Wasabi com path determinístico (avatars/{tenant}/{instance}/{jid}.jpg)
- Armazena o path relativo em contact.avatarUrl no banco
- Na próxima requisição, serve bytes direto do Wasabi sem tocar no CDN do WA
- Adiciona customPath em StorageUploadPayload e getBuffer() na interface
StorageProviderInterface/StorageProviderRegistry e plugin
Solução 4 — IndexedDB:
- Backend expõe avatarVersion (MD5 curto do avatarUrl) no response /api/chats
- Campo propagado por chatStore → chatToLegacy → NewWhatsChat
- Hook useAvatarCache (IndexedDB nativo, sem deps) armazena dataUrl do avatar
com chave jid:version — invalida automaticamente quando o avatar muda no banco
- Avatar.tsx serve do IndexedDB antes de disparar qualquer request HTTP;
converte via canvas e persiste no IndexedDB na primeira carga bem-sucedida
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,11 @@ class StorageProviderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getBuffer(path: string): Promise<Buffer | null> {
|
||||||
|
if (!this.provider) return null
|
||||||
|
return this.provider.getBuffer(path)
|
||||||
|
}
|
||||||
|
|
||||||
async deletePrefix(prefix: string): Promise<{ deleted: number }> {
|
async deletePrefix(prefix: string): Promise<{ deleted: number }> {
|
||||||
if (!this.provider) {
|
if (!this.provider) {
|
||||||
rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado')
|
rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado')
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export interface StorageUploadPayload {
|
|||||||
usuarioSis?: string
|
usuarioSis?: string
|
||||||
/** JID da sessão WhatsApp — usado para compor o path de mídia WhatsApp */
|
/** JID da sessão WhatsApp — usado para compor o path de mídia WhatsApp */
|
||||||
sessionJID?: string
|
sessionJID?: string
|
||||||
|
/** Path completo no bucket — quando fornecido, substitui o path gerado automaticamente */
|
||||||
|
customPath?: string
|
||||||
file: {
|
file: {
|
||||||
originalname: string
|
originalname: string
|
||||||
buffer: Buffer
|
buffer: Buffer
|
||||||
@@ -131,4 +133,6 @@ export interface StorageProviderInterface {
|
|||||||
checkDetailedHealth(): Promise<import('./StorageProvider').StorageHealthStatus>
|
checkDetailedHealth(): Promise<import('./StorageProvider').StorageHealthStatus>
|
||||||
upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }>
|
upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }>
|
||||||
deletePrefix(prefix: string): Promise<{ deleted: number }>
|
deletePrefix(prefix: string): Promise<{ deleted: number }>
|
||||||
|
/** Recupera os bytes de um arquivo pelo seu path relativo no bucket */
|
||||||
|
getBuffer(path: string): Promise<Buffer | null>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
* @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'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { ChatRepository } from './chat.repository'
|
import { ChatRepository } from './chat.repository'
|
||||||
import { MessageRepository } from './message.repository'
|
import { MessageRepository } from './message.repository'
|
||||||
@@ -114,11 +115,16 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
|||||||
name: resolvedName,
|
name: resolvedName,
|
||||||
phone: contact.phone,
|
phone: contact.phone,
|
||||||
avatarUrl: contact.avatarUrl,
|
avatarUrl: contact.avatarUrl,
|
||||||
|
// Hash curto da URL armazenada — muda quando o avatar é atualizado no banco.
|
||||||
|
// Usado pelo frontend como chave de cache IndexedDB (invalida ao trocar foto).
|
||||||
|
avatarVersion: contact.avatarUrl
|
||||||
|
? createHash('md5').update(contact.avatarUrl).digest('hex').slice(0, 8)
|
||||||
|
: null,
|
||||||
scoreReputacao: contact.scoreReputacao,
|
scoreReputacao: contact.scoreReputacao,
|
||||||
flagRestricao: contact.flagRestricao,
|
flagRestricao: contact.flagRestricao,
|
||||||
}
|
}
|
||||||
: isGroup
|
: isGroup
|
||||||
? { name: resolvedName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
|
? { name: resolvedName, phone: null, avatarUrl: null, avatarVersion: null, scoreReputacao: 0, flagRestricao: false }
|
||||||
: null,
|
: null,
|
||||||
lastMessage: lastMsg
|
lastMessage: lastMsg
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { z } from 'zod'
|
|||||||
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 { logger } from '../../config/logger'
|
import { logger } from '../../config/logger'
|
||||||
|
import { storageProvider } from '../../core/StorageProvider'
|
||||||
import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager'
|
import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager'
|
||||||
import { sendRichMessage } from './rich-message'
|
import { sendRichMessage } from './rich-message'
|
||||||
|
|
||||||
@@ -715,6 +716,26 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
|
|||||||
// Dragonfly indisponível — segue o fluxo normal sem quebrar
|
// Dragonfly indisponível — segue o fluxo normal sem quebrar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers para persistência no Wasabi ────────────────────────────────────
|
||||||
|
// Path determinístico: avatars/{tenantId}/{instanceId}/{jid_safe}.jpg
|
||||||
|
// Não usa timestamp — sobrescreve o arquivo quando o avatar muda.
|
||||||
|
const avatarWasabiPath = (tid: string, iid: string, j: string) =>
|
||||||
|
`avatars/${tid}/${iid}/${j.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`
|
||||||
|
|
||||||
|
// Faz upload fire-and-forget para Wasabi e atualiza o banco com o path relativo.
|
||||||
|
// Chamado após servir os bytes ao browser para não bloquear a resposta.
|
||||||
|
const persistToWasabi = (buffer: Buffer) => {
|
||||||
|
if (!storageProvider.isRegistered()) return
|
||||||
|
const wasabiPath = avatarWasabiPath(tenantId, instanceId, jid)
|
||||||
|
storageProvider.uploadFile({
|
||||||
|
category: 'whatsapp',
|
||||||
|
customPath: wasabiPath,
|
||||||
|
file: { originalname: `${jid.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`, buffer, mimetype: 'image/jpeg' },
|
||||||
|
}).then(() =>
|
||||||
|
prisma.contact.updateMany({ where: { tenantId, instanceId, jid }, data: { avatarUrl: wasabiPath } })
|
||||||
|
).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Tenta avatarUrl já salvo no banco (funciona para todos os tipos de JID)
|
// 1. Tenta avatarUrl já salvo no banco (funciona para todos os tipos de JID)
|
||||||
const contact = await prisma.contact.findFirst({
|
const contact = await prisma.contact.findFirst({
|
||||||
where: { tenantId, instanceId, jid },
|
where: { tenantId, instanceId, jid },
|
||||||
@@ -722,34 +743,48 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (contact?.avatarUrl) {
|
if (contact?.avatarUrl) {
|
||||||
// Revalidação condicional: se o browser mandou If-None-Match e bate,
|
const storedUrl = contact.avatarUrl
|
||||||
// responde 304 sem baixar do CDN. Reduz o custo em rehit a ~zero.
|
const isWasabi = !storedUrl.startsWith('http')
|
||||||
const etag = etagFor(contact.avatarUrl)
|
|
||||||
|
const etag = etagFor(storedUrl)
|
||||||
if (req.headers['if-none-match'] === etag) {
|
if (req.headers['if-none-match'] === etag) {
|
||||||
res.set('ETag', etag)
|
res.set('ETag', etag).set('Cache-Control', HIT_CACHE_CONTROL).status(304).end()
|
||||||
res.set('Cache-Control', HIT_CACHE_CONTROL)
|
|
||||||
res.status(304).end()
|
|
||||||
logger.debug({ jid, ms: Date.now() - t0, source: 'etag_304' }, '[Avatar] 304')
|
logger.debug({ jid, ms: Date.now() - t0, source: 'etag_304' }, '[Avatar] 304')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isWasabi) {
|
||||||
|
// Já está no Wasabi — serve direto sem tocar no CDN do WhatsApp
|
||||||
try {
|
try {
|
||||||
// Busca imagem do CDN do WhatsApp (URL temporária, expira em ~24h)
|
const buffer = await storageProvider.getBuffer(storedUrl)
|
||||||
const upstream = await fetch(contact.avatarUrl)
|
if (buffer) {
|
||||||
|
res.set('Content-Type', 'image/jpeg')
|
||||||
|
.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||||
|
.set('ETag', etag)
|
||||||
|
.set('Content-Length', String(buffer.length))
|
||||||
|
.send(buffer)
|
||||||
|
logger.debug({ jid, ms: Date.now() - t0, source: 'wasabi' }, '[Avatar] served')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch { /* Wasabi indisponível — cai no fallback Baileys abaixo */ }
|
||||||
|
} else {
|
||||||
|
// CDN URL do WhatsApp — busca bytes e inicia upload para Wasabi
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(storedUrl)
|
||||||
if (upstream.ok) {
|
if (upstream.ok) {
|
||||||
const buffer = Buffer.from(await upstream.arrayBuffer())
|
const buffer = Buffer.from(await upstream.arrayBuffer())
|
||||||
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
|
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
|
||||||
res.set('Cache-Control', HIT_CACHE_CONTROL)
|
.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||||
res.set('ETag', etag)
|
.set('ETag', etag)
|
||||||
res.set('Content-Length', String(buffer.length))
|
.set('Content-Length', String(buffer.length))
|
||||||
res.send(buffer)
|
.send(buffer)
|
||||||
logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served')
|
logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served')
|
||||||
|
persistToWasabi(buffer)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// URL da DB expirou — tenta via Baileys abaixo
|
// URL expirou — cai no fallback Baileys abaixo
|
||||||
logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys')
|
logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys')
|
||||||
} catch {
|
} catch { /* Erro de rede — cai no fallback Baileys abaixo */ }
|
||||||
// URL da DB expirou ou erro de rede — tenta via Baileys abaixo
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,24 +799,19 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
|
|||||||
const url = await sock.profilePictureUrl(jid, 'image')
|
const url = await sock.profilePictureUrl(jid, 'image')
|
||||||
if (!url) { noAvatar(); return }
|
if (!url) { noAvatar(); return }
|
||||||
|
|
||||||
// Faz proxy do buffer — evita CORS ao redirecionar para o CDN do WhatsApp
|
|
||||||
const upstream = await fetch(url)
|
const upstream = await fetch(url)
|
||||||
if (!upstream.ok) { noAvatar(); return }
|
if (!upstream.ok) { noAvatar(); return }
|
||||||
|
|
||||||
const buffer = Buffer.from(await upstream.arrayBuffer())
|
const buffer = Buffer.from(await upstream.arrayBuffer())
|
||||||
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
|
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
|
||||||
res.set('Cache-Control', HIT_CACHE_CONTROL)
|
.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||||
res.set('ETag', etagFor(url))
|
.set('ETag', etagFor(url))
|
||||||
res.set('Content-Length', String(buffer.length))
|
.set('Content-Length', String(buffer.length))
|
||||||
res.send(buffer)
|
.send(buffer)
|
||||||
logger.debug({ jid, ms: Date.now() - t0, source: 'baileys' }, '[Avatar] served')
|
logger.debug({ jid, ms: Date.now() - t0, source: 'baileys' }, '[Avatar] served')
|
||||||
|
|
||||||
// Salva URL nova no banco para próximas requisições (fire-and-forget).
|
// Persiste no Wasabi (fire-and-forget) e invalida negative cache
|
||||||
// Também invalida o negative cache, caso exista: agora temos foto.
|
persistToWasabi(buffer)
|
||||||
await prisma.contact.updateMany({
|
|
||||||
where: { tenantId, instanceId, jid },
|
|
||||||
data: { avatarUrl: url },
|
|
||||||
}).catch(() => {})
|
|
||||||
dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => {})
|
dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => {})
|
||||||
} catch {
|
} catch {
|
||||||
noAvatar()
|
noAvatar()
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import { getAvatarInitials, getAvatarColor } from '../../utils/inboxUtils';
|
||||||
getAvatarInitials,
|
import { getAvatarFromCache, setAvatarInCache } from '../../hooks/useAvatarCache';
|
||||||
getAvatarColor,
|
|
||||||
} from '../../utils/inboxUtils';
|
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
chat: any;
|
chat: any;
|
||||||
@@ -14,30 +12,73 @@ interface AvatarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Avatar({ chat, size = 46, className = '' }: AvatarProps) {
|
export default function Avatar({ chat, size = 46, className = '' }: AvatarProps) {
|
||||||
const [showInitials, setShowInitials] = useState(false);
|
const [displayUrl, setDisplayUrl] = useState<string | null>(null);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const [showInitials, setShowInitials] = useState(false);
|
||||||
|
const cacheAttempted = useRef(false);
|
||||||
|
|
||||||
// chat.avatar_url é preenchido upstream (useNewInboxBridge, phonebook,
|
|
||||||
// ContactPicker) com a URL do proxy SÓ quando o banco confirma que existe
|
|
||||||
// foto. Null aqui = sem foto → Avatar pula a requisição e mostra iniciais.
|
|
||||||
const imageUrl = chat?.avatar_url || null;
|
const imageUrl = chat?.avatar_url || null;
|
||||||
|
const version = chat?.avatar_version || null;
|
||||||
|
const remoteJid = chat?.remote_jid || '';
|
||||||
|
|
||||||
|
// Quando jid ou version muda, tenta servir do cache IndexedDB antes de fazer request HTTP.
|
||||||
|
// Se não houver cache (primeira vez), usa a URL do proxy normalmente.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
cacheAttempted.current = false;
|
||||||
setIsLoaded(false);
|
setIsLoaded(false);
|
||||||
setShowInitials(!imageUrl);
|
|
||||||
}, [chat?.remote_jid, imageUrl]);
|
|
||||||
|
|
||||||
const currentUrl = useMemo(() => {
|
if (!imageUrl || !version) {
|
||||||
if (showInitials || !imageUrl) return null;
|
|
||||||
return imageUrl;
|
|
||||||
}, [showInitials, imageUrl]);
|
|
||||||
|
|
||||||
const handleImageError = () => {
|
|
||||||
setShowInitials(true);
|
setShowInitials(true);
|
||||||
|
setDisplayUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowInitials(false);
|
||||||
|
|
||||||
|
// Tenta cache IndexedDB — resultado síncrono do ponto de vista do usuário
|
||||||
|
getAvatarFromCache(remoteJid, version).then((cached) => {
|
||||||
|
if (cached) {
|
||||||
|
setDisplayUrl(cached);
|
||||||
|
setIsLoaded(true); // já temos os bytes — sem skeleton
|
||||||
|
} else {
|
||||||
|
setDisplayUrl(imageUrl); // busca pelo proxy normalmente
|
||||||
|
}
|
||||||
|
cacheAttempted.current = true;
|
||||||
|
}).catch(() => {
|
||||||
|
setDisplayUrl(imageUrl);
|
||||||
|
cacheAttempted.current = true;
|
||||||
|
});
|
||||||
|
}, [remoteJid, version, imageUrl]);
|
||||||
|
|
||||||
|
const handleLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
|
setIsLoaded(true);
|
||||||
|
|
||||||
|
// Salva no IndexedDB após primeira carga bem-sucedida do proxy.
|
||||||
|
// Só salva se a versão é conhecida e o displayUrl não é já um dataUrl (evita re-cache).
|
||||||
|
if (!version || !remoteJid || !displayUrl || displayUrl.startsWith('data:')) return;
|
||||||
|
try {
|
||||||
|
const img = e.currentTarget;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.naturalWidth || size;
|
||||||
|
canvas.height = img.naturalHeight || size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||||
|
setAvatarInCache(remoteJid, version, dataUrl);
|
||||||
|
} catch { /* CORS ou canvas tainted — ignora, cache não é crítico */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
setShowInitials(true);
|
||||||
|
setDisplayUrl(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const initials = useMemo(() => getAvatarInitials(chat), [chat]);
|
const initials = useMemo(() => getAvatarInitials(chat), [chat]);
|
||||||
const bgColor = useMemo(() => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'), [chat]);
|
const bgColor = useMemo(
|
||||||
|
() => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'),
|
||||||
|
[chat]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -45,18 +86,19 @@ export default function Avatar({ chat, size = 46, className = '' }: AvatarProps)
|
|||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size }}
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{!showInitials && currentUrl ? (
|
{!showInitials && displayUrl ? (
|
||||||
<motion.img
|
<motion.img
|
||||||
key={currentUrl}
|
key={displayUrl}
|
||||||
src={currentUrl}
|
src={displayUrl}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
crossOrigin="anonymous"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: isLoaded ? 1 : 0 }}
|
animate={{ opacity: isLoaded ? 1 : 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
onLoad={() => setIsLoaded(true)}
|
onLoad={handleLoad}
|
||||||
onError={handleImageError}
|
onError={handleError}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -72,8 +114,8 @@ export default function Avatar({ chat, size = 46, className = '' }: AvatarProps)
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Skeleton / Placeholder while loading first image */}
|
{/* Skeleton apenas quando buscando pelo proxy (não quando servindo do cache) */}
|
||||||
{!isLoaded && !showInitials && (
|
{!isLoaded && !showInitials && displayUrl && !displayUrl.startsWith('data:') && (
|
||||||
<div className="absolute inset-0 bg-gray-200 animate-pulse" />
|
<div className="absolute inset-0 bg-gray-200 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
|
|||||||
? getAvatarProxyUrl({ instance_name: c.instanceId, remote_jid: c.jid }, 'contact')
|
? getAvatarProxyUrl({ instance_name: c.instanceId, remote_jid: c.jid }, 'contact')
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const avatar_version: string | null = c.contactAvatarVersion ?? null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: c.id as any,
|
id: c.id as any,
|
||||||
instance_name: c.instanceId,
|
instance_name: c.instanceId,
|
||||||
@@ -49,6 +51,7 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
|
|||||||
phone: formattedPhone,
|
phone: formattedPhone,
|
||||||
lead_name: c.contactName,
|
lead_name: c.contactName,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
|
avatar_version,
|
||||||
bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'),
|
bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'),
|
||||||
displayName,
|
displayName,
|
||||||
subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone),
|
subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone),
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useAvatarCache — cache de avatares em IndexedDB.
|
||||||
|
*
|
||||||
|
* Chave de cache: `${jid}:${version}` (version = hash curto do avatarUrl no banco).
|
||||||
|
* Quando a versão muda (novo avatar no banco), a chave muda e o cache invalida
|
||||||
|
* automaticamente — sem TTL fixo, sem stale data visível ao usuário.
|
||||||
|
*
|
||||||
|
* Armazenamento: dataURL base64 do JPEG/WebP (compatível com <img src>).
|
||||||
|
* Limpeza: entradas com mais de 30 dias são removidas na abertura do DB.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DB_NAME = 'nw-avatars'
|
||||||
|
const STORE_NAME = 'blobs'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 dias
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
key: string // `${jid}:${version}`
|
||||||
|
dataUrl: string
|
||||||
|
savedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null = null
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
if (dbPromise) return dbPromise
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
db.createObjectStore(STORE_NAME, { keyPath: 'key' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const db = req.result
|
||||||
|
pruneOldEntries(db)
|
||||||
|
resolve(db)
|
||||||
|
}
|
||||||
|
req.onerror = () => { dbPromise = null; reject(req.error) }
|
||||||
|
})
|
||||||
|
return dbPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneOldEntries(db: IDBDatabase): void {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
const cutoff = Date.now() - MAX_AGE_MS
|
||||||
|
const req = store.openCursor()
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const cursor = req.result
|
||||||
|
if (!cursor) return
|
||||||
|
if ((cursor.value as CacheEntry).savedAt < cutoff) cursor.delete()
|
||||||
|
cursor.continue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAvatarFromCache(jid: string, version: string): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try {
|
||||||
|
const db = await openDB()
|
||||||
|
const key = `${jid}:${version}`
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||||
|
const req = tx.objectStore(STORE_NAME).get(key)
|
||||||
|
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
|
||||||
|
req.onerror = () => resolve(null)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAvatarInCache(jid: string, version: string, dataUrl: string): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
const db = await openDB()
|
||||||
|
const key = `${jid}:${version}`
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const entry: CacheEntry = { key, dataUrl, savedAt: Date.now() }
|
||||||
|
const req = tx.objectStore(STORE_NAME).put(entry)
|
||||||
|
req.onsuccess = () => resolve()
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
} catch { /* quota exceeded ou IDB indisponível — ignora silenciosamente */ }
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ function mapBackendChat(raw: any): Chat {
|
|||||||
isArchived: raw.isArchived ?? false,
|
isArchived: raw.isArchived ?? false,
|
||||||
contactName: raw.contact?.name ?? raw.contactName ?? null,
|
contactName: raw.contact?.name ?? raw.contactName ?? null,
|
||||||
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
|
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
|
||||||
|
contactAvatarVersion: raw.contact?.avatarVersion ?? raw.contactAvatarVersion ?? null,
|
||||||
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
|
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
|
||||||
scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0,
|
scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0,
|
||||||
flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false,
|
flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false,
|
||||||
@@ -52,6 +53,7 @@ export interface Chat {
|
|||||||
isArchived: boolean
|
isArchived: boolean
|
||||||
contactName: string | null
|
contactName: string | null
|
||||||
contactAvatarUrl: string | null
|
contactAvatarUrl: string | null
|
||||||
|
contactAvatarVersion: string | null
|
||||||
contactPhone: string | null
|
contactPhone: string | null
|
||||||
scoreReputacao: number
|
scoreReputacao: number
|
||||||
flagRestricao: boolean
|
flagRestricao: boolean
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export interface NewWhatsChat {
|
|||||||
phone: string
|
phone: string
|
||||||
lead_name: string | null
|
lead_name: string | null
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
|
avatar_version: string | null
|
||||||
bio: string | null
|
bio: string | null
|
||||||
displayName: string | null
|
displayName: string | null
|
||||||
subject: string | null
|
subject: string | null
|
||||||
|
|||||||
@@ -324,10 +324,11 @@ class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInt
|
|||||||
const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
const fileName = `${Date.now()}-${safeBase}`;
|
const fileName = `${Date.now()}-${safeBase}`;
|
||||||
|
|
||||||
// Path no bucket: whatsapp/{tenantId}/{instanceId}/{file} ou {category}/{file}
|
// Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
|
||||||
const relativePath = (category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
|
const relativePath = payload.customPath
|
||||||
|
?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
|
||||||
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
|
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
|
||||||
: `${category}/${fileName}`;
|
: `${category}/${fileName}`);
|
||||||
|
|
||||||
if (!this.s3 || !this.bucket) {
|
if (!this.s3 || !this.bucket) {
|
||||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||||
@@ -353,6 +354,24 @@ class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getBuffer(relativePath: string): Promise<Buffer | null> {
|
||||||
|
// 1. Fallback local
|
||||||
|
const localPath = path.join(this.localFallbackDir, relativePath);
|
||||||
|
if (fs.existsSync(localPath)) {
|
||||||
|
return fs.readFileSync(localPath);
|
||||||
|
}
|
||||||
|
// 2. Wasabi
|
||||||
|
if (!this.s3 || !this.bucket) return null;
|
||||||
|
try {
|
||||||
|
const result = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: relativePath }));
|
||||||
|
if (!result.Body) return null;
|
||||||
|
const bytes = await (result.Body as any).transformToByteArray();
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Salva buffer no diretório de fallback local, criando subpastas se necessário */
|
/** Salva buffer no diretório de fallback local, criando subpastas se necessário */
|
||||||
private saveToLocalFallback(relativePath: string, buffer: Buffer, originalName: string, _category: string): { provider: string; path: string } {
|
private saveToLocalFallback(relativePath: string, buffer: Buffer, originalName: string, _category: string): { provider: string; path: string } {
|
||||||
const dest = path.join(this.localFallbackDir, relativePath);
|
const dest = path.join(this.localFallbackDir, relativePath);
|
||||||
|
|||||||
Reference in New Issue
Block a user