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:
VPS 4 Deploy Agent
2026-05-11 16:02:08 +02:00
parent cdc32aa9b3
commit 2b9b855fc9
10 changed files with 268 additions and 67 deletions
@@ -324,10 +324,11 @@ class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInt
const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
const fileName = `${Date.now()}-${safeBase}`;
// Path no bucket: whatsapp/{tenantId}/{instanceId}/{file} ou {category}/{file}
const relativePath = (category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
: `${category}/${fileName}`;
// Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
const relativePath = payload.customPath
?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
: `${category}/${fileName}`);
if (!this.s3 || !this.bucket) {
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 */
private saveToLocalFallback(relativePath: string, buffer: Buffer, originalName: string, _category: string): { provider: string; path: string } {
const dest = path.join(this.localFallbackDir, relativePath);