mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
555 lines
14 KiB
TypeScript
555 lines
14 KiB
TypeScript
"use server";
|
|
|
|
import crypto, { randomUUID } from "node:crypto";
|
|
import { and, count, eq, inArray } from "drizzle-orm";
|
|
import { z } from "zod/v4";
|
|
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
|
import {
|
|
ALLOWED_MIME_TYPES,
|
|
MAX_FILE_SIZE,
|
|
} from "@/features/transactions/attachments-config";
|
|
import {
|
|
handleActionError,
|
|
revalidateForEntity,
|
|
} from "@/shared/lib/actions/helpers";
|
|
import { getUser } from "@/shared/lib/auth/server";
|
|
import { db } from "@/shared/lib/db";
|
|
import {
|
|
createPresignedGetUrl,
|
|
createPresignedPutUrl,
|
|
deleteS3Object,
|
|
headS3Object,
|
|
} from "@/shared/lib/storage/presign";
|
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
|
|
|
const UPLOAD_TOKEN_EXPIRY_SECONDS = 10 * 60;
|
|
|
|
const presignSchema = z.object({
|
|
fileName: z.string().min(1),
|
|
mimeType: z.enum(ALLOWED_MIME_TYPES),
|
|
fileSize: z.number().max(MAX_FILE_SIZE, "Arquivo deve ter no máximo 50MB."),
|
|
transactionId: z.string().uuid(),
|
|
});
|
|
|
|
const confirmSchema = z.object({
|
|
uploadToken: z.string().min(1),
|
|
scope: z.enum(["current", "period", "future", "all"]).default("current"),
|
|
});
|
|
|
|
const detachSchema = z.object({
|
|
attachmentId: z.string().uuid(),
|
|
transactionId: z.string().uuid(),
|
|
});
|
|
|
|
type PresignResult =
|
|
| {
|
|
success: true;
|
|
presignedUrl: string;
|
|
fileKey: string;
|
|
uploadToken: string;
|
|
}
|
|
| { success: false; error: string };
|
|
|
|
type UploadTokenPayload = {
|
|
userId: string;
|
|
transactionId: string;
|
|
fileKey: string;
|
|
fileName: string;
|
|
mimeType: (typeof ALLOWED_MIME_TYPES)[number];
|
|
fileSize: number;
|
|
exp: number;
|
|
};
|
|
|
|
function getUploadTokenSecret(): string {
|
|
const secret = process.env.BETTER_AUTH_SECRET;
|
|
if (!secret) {
|
|
throw new Error(
|
|
"BETTER_AUTH_SECRET is required. Set it in your .env file.",
|
|
);
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
function base64UrlEncode(value: string): string {
|
|
return Buffer.from(value)
|
|
.toString("base64")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=/g, "");
|
|
}
|
|
|
|
function base64UrlDecode(value: string): string {
|
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
const pad = normalized.length % 4;
|
|
const padded = pad ? normalized + "=".repeat(4 - pad) : normalized;
|
|
return Buffer.from(padded, "base64").toString("utf8");
|
|
}
|
|
|
|
function signUploadToken(payload: UploadTokenPayload): string {
|
|
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
|
const signature = crypto
|
|
.createHmac("sha256", getUploadTokenSecret())
|
|
.update(encodedPayload)
|
|
.digest("base64")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=/g, "");
|
|
|
|
return `${encodedPayload}.${signature}`;
|
|
}
|
|
|
|
function verifyUploadToken(token: string): UploadTokenPayload | null {
|
|
try {
|
|
const [encodedPayload, signature] = token.split(".");
|
|
if (!encodedPayload || !signature) return null;
|
|
|
|
const expectedSignature = crypto
|
|
.createHmac("sha256", getUploadTokenSecret())
|
|
.update(encodedPayload)
|
|
.digest("base64")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=/g, "");
|
|
|
|
if (
|
|
!crypto.timingSafeEqual(
|
|
Buffer.from(signature),
|
|
Buffer.from(expectedSignature),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const payload = JSON.parse(
|
|
base64UrlDecode(encodedPayload),
|
|
) as UploadTokenPayload;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
if (payload.exp < now) return null;
|
|
if (!payload.fileKey.startsWith(`${payload.userId}/`)) return null;
|
|
if (!ALLOWED_MIME_TYPES.includes(payload.mimeType)) return null;
|
|
if (payload.fileSize <= 0 || payload.fileSize > MAX_FILE_SIZE) return null;
|
|
|
|
return payload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getPresignedUploadUrlAction(input: {
|
|
fileName: string;
|
|
mimeType: string;
|
|
fileSize: number;
|
|
transactionId: string;
|
|
}): Promise<PresignResult> {
|
|
try {
|
|
const user = await getUser();
|
|
const data = presignSchema.parse(input);
|
|
|
|
const [transaction] = await db
|
|
.select({ id: transactions.id })
|
|
.from(transactions)
|
|
.where(
|
|
and(
|
|
eq(transactions.id, data.transactionId),
|
|
eq(transactions.userId, user.id),
|
|
),
|
|
);
|
|
|
|
if (!transaction) {
|
|
return { success: false, error: "Lançamento não encontrado." };
|
|
}
|
|
|
|
const ext = data.fileName.split(".").pop()?.toLowerCase() ?? "bin";
|
|
const fileKey = `${user.id}/${randomUUID()}.${ext}`;
|
|
const presignedUrl = await createPresignedPutUrl(fileKey, data.mimeType);
|
|
const uploadToken = signUploadToken({
|
|
userId: user.id,
|
|
transactionId: data.transactionId,
|
|
fileKey,
|
|
fileName: data.fileName,
|
|
mimeType: data.mimeType,
|
|
fileSize: data.fileSize,
|
|
exp: Math.floor(Date.now() / 1000) + UPLOAD_TOKEN_EXPIRY_SECONDS,
|
|
});
|
|
|
|
return { success: true, presignedUrl, fileKey, uploadToken };
|
|
} catch (error) {
|
|
const result = handleActionError(error);
|
|
if (!result.success) return { success: false, error: result.error };
|
|
return { success: false, error: "Erro inesperado." };
|
|
}
|
|
}
|
|
|
|
export async function confirmAttachmentUploadAction(input: {
|
|
uploadToken: string;
|
|
scope?: "current" | "period" | "future" | "all";
|
|
}): Promise<ActionResult> {
|
|
try {
|
|
const user = await getUser();
|
|
const data = confirmSchema.parse(input);
|
|
const uploadPayload = verifyUploadToken(data.uploadToken);
|
|
|
|
if (!uploadPayload || uploadPayload.userId !== user.id) {
|
|
return { success: false, error: "Upload de anexo inválido ou expirado." };
|
|
}
|
|
|
|
const [transaction] = await db
|
|
.select({
|
|
id: transactions.id,
|
|
seriesId: transactions.seriesId,
|
|
period: transactions.period,
|
|
})
|
|
.from(transactions)
|
|
.where(
|
|
and(
|
|
eq(transactions.id, uploadPayload.transactionId),
|
|
eq(transactions.userId, user.id),
|
|
),
|
|
);
|
|
|
|
if (!transaction) {
|
|
return { success: false, error: "Lançamento não encontrado." };
|
|
}
|
|
|
|
const objectMetadata = await headS3Object(uploadPayload.fileKey);
|
|
|
|
if (!objectMetadata.contentLength || objectMetadata.contentLength <= 0) {
|
|
return { success: false, error: "Arquivo enviado não encontrado." };
|
|
}
|
|
|
|
if (objectMetadata.contentLength > MAX_FILE_SIZE) {
|
|
return {
|
|
success: false,
|
|
error: "O arquivo enviado excede o limite permitido de 50MB.",
|
|
};
|
|
}
|
|
|
|
if (objectMetadata.contentLength !== uploadPayload.fileSize) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
"O tamanho do arquivo enviado não confere com o upload autorizado.",
|
|
};
|
|
}
|
|
|
|
if (objectMetadata.contentType !== uploadPayload.mimeType) {
|
|
return {
|
|
success: false,
|
|
error: "O tipo do arquivo enviado não confere com o upload autorizado.",
|
|
};
|
|
}
|
|
|
|
const [attachment] = await db
|
|
.insert(attachments)
|
|
.values({
|
|
userId: user.id,
|
|
fileKey: uploadPayload.fileKey,
|
|
fileName: uploadPayload.fileName,
|
|
fileSize: uploadPayload.fileSize,
|
|
mimeType: uploadPayload.mimeType,
|
|
})
|
|
.returning({ id: attachments.id });
|
|
|
|
if (!attachment) {
|
|
return { success: false, error: "Erro ao salvar o anexo." };
|
|
}
|
|
|
|
let transactionIds: string[] = [uploadPayload.transactionId];
|
|
|
|
if (data.scope !== "current" && transaction.seriesId) {
|
|
const seriesRows = await db
|
|
.select({ id: transactions.id, period: transactions.period })
|
|
.from(transactions)
|
|
.where(
|
|
and(
|
|
eq(transactions.seriesId, transaction.seriesId),
|
|
eq(transactions.userId, user.id),
|
|
),
|
|
);
|
|
|
|
if (data.scope === "period") {
|
|
transactionIds = seriesRows
|
|
.filter((r) => r.period === transaction.period)
|
|
.map((r) => r.id);
|
|
} else if (data.scope === "future") {
|
|
transactionIds = seriesRows
|
|
.filter((r) => (r.period ?? "") >= (transaction.period ?? ""))
|
|
.map((r) => r.id);
|
|
} else {
|
|
transactionIds = seriesRows.map((r) => r.id);
|
|
}
|
|
}
|
|
|
|
await db.insert(transactionAttachments).values(
|
|
transactionIds.map((tid) => ({
|
|
transactionId: tid,
|
|
attachmentId: attachment.id,
|
|
})),
|
|
);
|
|
|
|
revalidateForEntity("transactions", user.id);
|
|
|
|
return { success: true, message: "Anexo salvo com sucesso." };
|
|
} catch (error) {
|
|
return handleActionError(error);
|
|
}
|
|
}
|
|
|
|
export async function detachTransactionAttachmentAction(input: {
|
|
attachmentId: string;
|
|
transactionId: string;
|
|
}): Promise<ActionResult> {
|
|
try {
|
|
const user = await getUser();
|
|
const data = detachSchema.parse(input);
|
|
|
|
const [transaction] = await db
|
|
.select({ id: transactions.id })
|
|
.from(transactions)
|
|
.where(
|
|
and(
|
|
eq(transactions.id, data.transactionId),
|
|
eq(transactions.userId, user.id),
|
|
),
|
|
);
|
|
|
|
if (!transaction) {
|
|
return { success: false, error: "Lançamento não encontrado." };
|
|
}
|
|
|
|
const [attachment] = await db
|
|
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
|
.from(attachments)
|
|
.where(
|
|
and(
|
|
eq(attachments.id, data.attachmentId),
|
|
eq(attachments.userId, user.id),
|
|
),
|
|
);
|
|
|
|
if (!attachment) {
|
|
return { success: false, error: "Anexo não encontrado." };
|
|
}
|
|
|
|
await db
|
|
.delete(transactionAttachments)
|
|
.where(
|
|
and(
|
|
eq(transactionAttachments.transactionId, data.transactionId),
|
|
eq(transactionAttachments.attachmentId, data.attachmentId),
|
|
),
|
|
);
|
|
|
|
const [remaining] = await db
|
|
.select({ total: count() })
|
|
.from(transactionAttachments)
|
|
.where(eq(transactionAttachments.attachmentId, data.attachmentId));
|
|
|
|
if (!remaining || remaining.total === 0) {
|
|
await deleteS3Object(attachment.fileKey);
|
|
await db.delete(attachments).where(eq(attachments.id, data.attachmentId));
|
|
}
|
|
|
|
revalidateForEntity("transactions", user.id);
|
|
|
|
return { success: true, message: "Anexo removido com sucesso." };
|
|
} catch (error) {
|
|
return handleActionError(error);
|
|
}
|
|
}
|
|
|
|
export async function fetchTransactionAttachmentsAction(
|
|
transactionId: string,
|
|
): Promise<
|
|
Array<{
|
|
attachmentId: string;
|
|
fileName: string;
|
|
fileSize: number;
|
|
mimeType: string;
|
|
createdAt: Date;
|
|
url: string;
|
|
}>
|
|
> {
|
|
const user = await getUser();
|
|
|
|
const [transaction] = await db
|
|
.select({ id: transactions.id })
|
|
.from(transactions)
|
|
.where(
|
|
and(eq(transactions.id, transactionId), eq(transactions.userId, user.id)),
|
|
);
|
|
|
|
if (!transaction) {
|
|
return [];
|
|
}
|
|
|
|
const rows = await db
|
|
.select({
|
|
attachmentId: transactionAttachments.attachmentId,
|
|
fileName: attachments.fileName,
|
|
fileSize: attachments.fileSize,
|
|
mimeType: attachments.mimeType,
|
|
fileKey: attachments.fileKey,
|
|
createdAt: attachments.createdAt,
|
|
})
|
|
.from(transactionAttachments)
|
|
.innerJoin(
|
|
transactions,
|
|
and(
|
|
eq(transactionAttachments.transactionId, transactions.id),
|
|
eq(transactions.userId, user.id),
|
|
),
|
|
)
|
|
.innerJoin(
|
|
attachments,
|
|
and(
|
|
eq(transactionAttachments.attachmentId, attachments.id),
|
|
eq(attachments.userId, user.id),
|
|
),
|
|
)
|
|
.where(eq(transactionAttachments.transactionId, transactionId));
|
|
|
|
return Promise.all(
|
|
rows.map(async (row) => ({
|
|
attachmentId: row.attachmentId,
|
|
fileName: row.fileName,
|
|
fileSize: row.fileSize,
|
|
mimeType: row.mimeType,
|
|
createdAt: row.createdAt,
|
|
url: await createPresignedGetUrl(row.fileKey),
|
|
})),
|
|
);
|
|
}
|
|
|
|
const detachBulkSchema = z.object({
|
|
attachmentId: z.string().uuid(),
|
|
transactionId: z.string().uuid(),
|
|
scope: z.enum(["current", "period", "future", "all"]),
|
|
});
|
|
|
|
export async function detachAttachmentBulkAction(input: {
|
|
attachmentId: string;
|
|
transactionId: string;
|
|
scope: "current" | "period" | "future" | "all";
|
|
}): Promise<ActionResult> {
|
|
try {
|
|
const user = await getUser();
|
|
const data = detachBulkSchema.parse(input);
|
|
|
|
const [baseTransaction] = await db
|
|
.select({
|
|
id: transactions.id,
|
|
seriesId: transactions.seriesId,
|
|
period: transactions.period,
|
|
})
|
|
.from(transactions)
|
|
.where(
|
|
and(
|
|
eq(transactions.id, data.transactionId),
|
|
eq(transactions.userId, user.id),
|
|
),
|
|
);
|
|
|
|
if (!baseTransaction) {
|
|
return { success: false, error: "Lançamento não encontrado." };
|
|
}
|
|
|
|
const [attachment] = await db
|
|
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
|
.from(attachments)
|
|
.where(
|
|
and(
|
|
eq(attachments.id, data.attachmentId),
|
|
eq(attachments.userId, user.id),
|
|
),
|
|
);
|
|
|
|
if (!attachment) {
|
|
return { success: false, error: "Anexo não encontrado." };
|
|
}
|
|
|
|
let targetTransactionIds: string[];
|
|
|
|
if (data.scope === "current" || !baseTransaction.seriesId) {
|
|
targetTransactionIds = [data.transactionId];
|
|
} else {
|
|
const seriesRows = await db
|
|
.select({ id: transactions.id, period: transactions.period })
|
|
.from(transactions)
|
|
.where(
|
|
and(
|
|
eq(transactions.seriesId, baseTransaction.seriesId),
|
|
eq(transactions.userId, user.id),
|
|
),
|
|
);
|
|
|
|
if (data.scope === "period") {
|
|
targetTransactionIds = seriesRows
|
|
.filter((r) => r.period === baseTransaction.period)
|
|
.map((r) => r.id);
|
|
} else if (data.scope === "future") {
|
|
targetTransactionIds = seriesRows
|
|
.filter((r) => (r.period ?? "") >= (baseTransaction.period ?? ""))
|
|
.map((r) => r.id);
|
|
} else {
|
|
targetTransactionIds = seriesRows.map((r) => r.id);
|
|
}
|
|
}
|
|
|
|
if (targetTransactionIds.length > 0) {
|
|
await db
|
|
.delete(transactionAttachments)
|
|
.where(
|
|
and(
|
|
inArray(transactionAttachments.transactionId, targetTransactionIds),
|
|
eq(transactionAttachments.attachmentId, data.attachmentId),
|
|
),
|
|
);
|
|
}
|
|
|
|
const [remaining] = await db
|
|
.select({ total: count() })
|
|
.from(transactionAttachments)
|
|
.where(eq(transactionAttachments.attachmentId, data.attachmentId));
|
|
|
|
if (!remaining || remaining.total === 0) {
|
|
await deleteS3Object(attachment.fileKey);
|
|
await db.delete(attachments).where(eq(attachments.id, data.attachmentId));
|
|
}
|
|
|
|
revalidateForEntity("transactions", user.id);
|
|
|
|
return { success: true, message: "Anexo removido com sucesso." };
|
|
} catch (error) {
|
|
return handleActionError(error);
|
|
}
|
|
}
|
|
|
|
/** Limpa anexos órfãos do S3 após deletar transações. Chame APÓS o delete. */
|
|
export async function cleanupAttachmentsAfterTransactionDelete(
|
|
attachmentData: Array<{ id: string; fileKey: string }>,
|
|
): Promise<void> {
|
|
if (attachmentData.length === 0) return;
|
|
|
|
const uniqueIds = [...new Set(attachmentData.map((a) => a.id))];
|
|
|
|
const remaining = await db
|
|
.select({
|
|
attachmentId: transactionAttachments.attachmentId,
|
|
total: count(),
|
|
})
|
|
.from(transactionAttachments)
|
|
.where(inArray(transactionAttachments.attachmentId, uniqueIds))
|
|
.groupBy(transactionAttachments.attachmentId);
|
|
|
|
const remainingMap = new Map(remaining.map((r) => [r.attachmentId, r.total]));
|
|
|
|
for (const att of attachmentData) {
|
|
if (!remainingMap.has(att.id) || (remainingMap.get(att.id) ?? 0) === 0) {
|
|
await deleteS3Object(att.fileKey);
|
|
await db.delete(attachments).where(eq(attachments.id, att.id));
|
|
}
|
|
}
|
|
}
|