mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user