diff --git a/src/app/api/attachments/[attachmentId]/presign/route.ts b/src/app/api/attachments/[attachmentId]/presign/route.ts index 185b963..794aded 100644 --- a/src/app/api/attachments/[attachmentId]/presign/route.ts +++ b/src/app/api/attachments/[attachmentId]/presign/route.ts @@ -5,6 +5,10 @@ import { getUserId } from "@/shared/lib/auth/server"; import { db } from "@/shared/lib/db"; import { createPresignedGetUrl } from "@/shared/lib/storage/presign"; +const PRIVATE_RESPONSE_HEADERS = { + "Cache-Control": "private, no-store", +}; + export async function GET( _request: Request, { params }: { params: Promise<{ attachmentId: string }> }, @@ -19,9 +23,20 @@ export async function GET( ); if (!row) { - return NextResponse.json({ error: "Not found" }, { status: 404 }); + return NextResponse.json( + { error: "Not found" }, + { + status: 404, + headers: PRIVATE_RESPONSE_HEADERS, + }, + ); } const url = await createPresignedGetUrl(row.fileKey); - return NextResponse.json({ url }); + return NextResponse.json( + { url }, + { + headers: PRIVATE_RESPONSE_HEADERS, + }, + ); } diff --git a/src/app/api/auth/device/token/route.ts b/src/app/api/auth/device/token/route.ts index 8ae8c12..282aa28 100644 --- a/src/app/api/auth/device/token/route.ts +++ b/src/app/api/auth/device/token/route.ts @@ -1,5 +1,5 @@ import { headers } from "next/headers"; -import { NextResponse } from "next/server"; +import { connection, NextResponse } from "next/server"; import { z } from "zod"; import { apiTokens } from "@/db/schema"; import { @@ -16,14 +16,17 @@ const createTokenSchema = z.object({ }); export async function POST(request: Request) { + await connection(); + + // Verificar autenticação via sessão web + const requestHeaders = new Headers(await headers()); + const session = await auth.api.getSession({ headers: requestHeaders }); + + if (!session?.user) { + return NextResponse.json({ error: "Não autenticado" }, { status: 401 }); + } + try { - // Verificar autenticação via sessão web - const session = await auth.api.getSession({ headers: await headers() }); - - if (!session?.user) { - return NextResponse.json({ error: "Não autenticado" }, { status: 401 }); - } - // Validar body const body = await request.json(); const { name, deviceId } = createTokenSchema.parse(body); diff --git a/src/app/api/auth/device/tokens/[tokenId]/route.ts b/src/app/api/auth/device/tokens/[tokenId]/route.ts index 84bf88d..7b206ca 100644 --- a/src/app/api/auth/device/tokens/[tokenId]/route.ts +++ b/src/app/api/auth/device/tokens/[tokenId]/route.ts @@ -1,6 +1,6 @@ import { and, eq } from "drizzle-orm"; import { headers } from "next/headers"; -import { NextResponse } from "next/server"; +import { connection, NextResponse } from "next/server"; import { apiTokens } from "@/db/schema"; import { auth } from "@/shared/lib/auth/config"; import { db } from "@/shared/lib/db"; @@ -10,16 +10,19 @@ interface RouteParams { } export async function DELETE(_request: Request, { params }: RouteParams) { + await connection(); + + const { tokenId } = await params; + + // Verificar autenticação via sessão web + const requestHeaders = new Headers(await headers()); + const session = await auth.api.getSession({ headers: requestHeaders }); + + if (!session?.user) { + return NextResponse.json({ error: "Não autenticado" }, { status: 401 }); + } + try { - const { tokenId } = await params; - - // Verificar autenticação via sessão web - const session = await auth.api.getSession({ headers: await headers() }); - - if (!session?.user) { - return NextResponse.json({ error: "Não autenticado" }, { status: 401 }); - } - // Verificar se token pertence ao usuário const token = await db.query.apiTokens.findFirst({ where: and( diff --git a/src/app/api/auth/device/tokens/route.ts b/src/app/api/auth/device/tokens/route.ts index e6c386e..fcd9e63 100644 --- a/src/app/api/auth/device/tokens/route.ts +++ b/src/app/api/auth/device/tokens/route.ts @@ -1,19 +1,22 @@ import { and, desc, eq, isNull } from "drizzle-orm"; import { headers } from "next/headers"; -import { NextResponse } from "next/server"; +import { connection, NextResponse } from "next/server"; import { apiTokens } from "@/db/schema"; import { auth } from "@/shared/lib/auth/config"; import { db } from "@/shared/lib/db"; export async function GET() { + await connection(); + + // Verificar autenticação via sessão web + const requestHeaders = new Headers(await headers()); + const session = await auth.api.getSession({ headers: requestHeaders }); + + if (!session?.user) { + return NextResponse.json({ error: "Não autenticado" }, { status: 401 }); + } + try { - // Verificar autenticação via sessão web - const session = await auth.api.getSession({ headers: await headers() }); - - if (!session?.user) { - return NextResponse.json({ error: "Não autenticado" }, { status: 401 }); - } - // Buscar tokens ativos do usuário const activeTokens = await db .select({ diff --git a/src/app/api/insights/saved/route.ts b/src/app/api/insights/saved/route.ts new file mode 100644 index 0000000..bf49b87 --- /dev/null +++ b/src/app/api/insights/saved/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { + fetchSavedInsights, + savedInsightsPeriodSchema, +} from "@/features/insights/queries"; +import { getUserId } from "@/shared/lib/auth/server"; + +const PRIVATE_RESPONSE_HEADERS = { + "Cache-Control": "private, no-store", +}; + +export async function GET(request: Request) { + const period = new URL(request.url).searchParams.get("period") ?? ""; + const validatedPeriod = savedInsightsPeriodSchema.safeParse(period); + + if (!validatedPeriod.success) { + return NextResponse.json( + { + error: validatedPeriod.error.issues[0]?.message ?? "Período inválido.", + }, + { + status: 400, + headers: PRIVATE_RESPONSE_HEADERS, + }, + ); + } + + const userId = await getUserId(); + const insights = await fetchSavedInsights(userId, validatedPeriod.data); + + return NextResponse.json(insights, { + headers: PRIVATE_RESPONSE_HEADERS, + }); +} diff --git a/src/app/api/transactions/[transactionId]/attachments/route.ts b/src/app/api/transactions/[transactionId]/attachments/route.ts new file mode 100644 index 0000000..cee34f4 --- /dev/null +++ b/src/app/api/transactions/[transactionId]/attachments/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries"; +import { getUserId } from "@/shared/lib/auth/server"; + +const PRIVATE_RESPONSE_HEADERS = { + "Cache-Control": "private, no-store", +}; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ transactionId: string }> }, +) { + const [userId, { transactionId }] = await Promise.all([getUserId(), params]); + const attachments = await fetchTransactionAttachments(userId, transactionId); + + return NextResponse.json(attachments, { + headers: PRIVATE_RESPONSE_HEADERS, + }); +} diff --git a/src/app/api/transactions/installments/[seriesId]/anticipations/route.ts b/src/app/api/transactions/installments/[seriesId]/anticipations/route.ts new file mode 100644 index 0000000..adae96b --- /dev/null +++ b/src/app/api/transactions/installments/[seriesId]/anticipations/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries"; +import { getUserId } from "@/shared/lib/auth/server"; + +const PRIVATE_RESPONSE_HEADERS = { + "Cache-Control": "private, no-store", +}; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ seriesId: string }> }, +) { + try { + const [userId, { seriesId }] = await Promise.all([getUserId(), params]); + const anticipations = await fetchInstallmentAnticipations(userId, seriesId); + + return NextResponse.json(anticipations, { + headers: PRIVATE_RESPONSE_HEADERS, + }); + } catch (error) { + console.error("Erro ao carregar histórico de antecipações:", error); + + return NextResponse.json( + { + error: "Erro ao carregar histórico de antecipações.", + }, + { + status: 400, + headers: PRIVATE_RESPONSE_HEADERS, + }, + ); + } +} diff --git a/src/features/transactions/anticipation-actions.ts b/src/features/transactions/anticipation-actions.ts index 540282d..43b9766 100644 --- a/src/features/transactions/anticipation-actions.ts +++ b/src/features/transactions/anticipation-actions.ts @@ -131,6 +131,46 @@ export async function createInstallmentAnticipationAction( const user = await getUser(); const data = createAnticipationSchema.parse(input); + if (data.payerId || data.categoryId) { + const [payer, category] = await Promise.all([ + data.payerId + ? db + .select({ id: payers.id }) + .from(payers) + .where( + and(eq(payers.id, data.payerId), eq(payers.userId, user.id)), + ) + .limit(1) + : Promise.resolve([]), + data.categoryId + ? db + .select({ id: categories.id }) + .from(categories) + .where( + and( + eq(categories.id, data.categoryId), + eq(categories.userId, user.id), + ), + ) + .limit(1) + : Promise.resolve([]), + ]); + + if (data.payerId && payer.length === 0) { + return { + success: false, + error: "Pagador inválido para esta conta.", + }; + } + + if (data.categoryId && category.length === 0) { + return { + success: false, + error: "Categoria inválida para esta conta.", + }; + } + } + // 1. Validar parcelas selecionadas const installments = await db.query.transactions.findMany({ where: and( diff --git a/src/features/transactions/anticipation-queries.ts b/src/features/transactions/anticipation-queries.ts new file mode 100644 index 0000000..8f322e1 --- /dev/null +++ b/src/features/transactions/anticipation-queries.ts @@ -0,0 +1,99 @@ +import { and, desc, eq } from "drizzle-orm"; +import { + categories, + installmentAnticipations, + payers, + transactions, +} from "@/db/schema"; +import { db } from "@/shared/lib/db"; +import { uuidSchema } from "@/shared/lib/schemas/common"; + +export type InstallmentAnticipationListItem = { + id: string; + anticipationPeriod: string; + anticipationDate: string; + installmentCount: number; + totalAmount: string; + discount: string; + transactionId: string; + note: string | null; + transaction: { + isSettled: boolean | null; + } | null; + payer: { + name: string; + } | null; + category: { + name: string; + } | null; +}; + +export async function fetchInstallmentAnticipations( + userId: string, + seriesId: string, +): Promise { + const validatedSeriesId = uuidSchema("Série").parse(seriesId); + + const anticipations = await db + .select({ + id: installmentAnticipations.id, + anticipationPeriod: installmentAnticipations.anticipationPeriod, + anticipationDate: installmentAnticipations.anticipationDate, + installmentCount: installmentAnticipations.installmentCount, + totalAmount: installmentAnticipations.totalAmount, + discount: installmentAnticipations.discount, + transactionId: installmentAnticipations.transactionId, + note: installmentAnticipations.note, + transactionRecordId: transactions.id, + transactionIsSettled: transactions.isSettled, + payerName: payers.name, + categoryName: categories.name, + }) + .from(installmentAnticipations) + .leftJoin( + transactions, + and( + eq(installmentAnticipations.transactionId, transactions.id), + eq(transactions.userId, userId), + ), + ) + .leftJoin( + payers, + and( + eq(installmentAnticipations.payerId, payers.id), + eq(payers.userId, userId), + ), + ) + .leftJoin( + categories, + and( + eq(installmentAnticipations.categoryId, categories.id), + eq(categories.userId, userId), + ), + ) + .where( + and( + eq(installmentAnticipations.seriesId, validatedSeriesId), + eq(installmentAnticipations.userId, userId), + ), + ) + .orderBy(desc(installmentAnticipations.createdAt)); + + return anticipations.map((anticipation) => ({ + id: anticipation.id, + anticipationPeriod: anticipation.anticipationPeriod, + anticipationDate: anticipation.anticipationDate.toISOString(), + installmentCount: anticipation.installmentCount, + totalAmount: anticipation.totalAmount, + discount: anticipation.discount, + transactionId: anticipation.transactionId, + note: anticipation.note, + transaction: anticipation.transactionRecordId + ? { isSettled: anticipation.transactionIsSettled } + : null, + payer: anticipation.payerName ? { name: anticipation.payerName } : null, + category: anticipation.categoryName + ? { name: anticipation.categoryName } + : null, + })); +} diff --git a/src/features/transactions/attachment-queries.ts b/src/features/transactions/attachment-queries.ts new file mode 100644 index 0000000..d21a09a --- /dev/null +++ b/src/features/transactions/attachment-queries.ts @@ -0,0 +1,66 @@ +import { and, eq } from "drizzle-orm"; +import { attachments, transactionAttachments, transactions } from "@/db/schema"; +import { db } from "@/shared/lib/db"; +import { createPresignedGetUrl } from "@/shared/lib/storage/presign"; + +export type TransactionAttachmentListItem = { + attachmentId: string; + fileName: string; + fileSize: number; + mimeType: string; + createdAt: string; + url: string; +}; + +export async function fetchTransactionAttachments( + userId: string, + transactionId: string, +): Promise { + const [transaction] = await db + .select({ id: transactions.id }) + .from(transactions) + .where( + and(eq(transactions.id, transactionId), eq(transactions.userId, userId)), + ); + + 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, userId), + ), + ) + .innerJoin( + attachments, + and( + eq(transactionAttachments.attachmentId, attachments.id), + eq(attachments.userId, userId), + ), + ) + .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.toISOString(), + url: await createPresignedGetUrl(row.fileKey), + })), + ); +} diff --git a/src/proxy.ts b/src/proxy.ts index 999c281..5561a42 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -33,6 +33,9 @@ export default async function proxy(request: NextRequest) { const hostname = request.headers.get("host")?.replace(/:\d+$/, ""); if (publicDomain && hostname === publicDomain) { + if (pathname.startsWith("/api/")) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } if (pathname !== "/") { return NextResponse.redirect(new URL("/", request.url)); } @@ -67,6 +70,7 @@ export const config = { // Apply middleware to protected and auth routes matcher: [ "/", + "/api/:path*", "/settings/:path*", "/notes/:path*", "/calendar/:path*", diff --git a/src/shared/lib/auth/config.ts b/src/shared/lib/auth/config.ts index 581ab7c..63ccd0d 100644 --- a/src/shared/lib/auth/config.ts +++ b/src/shared/lib/auth/config.ts @@ -57,6 +57,16 @@ export const auth = betterAuth({ autoSignIn: true, }, + // Rate limiting + rateLimit: { + window: 60, + max: 100, + customRules: { + "/sign-in/email": { window: 60, max: 5 }, + "/sign-up/email": { window: 60, max: 3 }, + }, + }, + // Database adapter (Drizzle + PostgreSQL) database: drizzleAdapter(db, { provider: "pg",