From f82043127a17dfa74cafcf89b18fe7bce65af76c Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Sat, 28 Mar 2026 15:13:05 +0000 Subject: [PATCH] feat(lancamentos): adicionar suporte a anexos com upload para storage S3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permite vincular arquivos (PDF, imagens) a lançamentos via upload direto para storage compatível com S3, usando token assinado por arquivo e validação de propriedade na leitura e remoção. Co-Authored-By: Claude Sonnet 4.6 --- src/db/schema.ts | 113 ++++- .../transactions/actions/attachments.ts | 435 ++++++++++++++++++ .../transactions/attachments-config.ts | 8 + .../attachments/attachment-file-picker.tsx | 92 ++++ .../attachments/attachment-item.tsx | 253 ++++++++++ .../attachments/attachment-section.tsx | 87 ++++ .../attachments/attachment-upload.tsx | 180 ++++++++ .../dialogs/transaction-details-dialog.tsx | 244 +++++----- .../components/table/transactions-table.tsx | 46 +- src/features/transactions/components/types.ts | 1 + src/features/transactions/page-helpers.ts | 2 + src/features/transactions/queries.ts | 7 + src/shared/lib/storage/presign.ts | 52 +++ src/shared/lib/storage/s3-client.ts | 13 + 14 files changed, 1392 insertions(+), 141 deletions(-) create mode 100644 src/features/transactions/actions/attachments.ts create mode 100644 src/features/transactions/attachments-config.ts create mode 100644 src/features/transactions/components/attachments/attachment-file-picker.tsx create mode 100644 src/features/transactions/components/attachments/attachment-item.tsx create mode 100644 src/features/transactions/components/attachments/attachment-section.tsx create mode 100644 src/features/transactions/components/attachments/attachment-upload.tsx create mode 100644 src/shared/lib/storage/presign.ts create mode 100644 src/shared/lib/storage/s3-client.ts diff --git a/src/db/schema.ts b/src/db/schema.ts index 5b6ba02..2069ba8 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -847,32 +847,36 @@ export const inboxItemsRelations = relations(inboxItems, ({ one }) => ({ }), })); -export const transactionsRelations = relations(transactions, ({ one }) => ({ - user: one(user, { - fields: [transactions.userId], - references: [user.id], +export const transactionsRelations = relations( + transactions, + ({ one, many }) => ({ + user: one(user, { + fields: [transactions.userId], + references: [user.id], + }), + card: one(cards, { + fields: [transactions.cardId], + references: [cards.id], + }), + financialAccount: one(financialAccounts, { + fields: [transactions.accountId], + references: [financialAccounts.id], + }), + category: one(categories, { + fields: [transactions.categoryId], + references: [categories.id], + }), + payer: one(payers, { + fields: [transactions.payerId], + references: [payers.id], + }), + anticipation: one(installmentAnticipations, { + fields: [transactions.anticipationId], + references: [installmentAnticipations.id], + }), + transactionAttachments: many(transactionAttachments), }), - card: one(cards, { - fields: [transactions.cardId], - references: [cards.id], - }), - financialAccount: one(financialAccounts, { - fields: [transactions.accountId], - references: [financialAccounts.id], - }), - category: one(categories, { - fields: [transactions.categoryId], - references: [categories.id], - }), - payer: one(payers, { - fields: [transactions.payerId], - references: [payers.id], - }), - anticipation: one(installmentAnticipations, { - fields: [transactions.anticipationId], - references: [installmentAnticipations.id], - }), -})); +); export const installmentAnticipationsRelations = relations( installmentAnticipations, @@ -896,6 +900,40 @@ export const installmentAnticipationsRelations = relations( }), ); +// ===================== ATTACHMENTS ===================== + +export const attachments = pgTable("anexos", { + id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + fileKey: text("chave_arquivo").notNull().unique(), + fileName: text("nome_arquivo").notNull(), + fileSize: integer("tamanho_bytes").notNull(), + mimeType: text("mime_type").notNull(), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const transactionAttachments = pgTable( + "lancamento_anexos", + { + transactionId: uuid("lancamento_id") + .notNull() + .references(() => transactions.id, { onDelete: "cascade" }), + attachmentId: uuid("anexo_id") + .notNull() + .references(() => attachments.id, { onDelete: "cascade" }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.transactionId, table.attachmentId] }), + attachmentIdIdx: index("lancamento_anexos_anexo_id_idx").on( + table.attachmentId, + ), + }), +); + export const importCategoryMappings = pgTable( "import_category_mappings", { @@ -939,3 +977,28 @@ export type NewApiToken = typeof apiTokens.$inferInsert; export type InboxItem = typeof inboxItems.$inferSelect; export type NewInboxItem = typeof inboxItems.$inferInsert; export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect; + +export const attachmentsRelations = relations(attachments, ({ one, many }) => ({ + user: one(user, { + fields: [attachments.userId], + references: [user.id], + }), + transactionAttachments: many(transactionAttachments), +})); + +export const transactionAttachmentsRelations = relations( + transactionAttachments, + ({ one }) => ({ + transaction: one(transactions, { + fields: [transactionAttachments.transactionId], + references: [transactions.id], + }), + attachment: one(attachments, { + fields: [transactionAttachments.attachmentId], + references: [attachments.id], + }), + }), +); + +export type Attachment = typeof attachments.$inferSelect; +export type TransactionAttachment = typeof transactionAttachments.$inferSelect; diff --git a/src/features/transactions/actions/attachments.ts b/src/features/transactions/actions/attachments.ts new file mode 100644 index 0000000..2096318 --- /dev/null +++ b/src/features/transactions/actions/attachments.ts @@ -0,0 +1,435 @@ +"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), + applyToSeries: z.boolean().default(false), +}); + +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 { + 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; + applyToSeries?: boolean; +}): Promise { + 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 }) + .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.applyToSeries && transaction.seriesId) { + const seriesRows = await db + .select({ id: transactions.id }) + .from(transactions) + .where( + and( + eq(transactions.seriesId, transaction.seriesId), + eq(transactions.userId, user.id), + ), + ); + transactionIds = seriesRows.map((t) => t.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 { + 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), + })), + ); +} + +/** 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 { + 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)); + } + } +} diff --git a/src/features/transactions/attachments-config.ts b/src/features/transactions/attachments-config.ts new file mode 100644 index 0000000..a9257f5 --- /dev/null +++ b/src/features/transactions/attachments-config.ts @@ -0,0 +1,8 @@ +export const ALLOWED_MIME_TYPES = [ + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", +] as const; + +export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB diff --git a/src/features/transactions/components/attachments/attachment-file-picker.tsx b/src/features/transactions/components/attachments/attachment-file-picker.tsx new file mode 100644 index 0000000..e42789a --- /dev/null +++ b/src/features/transactions/components/attachments/attachment-file-picker.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { RiAttachment2, RiCloseLine } from "@remixicon/react"; +import { useRef } from "react"; +import { toast } from "sonner"; +import { + ALLOWED_MIME_TYPES, + MAX_FILE_SIZE, +} from "@/features/transactions/attachments-config"; +import { Button } from "@/shared/components/ui/button"; + +interface AttachmentFilePickerProps { + file: File | null; + onChange: (file: File | null) => void; +} + +export function AttachmentFilePicker({ + file, + onChange, +}: AttachmentFilePickerProps) { + const inputRef = useRef(null); + + function handleFileChange(e: React.ChangeEvent) { + const selected = e.target.files?.[0]; + if (inputRef.current) inputRef.current.value = ""; + + if (!selected) return; + + if ( + !ALLOWED_MIME_TYPES.includes( + selected.type as (typeof ALLOWED_MIME_TYPES)[number], + ) + ) { + toast.error( + "Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).", + ); + return; + } + + if (selected.size > MAX_FILE_SIZE) { + toast.error("O arquivo deve ter no máximo 50MB."); + return; + } + + onChange(selected); + } + + return ( +
+

Anexo

+ + + {file ? ( +
+ + + {file.name} + + +
+ ) : ( + + )} +
+ ); +} diff --git a/src/features/transactions/components/attachments/attachment-item.tsx b/src/features/transactions/components/attachments/attachment-item.tsx new file mode 100644 index 0000000..b714dce --- /dev/null +++ b/src/features/transactions/components/attachments/attachment-item.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { + RiDeleteBinLine, + RiDownloadLine, + RiExternalLinkLine, + RiFileImageLine, + RiFileLine, + RiFilePdfLine, +} from "@remixicon/react"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { detachTransactionAttachmentAction } from "@/features/transactions/actions/attachments"; +import { Button } from "@/shared/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog"; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function AttachmentIcon({ mimeType }: { mimeType: string }) { + if (mimeType === "application/pdf") + return ; + if (mimeType.startsWith("image/")) + return ; + return ; +} + +function AttachmentPreview({ + open, + onOpenChange, + fileName, + mimeType, + url, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + fileName: string; + mimeType: string; + url: string; +}) { + const isPdf = mimeType === "application/pdf"; + const isImage = mimeType.startsWith("image/"); + + return ( + + + +
+ + {fileName} + +
+ +
+ + + + + +
+
+ +
+ {isPdf && ( +