fix(attachments): limpar arquivos órfãos no S3 em deleções e reset

Três caminhos de deleção não chamavam o cleanup de storage, deixando
arquivos órfãos no S3:

- deleteTransactionBulkAction: deleções por escopo de série (período,
  futuras, todas) agora coletam attachments vinculados antes do delete
  e disparam cleanupAttachmentsAfterTransactionDelete
- deleteMultipleTransactionsAction: mesma correção para seleção
  múltipla de lançamentos
- resetUserAppData: reset de conta em Ajustes coleta os fileKeys
  antes de truncar e remove os objetos do S3 em paralelo

Também ajusta deleteS3Object para ignorar NoSuchKey silenciosamente,
necessário para providers S3-compatíveis como Cloudflare R2 que não
são idempotentes nessa operação.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-25 14:45:45 +00:00
parent b14f487824
commit 7f05d2a681
3 changed files with 91 additions and 58 deletions

View File

@@ -18,6 +18,7 @@ import {
} from "@/shared/lib/payers/constants";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
import { deleteS3Object } from "@/shared/lib/storage/presign";
type ActionResponse<T = void> = {
success: boolean;
@@ -85,6 +86,11 @@ async function resetUserAppData(
const avatarUrl = user.image ?? DEFAULT_PAYER_AVATAR;
const defaultPayerStatus = PAYER_STATUS_OPTIONS[0];
const userAttachments = await db
.select({ id: schema.attachments.id, fileKey: schema.attachments.fileKey })
.from(schema.attachments)
.where(eq(schema.attachments.userId, userId));
await db.transaction(async (tx: typeof db) => {
await tx
.delete(schema.payerShares)
@@ -115,6 +121,9 @@ async function resetUserAppData(
await tx
.delete(schema.transactions)
.where(eq(schema.transactions.userId, userId));
await tx
.delete(schema.attachments)
.where(eq(schema.attachments.userId, userId));
await tx.delete(schema.invoices).where(eq(schema.invoices.userId, userId));
await tx.delete(schema.cards).where(eq(schema.cards.userId, userId));
await tx
@@ -147,6 +156,14 @@ async function resetUserAppData(
userId,
});
});
await Promise.all(
userAttachments.map((att) =>
deleteS3Object(att.fileKey).catch((err) => {
console.error("Falha ao remover anexo do S3 no reset:", err);
}),
),
);
}
// Actions

View File

@@ -1,7 +1,7 @@
"use server";
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
import { transactions } from "@/db/schema";
import { attachments, transactionAttachments, transactions } from "@/db/schema";
import {
PAYMENT_METHODS,
TRANSACTION_CONDITIONS,
@@ -17,6 +17,7 @@ import {
import type { ActionResult } from "@/shared/lib/types/actions";
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
import { addMonthsToPeriod, parsePeriod } from "@/shared/utils/period";
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
import {
centsToDecimalString,
type DeleteBulkInput,
@@ -78,71 +79,64 @@ export async function deleteTransactionBulkAction(
};
}
let scopeFilter: ReturnType<typeof and>;
let successMessage: string;
if (data.scope === "current") {
await db
.delete(transactions)
.where(
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
);
revalidate(user.id);
return { success: true, message: "Lançamento removido com sucesso." };
}
if (data.scope === "period") {
await db
.delete(transactions)
.where(
and(
scopeFilter = eq(transactions.id, data.id);
successMessage = "Lançamento removido com sucesso.";
} else if (data.scope === "period") {
scopeFilter = and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
eq(transactions.period, existing.period ?? ""),
),
);
revalidate(user.id);
return {
success: true,
message: "Todos os lançamentos do período foram removidos.",
};
}
if (data.scope === "future") {
await db
.delete(transactions)
.where(
and(
successMessage = "Todos os lançamentos do período foram removidos.";
} else if (data.scope === "future") {
scopeFilter = and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
sql`${transactions.period} >= ${existing.period}`,
),
);
revalidate(user.id);
return {
success: true,
message: "Lançamentos removidos com sucesso.",
};
successMessage = "Lançamentos removidos com sucesso.";
} else if (data.scope === "all") {
scopeFilter = eq(transactions.seriesId, existing.seriesId);
successMessage = "Todos os lançamentos da série foram removidos.";
} else {
return { success: false, error: "Escopo de ação inválido." };
}
if (data.scope === "all") {
const targetRows = await db
.select({ id: transactions.id })
.from(transactions)
.where(and(scopeFilter, eq(transactions.userId, user.id)));
const targetIds = targetRows.map((r) => r.id);
if (targetIds.length === 0) {
return { success: false, error: "Nenhum lançamento encontrado." };
}
const linkedAttachments = await db
.select({ id: attachments.id, fileKey: attachments.fileKey })
.from(transactionAttachments)
.innerJoin(
attachments,
eq(transactionAttachments.attachmentId, attachments.id),
)
.where(inArray(transactionAttachments.transactionId, targetIds));
await db
.delete(transactions)
.where(
and(
eq(transactions.seriesId, existing.seriesId),
inArray(transactions.id, targetIds),
eq(transactions.userId, user.id),
),
);
revalidate(user.id);
return {
success: true,
message: "Todos os lançamentos da série foram removidos.",
};
}
await cleanupAttachmentsAfterTransactionDelete(linkedAttachments);
return { success: false, error: "Escopo de ação inválido." };
revalidate(user.id);
return { success: true, message: successMessage };
} catch (error) {
return handleActionError(error);
}
@@ -759,6 +753,15 @@ export async function deleteMultipleTransactionsAction(
return { success: false, error: "Nenhum lançamento encontrado." };
}
const linkedAttachments = await db
.select({ id: attachments.id, fileKey: attachments.fileKey })
.from(transactionAttachments)
.innerJoin(
attachments,
eq(transactionAttachments.attachmentId, attachments.id),
)
.where(inArray(transactionAttachments.transactionId, data.ids));
await db
.delete(transactions)
.where(
@@ -768,6 +771,8 @@ export async function deleteMultipleTransactionsAction(
),
);
await cleanupAttachmentsAfterTransactionDelete(linkedAttachments);
const notificationData = existing
.filter(
(

View File

@@ -48,5 +48,16 @@ export async function deleteS3Object(fileKey: string): Promise<void> {
Bucket: S3_BUCKET,
Key: fileKey,
});
try {
await s3.send(command);
} catch (err) {
if (
err instanceof Error &&
"Code" in err &&
(err as { Code: string }).Code === "NoSuchKey"
) {
return;
}
throw err;
}
}