feat(media): index e delete em lote de mídias (Wasabi, coordenado DB↔storage)

- StorageProvider.deleteObjects(paths): delete seletivo em lote (chunks de 1000),
  no provider concreto (Wasabi DeleteObjectsCommand + fallback local).
- GET /api/instances/:id/media-index: index/auditoria por chat/tipo/período
  (baseado no DB, com agregado byType, paginado).
- POST /api/instances/:id/media-batch-delete: delete coordenado DB↔Wasabi,
  dryRun por padrão (precisa dryRun:false p/ apagar); limpa mediaPath/mediaUrl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-06-28 14:52:25 +02:00
parent 809e378117
commit e47b64a5b5
6 changed files with 184 additions and 1 deletions
@@ -103,6 +103,15 @@ class StorageProviderRegistry {
return this.provider.deletePrefix(prefix)
}
async deleteObjects(paths: string[]): Promise<{ deleted: number }> {
if (!this.provider) {
rootLogger.warn('[StorageProvider] deleteObjects chamado mas nenhum provider está registrado')
return { deleted: 0 }
}
if (paths.length === 0) return { deleted: 0 }
return this.provider.deleteObjects(paths)
}
unregister(): void {
this.provider = null
this.healthy = false
@@ -133,6 +133,8 @@ export interface StorageProviderInterface {
checkDetailedHealth(): Promise<import('./StorageProvider').StorageHealthStatus>
upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }>
deletePrefix(prefix: string): Promise<{ deleted: number }>
/** Deleta uma lista específica de paths (delete em lote seletivo, até 1000/req no S3) */
deleteObjects(paths: string[]): Promise<{ deleted: number }>
/** Recupera os bytes de um arquivo pelo seu path relativo no bucket */
getBuffer(path: string): Promise<Buffer | null>
}
@@ -360,9 +360,108 @@ export function buildMediaRoutes(manager: WhatsAppConnectionManager): Router {
}
})
// ── GET /api/instances/:instanceId/media-index ─────────────────────────────
// Index/auditoria de mídias desta instância (baseado no DB, sem listObjects).
// Query: chatId?, type?, from?, to? (ISO), page=1, pageSize=50
router.get('/media-index', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const where = buildMediaWhere(tenantId, instanceId, req.query)
const page = Math.max(1, parseInt(String(req.query['page'] ?? '1'), 10) || 1)
const pageSize = Math.min(200, Math.max(1, parseInt(String(req.query['pageSize'] ?? '50'), 10) || 50))
const [total, byTypeRaw, items] = await Promise.all([
prisma.message.count({ where }),
prisma.message.groupBy({ by: ['type'], where, _count: { _all: true } }),
prisma.message.findMany({
where,
select: { id: true, type: true, chatId: true, remoteJid: true, mediaPath: true, mediaUrl: true, mimeType: true, timestamp: true },
orderBy: { timestamp: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
])
const byType = Object.fromEntries(byTypeRaw.map((r) => [r.type, r._count._all]))
res.json({ total, page, pageSize, byType, items })
} catch (err: any) {
logger.error({ err }, 'Erro no media-index')
res.status(500).json({ error: err.message ?? 'Erro ao indexar mídias' })
}
})
// ── POST /api/instances/:instanceId/media-batch-delete ─────────────────────
// Delete em lote coordenado DB↔Wasabi. Body: { chatId?, type?, from?, to?, dryRun? }
// dryRun=true (padrão) só conta; para apagar de fato, enviar dryRun:false.
router.post('/media-batch-delete', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const { dryRun = true } = req.body ?? {}
const where = buildMediaWhere(tenantId, instanceId, req.body ?? {})
const matchedRows = await prisma.message.findMany({
where,
select: { id: true, mediaPath: true },
})
const matched = matchedRows.length
if (dryRun) {
res.json({ dryRun: true, matched, willDeleteObjects: matchedRows.filter((m) => m.mediaPath).length })
return
}
if (matched === 0) {
res.json({ dryRun: false, matched: 0, deletedObjects: 0, dbCleared: 0 })
return
}
// 1. Apaga objetos do storage (só os que têm path relativo no bucket)
const paths = matchedRows.map((m) => m.mediaPath).filter((p): p is string => Boolean(p) && !p!.startsWith('http'))
let deletedObjects = 0
if (paths.length > 0 && storageProvider.isRegistered()) {
const r = await storageProvider.deleteObjects(paths)
deletedObjects = r.deleted
}
// 2. Limpa referências no DB (não deixa link quebrado nem órfão lógico)
const upd = await prisma.message.updateMany({
where: { id: { in: matchedRows.map((m) => m.id) } },
data: { mediaPath: null, mediaUrl: null },
})
logger.info({ instanceId, matched, deletedObjects, dbCleared: upd.count }, 'Delete em lote de mídias concluído')
res.json({ dryRun: false, matched, deletedObjects, dbCleared: upd.count })
} catch (err: any) {
logger.error({ err }, 'Erro no media-batch-delete')
res.status(500).json({ error: err.message ?? 'Erro ao apagar mídias em lote' })
}
})
return router
}
// ─── Helper: monta o filtro Prisma para index/delete de mídias ────────────────
const MEDIA_TYPES = ['IMAGE', 'VIDEO', 'AUDIO', 'DOCUMENT', 'STICKER'] as const
function buildMediaWhere(tenantId: string, instanceId: string, src: Record<string, any>) {
const where: any = { tenantId, instanceId, mediaPath: { not: null } }
if (src['chatId']) where.chatId = String(src['chatId'])
if (src['type']) {
const t = String(src['type']).toUpperCase()
if ((MEDIA_TYPES as readonly string[]).includes(t)) where.type = t
}
const from = src['from'] ? new Date(String(src['from'])) : null
const to = src['to'] ? new Date(String(src['to'])) : null
if ((from && !isNaN(from.getTime())) || (to && !isNaN(to.getTime()))) {
where.timestamp = {}
if (from && !isNaN(from.getTime())) where.timestamp.gte = from
if (to && !isNaN(to.getTime())) where.timestamp.lte = to
}
return where
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const EXT_MAP: Record<string, string> = {
@@ -271,6 +271,42 @@ class UnifiedStorageProviderPlugin {
}
return { deleted: deletedCount };
}
/** Delete em lote seletivo: apaga exatamente os paths informados (não por prefixo). */
async deleteObjects(paths) {
const keys = paths.filter((p) => p && !p.startsWith('http'));
if (keys.length === 0)
return { deleted: 0 };
let deletedCount = 0;
// 1. Wasabi — em chunks de 1000 (limite do DeleteObjectsCommand)
if (this.s3 && this.bucket) {
for (let i = 0; i < keys.length; i += 1000) {
const chunk = keys.slice(i, i + 1000);
try {
await this.s3.send(new client_s3_1.DeleteObjectsCommand({
Bucket: this.bucket,
Delete: { Objects: chunk.map((Key) => ({ Key })), Quiet: true },
}));
deletedCount += chunk.length;
this.ctx.logger.info(`[UnifiedStorageProvider] ${chunk.length} objetos deletados do Wasabi (lote seletivo)`);
}
catch (err) {
this.ctx.logger.error(`[UnifiedStorageProvider] Erro no delete em lote do Wasabi: ${err.message}`);
}
}
}
// 2. Fallback local
for (const key of keys) {
const localPath = path_1.default.join(this.localFallbackDir, key);
try {
if (fs_1.default.existsSync(localPath))
fs_1.default.unlinkSync(localPath);
}
catch (err) {
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao remover fallback local ${key}: ${err.message}`);
}
}
return { deleted: deletedCount };
}
async upload(payload) {
const { category, file } = payload;
if (!VALID_CATEGORIES.includes(category))
File diff suppressed because one or more lines are too long
@@ -301,6 +301,43 @@ class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInt
return { deleted: deletedCount };
}
/** Delete em lote seletivo: apaga exatamente os paths informados (não por prefixo). */
async deleteObjects(paths: string[]): Promise<{ deleted: number }> {
const keys = paths.filter((p) => p && !p.startsWith('http'));
if (keys.length === 0) return { deleted: 0 };
let deletedCount = 0;
// 1. Wasabi — em chunks de 1000 (limite do DeleteObjectsCommand)
if (this.s3 && this.bucket) {
for (let i = 0; i < keys.length; i += 1000) {
const chunk = keys.slice(i, i + 1000);
try {
await this.s3.send(new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: { Objects: chunk.map((Key) => ({ Key })), Quiet: true },
}));
deletedCount += chunk.length;
this.ctx.logger.info(`[UnifiedStorageProvider] ${chunk.length} objetos deletados do Wasabi (lote seletivo)`);
} catch (err: any) {
this.ctx.logger.error(`[UnifiedStorageProvider] Erro no delete em lote do Wasabi: ${err.message}`);
}
}
}
// 2. Fallback local
for (const key of keys) {
const localPath = path.join(this.localFallbackDir, key);
try {
if (fs.existsSync(localPath)) fs.unlinkSync(localPath);
} catch (err: any) {
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao remover fallback local ${key}: ${err.message}`);
}
}
return { deleted: deletedCount };
}
async upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> {
const { category, file } = payload;
if (!VALID_CATEGORIES.includes(category)) throw new Error(`Categoria inválida: ${category}`);