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

View File

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

View File

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