feat(lancamentos): adicionar suporte a anexos com upload para storage S3

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 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-03-28 15:13:05 +00:00
parent 32da4f906e
commit f82043127a
14 changed files with 1392 additions and 141 deletions

View File

@@ -847,32 +847,36 @@ export const inboxItemsRelations = relations(inboxItems, ({ one }) => ({
}), }),
})); }));
export const transactionsRelations = relations(transactions, ({ one }) => ({ export const transactionsRelations = relations(
user: one(user, { transactions,
fields: [transactions.userId], ({ one, many }) => ({
references: [user.id], 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( export const installmentAnticipationsRelations = relations(
installmentAnticipations, 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( export const importCategoryMappings = pgTable(
"import_category_mappings", "import_category_mappings",
{ {
@@ -939,3 +977,28 @@ export type NewApiToken = typeof apiTokens.$inferInsert;
export type InboxItem = typeof inboxItems.$inferSelect; export type InboxItem = typeof inboxItems.$inferSelect;
export type NewInboxItem = typeof inboxItems.$inferInsert; export type NewInboxItem = typeof inboxItems.$inferInsert;
export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect; 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;

View File

@@ -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<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;
applyToSeries?: boolean;
}): 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 })
.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<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),
})),
);
}
/** 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));
}
}
}

View File

@@ -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

View File

@@ -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<HTMLInputElement>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<div className="space-y-1.5">
<p className="text-xs">Anexo</p>
<input
ref={inputRef}
type="file"
className="hidden"
accept={ALLOWED_MIME_TYPES.join(",")}
onChange={handleFileChange}
/>
{file ? (
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate" title={file.name}>
{file.name}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-6 shrink-0"
onClick={() => onChange(null)}
>
<RiCloseLine className="size-4" />
</Button>
</div>
) : (
<button
type="button"
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
onClick={() => inputRef.current?.click()}
>
<span className="flex items-center gap-2">
<RiAttachment2 className="size-4" />
Adicionar anexo
</span>
<span className="text-[11px]">
PDF, JPEG, PNG ou WebP · máx. 50 MB
</span>
</button>
)}
</div>
);
}

View File

@@ -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 <RiFilePdfLine className="size-4 text-red-500 shrink-0" />;
if (mimeType.startsWith("image/"))
return <RiFileImageLine className="size-4 text-blue-500 shrink-0" />;
return <RiFileLine className="size-4 text-muted-foreground shrink-0" />;
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="flex h-[92vh] w-[min(96vw,1400px)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:p-0"
>
<DialogHeader className="flex-row items-center justify-between gap-3 border-b px-4 py-3 sm:px-5">
<div className="min-w-0">
<DialogTitle
className="truncate text-sm font-medium"
title={fileName}
>
{fileName}
</DialogTitle>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="icon" asChild>
<a
href={url}
target="_blank"
rel="noreferrer"
download={fileName}
>
<RiDownloadLine className="size-4" />
</a>
</Button>
<Button type="button" variant="ghost" size="icon" asChild>
<a href={url} target="_blank" rel="noreferrer">
<RiExternalLinkLine className="size-4" />
</a>
</Button>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Fechar
</Button>
</DialogClose>
</div>
</DialogHeader>
<div className="min-h-0 min-w-0 flex-1">
{isPdf && (
<iframe
src={url}
className="h-full w-full border-0 bg-background"
title={fileName}
/>
)}
{isImage && (
<div className="flex h-full w-full items-center justify-center bg-black/85 p-4 sm:p-6">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={fileName}
className="max-h-full max-w-full rounded-md object-contain"
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
interface AttachmentItemProps {
attachmentId: string;
transactionId: string;
fileName: string;
fileSize: number;
mimeType: string;
url: string;
onDeleted: () => void;
readonly?: boolean;
}
export function AttachmentItem({
attachmentId,
transactionId,
fileName,
fileSize,
mimeType,
url,
onDeleted,
readonly = false,
}: AttachmentItemProps) {
const [isPending, startTransition] = useTransition();
const [previewOpen, setPreviewOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const canPreview =
mimeType === "application/pdf" || mimeType.startsWith("image/");
function handleDelete() {
startTransition(async () => {
const result = await detachTransactionAttachmentAction({
attachmentId,
transactionId,
});
if (result.success) {
toast.success(result.message);
onDeleted();
} else {
toast.error(result.error);
}
});
setConfirmOpen(false);
}
return (
<>
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
<AttachmentIcon mimeType={mimeType} />
{canPreview ? (
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => setPreviewOpen(true)}
title={fileName}
>
<p className="truncate font-medium hover:underline">{fileName}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(fileSize)}
</p>
</button>
) : (
<div className="flex-1 min-w-0">
<p className="truncate font-medium">{fileName}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(fileSize)}
</p>
</div>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
asChild
>
<a href={url} target="_blank" rel="noreferrer" download={fileName}>
<RiDownloadLine className="size-4" />
</a>
</Button>
{!readonly && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 text-destructive hover:text-destructive"
onClick={() => setConfirmOpen(true)}
disabled={isPending}
>
<RiDeleteBinLine className="size-4" />
</Button>
)}
</div>
{canPreview && (
<AttachmentPreview
open={previewOpen}
onOpenChange={setPreviewOpen}
fileName={fileName}
mimeType={mimeType}
url={url}
/>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Remover anexo</DialogTitle>
<DialogDescription>
Tem certeza que deseja remover{" "}
<span className="break-all font-medium text-foreground">
{fileName}
</span>
?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={isPending}>
Cancelar
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending}
>
{isPending ? "Removendo..." : "Remover"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
import { AttachmentItem } from "./attachment-item";
import { AttachmentUpload } from "./attachment-upload";
type AttachmentRow = {
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
createdAt: Date;
url: string;
};
interface AttachmentSectionProps {
transactionId: string;
seriesId: string | null;
readonly?: boolean;
onLoaded?: (count: number) => void;
}
export function AttachmentSection({
transactionId,
seriesId,
readonly = false,
onLoaded,
}: AttachmentSectionProps) {
const [items, setItems] = useState<AttachmentRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const load = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchTransactionAttachmentsAction(transactionId);
setItems(data);
onLoaded?.(data.length);
} finally {
setIsLoading(false);
}
}, [transactionId, onLoaded]);
useEffect(() => {
load();
}, [load]);
return (
<div className="min-w-0 space-y-2 overflow-hidden">
{isLoading ? (
<p className="text-xs text-muted-foreground">Carregando...</p>
) : (
<>
{items.length > 0 ? (
<div className="min-w-0 space-y-1.5">
{items.map((item) => (
<AttachmentItem
key={item.attachmentId}
attachmentId={item.attachmentId}
transactionId={transactionId}
fileName={item.fileName}
fileSize={item.fileSize}
mimeType={item.mimeType}
url={item.url}
onDeleted={load}
readonly={readonly}
/>
))}
</div>
) : (
readonly && (
<p className="text-xs text-muted-foreground">Nenhum anexo.</p>
)
)}
{!readonly && (
<AttachmentUpload
transactionId={transactionId}
seriesId={seriesId}
onUploaded={load}
/>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { RiAttachment2 } from "@remixicon/react";
import { useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import {
confirmAttachmentUploadAction,
getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments";
import {
ALLOWED_MIME_TYPES,
MAX_FILE_SIZE,
} from "@/features/transactions/attachments-config";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import { Label } from "@/shared/components/ui/label";
interface AttachmentUploadProps {
transactionId: string;
seriesId: string | null;
onUploaded: () => void;
}
export function AttachmentUpload({
transactionId,
seriesId,
onUploaded,
}: AttachmentUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isPending, startTransition] = useTransition();
const [applyToSeries, setApplyToSeries] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!inputRef.current) return;
inputRef.current.value = "";
if (!file) return;
if (
!ALLOWED_MIME_TYPES.includes(
file.type as (typeof ALLOWED_MIME_TYPES)[number],
)
) {
toast.error(
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
);
return;
}
if (file.size > MAX_FILE_SIZE) {
toast.error("O arquivo deve ter no máximo 50MB.");
return;
}
if (seriesId) {
setPendingFile(file);
} else {
uploadFile(file, false);
}
}
function uploadFile(file: File, toSeries: boolean) {
startTransition(async () => {
const presignResult = await getPresignedUploadUrlAction({
fileName: file.name,
mimeType: file.type,
fileSize: file.size,
transactionId,
});
if (!presignResult.success) {
toast.error(presignResult.error ?? "Erro ao iniciar upload.");
return;
}
const uploadResponse = await fetch(presignResult.presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!uploadResponse.ok) {
toast.error("Erro ao enviar o arquivo. Tente novamente.");
return;
}
const confirmResult = await confirmAttachmentUploadAction({
uploadToken: presignResult.uploadToken,
applyToSeries: toSeries,
});
if (confirmResult.success) {
toast.success(confirmResult.message);
setPendingFile(null);
setApplyToSeries(false);
onUploaded();
} else {
toast.error(confirmResult.error);
}
});
}
function handleConfirmPending() {
if (pendingFile) uploadFile(pendingFile, applyToSeries);
}
function handleCancelPending() {
setPendingFile(null);
setApplyToSeries(false);
}
if (pendingFile) {
return (
<div className="min-w-0 space-y-2 rounded-md border border-dashed p-3 text-sm">
<div className="min-w-0 overflow-hidden">
<p className="truncate font-medium" title={pendingFile.name}>
{pendingFile.name}
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="apply-series"
checked={applyToSeries}
onCheckedChange={(v) => setApplyToSeries(Boolean(v))}
/>
<Label htmlFor="apply-series" className="cursor-pointer text-xs">
Aplicar a todas as parcelas da série
</Label>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
onClick={handleConfirmPending}
disabled={isPending}
>
{isPending ? "Enviando..." : "Confirmar"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleCancelPending}
disabled={isPending}
>
Cancelar
</Button>
</div>
</div>
);
}
return (
<>
<input
ref={inputRef}
type="file"
className="hidden"
accept={ALLOWED_MIME_TYPES.join(",")}
onChange={handleFileChange}
/>
<button
type="button"
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
onClick={() => inputRef.current?.click()}
disabled={isPending}
>
<span className="flex items-center gap-2">
<RiAttachment2 className="size-4" />
{isPending ? "Enviando..." : "Adicionar anexo"}
</span>
{!isPending && (
<span className="text-xs">PDF, JPEG, PNG ou WebP · máx. 50 MB</span>
)}
</button>
</>
);
}

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { import {
currencyFormatter, currencyFormatter,
formatCondition, formatCondition,
@@ -21,6 +22,7 @@ import {
import { Separator } from "@/shared/components/ui/separator"; import { Separator } from "@/shared/components/ui/separator";
import { parseLocalDateString } from "@/shared/utils/date"; import { parseLocalDateString } from "@/shared/utils/date";
import { getPaymentMethodIcon } from "@/shared/utils/icons"; import { getPaymentMethodIcon } from "@/shared/utils/icons";
import { AttachmentSection } from "../attachments/attachment-section";
import { InstallmentTimeline } from "../shared/installment-timeline"; import { InstallmentTimeline } from "../shared/installment-timeline";
import type { TransactionItem } from "../types"; import type { TransactionItem } from "../types";
@@ -37,6 +39,12 @@ export function TransactionDetailsDialog({
transaction, transaction,
onEdit, onEdit,
}: TransactionDetailsDialogProps) { }: TransactionDetailsDialogProps) {
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
useEffect(() => {
setAttachmentCount(null);
}, [transaction?.id]);
if (!transaction) return null; if (!transaction) return null;
const isInstallment = const isInstallment =
@@ -63,7 +71,7 @@ export function TransactionDetailsDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl"> <DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{transaction.name}</DialogTitle> <DialogTitle>{transaction.name}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -71,57 +79,18 @@ export function TransactionDetailsDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-[60vh] overflow-y-auto text-sm"> <div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
<div className="grid gap-3"> <div className="min-w-0 space-y-4">
<ul className="grid gap-3"> <section className="rounded-lg border bg-muted/20 p-3">
<DetailRow <div className="flex items-start justify-between gap-3">
label="Período" <div className="min-w-0">
value={formatPeriod(transaction.period)} <p className="text-xs uppercase tracking-wide text-muted-foreground">
/> Resumo
</p>
<li className="flex items-center justify-between"> <p className="mt-1 text-2xl font-semibold">
<span className="text-muted-foreground"> {currencyFormatter.format(valorTotal)}
Forma de Pagamento </p>
</span> </div>
<span className="flex items-center gap-1.5">
{getPaymentMethodIcon(transaction.paymentMethod)}
<span>{transaction.paymentMethod}</span>
</span>
</li>
<DetailRow
label={transaction.cartaoName ? "Cartão" : "Conta"}
value={transaction.cartaoName ?? transaction.contaName ?? "—"}
/>
<DetailRow
label="Categoria"
value={transaction.categoriaName ?? "—"}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Tipo de Transação</span>
<TransactionTypeBadge
kind={
transaction.categoriaName === "Saldo inicial"
? "Saldo inicial"
: transaction.transactionType
}
/>
</li>
<DetailRow
label="Condição"
value={formatCondition(transaction.condition)}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Responsável</span>
<span>{transaction.pagadorName}</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Status</span>
<Badge <Badge
variant="secondary" variant="secondary"
className={ className={
@@ -132,75 +101,140 @@ export function TransactionDetailsDialog({
> >
{transaction.isSettled ? "Pago" : "Pendente"} {transaction.isSettled ? "Pago" : "Pendente"}
</Badge> </Badge>
</li> </div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{isBoleto && transaction.dueDate && ( <TransactionTypeBadge
<DetailRow kind={
label="Vencimento" transaction.categoriaName === "Saldo inicial"
value={formatDate(transaction.dueDate)} ? "Saldo inicial"
: transaction.transactionType
}
/>
<span>{formatCondition(transaction.condition)}</span>
</div>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Detalhes
</h3>
<ul className="min-w-0 grid gap-2 rounded-lg border p-3">
<DetailRow
label="Período"
value={formatPeriod(transaction.period)}
/> />
)}
{transaction.isDivided && (
<li className="flex items-center justify-between"> <li className="flex items-center justify-between">
<span className="text-muted-foreground">Divisão</span> <span className="text-muted-foreground">
<Badge variant="outline">Dividido</Badge> Forma de Pagamento
</span>
<span className="flex items-center gap-1.5">
{getPaymentMethodIcon(transaction.paymentMethod)}
<span>{transaction.paymentMethod}</span>
</span>
</li> </li>
)}
{transaction.note && ( <DetailRow
<li className="flex flex-col gap-1"> label={transaction.cartaoName ? "Cartão" : "Conta"}
<span className="text-muted-foreground">Notas</span> value={transaction.cartaoName ?? transaction.contaName ?? "—"}
<span className="text-foreground">{transaction.note}</span> />
<DetailRow
label="Categoria"
value={transaction.categoriaName ?? "—"}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Responsável</span>
<span>{transaction.pagadorName}</span>
</li> </li>
)}
</ul>
<ul className="mb-2 grid gap-3"> {isBoleto && transaction.dueDate && (
{isInstallment && ( <DetailRow
<li className="mt-4"> label="Vencimento"
<InstallmentTimeline value={formatDate(transaction.dueDate)}
purchaseDate={parseLocalDateString(
transaction.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={transaction.period}
/> />
</li> )}
)}
<DetailRow {transaction.isDivided && (
label={isInstallment ? "Valor da Parcela" : "Valor"} <li className="flex items-center justify-between">
value={currencyFormatter.format(valorParcela)} <span className="text-muted-foreground">Divisão</span>
/> <Badge variant="outline">Dividido</Badge>
</li>
)}
</ul>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Valores
</h3>
<ul className="min-w-0 grid gap-2 rounded-lg border p-3">
{isInstallment && (
<li className="mb-1">
<InstallmentTimeline
purchaseDate={parseLocalDateString(
transaction.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={transaction.period}
/>
</li>
)}
{isInstallment && (
<DetailRow <DetailRow
label="Valor Restante" label={isInstallment ? "Valor da Parcela" : "Valor"}
value={currencyFormatter.format(valorRestante)} value={currencyFormatter.format(valorParcela)}
/> />
)}
{transaction.recurrenceCount && ( {isInstallment && (
<DetailRow <DetailRow
label="Quantidade de Recorrências" label="Valor Restante"
value={`${transaction.recurrenceCount} meses`} value={currencyFormatter.format(valorRestante)}
/> />
)} )}
{!isInstallment && <Separator className="my-2" />} {transaction.recurrenceCount && (
<DetailRow
label="Quantidade de Recorrências"
value={`${transaction.recurrenceCount} meses`}
/>
)}
</ul>
</section>
<li className="flex items-center justify-between font-semibold"> {transaction.note ? (
<span className="text-muted-foreground">Total da Compra</span> <section className="space-y-2">
<span className="text-lg"> <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{currencyFormatter.format(valorTotal)} Notas
</span> </h3>
</li> <div className="rounded-lg border p-3 text-foreground">
</ul> {transaction.note}
</div>
</section>
) : null}
{attachmentCount !== 0 && (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Anexos
</h3>
<div className="min-w-0">
<AttachmentSection
transactionId={transaction.id}
seriesId={transaction.seriesId}
readonly
onLoaded={setAttachmentCount}
/>
</div>
</section>
)}
</div> </div>
</div> </div>
<Separator />
<DialogFooter> <DialogFooter>
{onEdit && !transaction.readonly && ( {onEdit && !transaction.readonly && (
<Button variant="outline" onClick={handleEdit}> <Button variant="outline" onClick={handleEdit}>
@@ -223,9 +257,9 @@ interface DetailRowProps {
function DetailRow({ label, value }: DetailRowProps) { function DetailRow({ label, value }: DetailRowProps) {
return ( return (
<li className="flex items-center justify-between"> <li className="min-w-0 flex items-center justify-between gap-3">
<span className="text-muted-foreground">{label}</span> <span className="text-muted-foreground">{label}</span>
<span>{value}</span> <span className="min-w-0 truncate">{value}</span>
</li> </li>
); );
} }

View File

@@ -6,6 +6,7 @@ import {
RiArrowLeftSLine, RiArrowLeftSLine,
RiArrowRightDoubleLine, RiArrowRightDoubleLine,
RiArrowRightSLine, RiArrowRightSLine,
RiAttachment2,
RiBankCard2Line, RiBankCard2Line,
RiChat1Line, RiChat1Line,
RiCheckboxBlankCircleLine, RiCheckboxBlankCircleLine,
@@ -115,6 +116,14 @@ type BuildColumnsArgs = {
showActions: boolean; showActions: boolean;
}; };
function getPaymentMethodTableLabel(method: string) {
if (method === "Transferência bancária") {
return "Transf. bancária";
}
return method;
}
const buildColumns = ({ const buildColumns = ({
currentUserId, currentUserId,
noteAsColumn, noteAsColumn,
@@ -182,6 +191,7 @@ const buildColumns = ({
note, note,
isDivided, isDivided,
isAnticipated, isAnticipated,
hasAttachments,
} = row.original; } = row.original;
const installmentBadge = const installmentBadge =
@@ -191,7 +201,7 @@ const buildColumns = ({
const isBoleto = paymentMethod === "Boleto" && dueDate; const isBoleto = paymentMethod === "Boleto" && dueDate;
const dueDateLabel = const dueDateLabel =
isBoleto && dueDate ? `Venc. ${formatDate(dueDate)}` : null; isBoleto && dueDate ? `venc. ${formatDate(dueDate)}` : null;
const hasNote = Boolean(note?.trim().length); const hasNote = Boolean(note?.trim().length);
const isLastInstallment = const isLastInstallment =
currentInstallment === installmentCount && currentInstallment === installmentCount &&
@@ -201,13 +211,18 @@ const buildColumns = ({
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<EstablishmentLogo name={name} size={28} /> <EstablishmentLogo name={name} size={28} />
<span className="flex flex-col">
<span className="text-[11px] text-muted-foreground"> <span className="flex flex-col py-0.5">
<span className="text-xs text-muted-foreground flex items-center gap-2">
{formatDate(purchaseDate)} {formatDate(purchaseDate)}
{dueDateLabel ? (
<span className="text-primary">{dueDateLabel}</span>
) : null}
</span> </span>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="line-clamp-2 max-w-[160px] font-semibold truncate"> <span className="line-clamp-2 max-w-[180px] font-bold truncate">
{name} {name}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -259,12 +274,6 @@ const buildColumns = ({
</Badge> </Badge>
) : null} ) : null}
{dueDateLabel ? (
<Badge variant="outline" className="px-2 text-xs">
{dueDateLabel}
</Badge>
) : null}
{isAnticipated && ( {isAnticipated && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -301,6 +310,21 @@ const buildColumns = ({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
{hasAttachments ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiAttachment2
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Possui anexos</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Possui anexos</TooltipContent>
</Tooltip>
) : null}
</span> </span>
); );
}, },
@@ -366,7 +390,7 @@ const buildColumns = ({
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{icon} {icon}
<span>{method}</span> <span>{getPaymentMethodTableLabel(method)}</span>
</span> </span>
); );
}, },

View File

@@ -33,6 +33,7 @@ export type TransactionItem = {
isAnticipated: boolean; isAnticipated: boolean;
anticipationId: string | null; anticipationId: string | null;
seriesId: string | null; seriesId: string | null;
hasAttachments: boolean;
readonly?: boolean; readonly?: boolean;
}; };

View File

@@ -402,6 +402,7 @@ type TransactionRowWithRelations = Partial<typeof transactions.$inferSelect> & {
financialAccount?: AccountRow | null; financialAccount?: AccountRow | null;
card?: CardRow | null; card?: CardRow | null;
category?: CategoryRow | null; category?: CategoryRow | null;
hasAttachments?: boolean;
}; };
export const mapTransactionsData = (rows: TransactionRowWithRelations[]) => export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
@@ -442,6 +443,7 @@ export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
isAnticipated: item.isAnticipated ?? false, isAnticipated: item.isAnticipated ?? false,
anticipationId: item.anticipationId ?? null, anticipationId: item.anticipationId ?? null,
seriesId: item.seriesId ?? null, seriesId: item.seriesId ?? null,
hasAttachments: item.hasAttachments ?? false,
readonly: readonly:
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) || Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
item.category?.name === "Saldo inicial" || item.category?.name === "Saldo inicial" ||

View File

@@ -15,6 +15,7 @@ import {
categories, categories,
financialAccounts, financialAccounts,
payers, payers,
transactionAttachments,
transactions, transactions,
} from "@/db/schema"; } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants"; import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
@@ -74,6 +75,7 @@ const mapTransactionRows = (
financialAccount: typeof financialAccounts.$inferSelect | null; financialAccount: typeof financialAccounts.$inferSelect | null;
card: typeof cards.$inferSelect | null; card: typeof cards.$inferSelect | null;
category: typeof categories.$inferSelect | null; category: typeof categories.$inferSelect | null;
hasAttachments: boolean;
}[], }[],
) => ) =>
transactionRows.map((row) => ({ transactionRows.map((row) => ({
@@ -82,6 +84,7 @@ const mapTransactionRows = (
financialAccount: row.financialAccount, financialAccount: row.financialAccount,
card: row.card, card: row.card,
category: row.category, category: row.category,
hasAttachments: row.hasAttachments,
})); }));
async function selectTransactionsWithRelations({ async function selectTransactionsWithRelations({
@@ -98,6 +101,10 @@ async function selectTransactionsWithRelations({
financialAccount: financialAccounts, financialAccount: financialAccounts,
card: cards, card: cards,
category: categories, category: categories,
hasAttachments: sql<boolean>`EXISTS (
SELECT 1 FROM ${transactionAttachments}
WHERE ${transactionAttachments.transactionId} = ${transactions.id}
)`,
}) })
.from(transactions) .from(transactions)
.leftJoin(payers, eq(transactions.payerId, payers.id)) .leftJoin(payers, eq(transactions.payerId, payers.id))

View File

@@ -0,0 +1,52 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { S3_BUCKET, s3 } from "./s3-client";
export async function createPresignedPutUrl(
fileKey: string,
mimeType: string,
): Promise<string> {
const command = new PutObjectCommand({
Bucket: S3_BUCKET,
Key: fileKey,
ContentType: mimeType,
});
return getSignedUrl(s3, command, { expiresIn: 300 }); // 5 minutos
}
export async function createPresignedGetUrl(fileKey: string): Promise<string> {
const command = new GetObjectCommand({
Bucket: S3_BUCKET,
Key: fileKey,
});
return getSignedUrl(s3, command, { expiresIn: 3600 }); // 1 hora
}
export async function headS3Object(fileKey: string): Promise<{
contentLength: number | null;
contentType: string | null;
}> {
const command = new HeadObjectCommand({
Bucket: S3_BUCKET,
Key: fileKey,
});
const result = await s3.send(command);
return {
contentLength: result.ContentLength ?? null,
contentType: result.ContentType ?? null,
};
}
export async function deleteS3Object(fileKey: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: S3_BUCKET,
Key: fileKey,
});
await s3.send(command);
}

View File

@@ -0,0 +1,13 @@
import { S3Client } from "@aws-sdk/client-s3";
export const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT ?? "",
region: process.env.S3_REGION ?? "auto",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
},
forcePathStyle: true,
});
export const S3_BUCKET = process.env.S3_BUCKET ?? "attachments";