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