diff --git a/app/api/ai/analyze/route.ts b/app/api/ai/analyze/route.ts new file mode 100644 index 0000000..1da2345 --- /dev/null +++ b/app/api/ai/analyze/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { withAuth } from "@/lib/withAuth"; +import { limiters, enforce } from "@/lib/rate-limit"; +import { analyzeDeal } from "@/lib/ai"; + +export const POST = withAuth(async (req, user) => { + const { id } = await req.json(); // { id: "" } + + const { ok, headers } = await enforce(limiters.aiAnalyze, user.id); + if (!ok) return new Response("Too many requests", { status: 429, headers }); + + const result = await analyzeDeal(id); + return NextResponse.json({ data: result }, { status: 200, headers }); +}).__ratelimit("aiAnalyze"); diff --git a/app/api/auth/forgot-password/route.ts b/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..00c5794 --- /dev/null +++ b/app/api/auth/forgot-password/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { sendReset } from "@/lib/mailer"; +import { limiters, enforce } from "@/lib/rate-limit"; + +export async function POST(req: Request) { + const { email } = await req.json(); + + const { ok, headers } = await enforce(limiters.authForgot, email); + if (!ok) return new Response("Too many requests", { status: 429, headers }); + + await sendReset(email); + return NextResponse.json({ ok: true }, { status: 200, headers }); +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..9fa5dba --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { limiters, enforce } from "@/lib/rate-limit"; + +export async function GET(req: Request) { + const session = await auth(); + + const key = session?.user?.id ?? req.headers.get("x-forwarded-for") ?? undefined; + const { ok, headers } = await enforce(limiters.authSession, key); + if (!ok) return new Response("Too many requests", { status: 429, headers }); + + return NextResponse.json({ data: session ?? null }, { headers }); +} diff --git a/app/api/auth/signin/route.ts b/app/api/auth/signin/route.ts new file mode 100644 index 0000000..58413ac --- /dev/null +++ b/app/api/auth/signin/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { signIn } from "@/auth"; +import { limiters, enforce } from "@/lib/rate-limit"; + +export async function POST(req: Request) { + const { email, password } = await req.json(); + + /* ─ rate-limit ─ */ + const { ok, headers } = await enforce(limiters.authSignin, email); + if (!ok) return new Response("Too many sign-in attempts", { status: 429, headers }); + + const session = await signIn(email, password); + if (!session) { + return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); + } + return NextResponse.json({ data: session }, { status: 200, headers }); +} diff --git a/app/api/deals/[id]/comment/route.ts b/app/api/deals/[id]/comment/route.ts new file mode 100644 index 0000000..9684ceb --- /dev/null +++ b/app/api/deals/[id]/comment/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { withAuth } from "@/lib/withAuth"; +import { limiters, enforce } from "@/lib/rate-limit"; + + +export const PATCH = withAuth(async (req, user) => { + const { id } = (req as any).params as { id: string }; + const body = await req.json(); + + const { ok, headers } = await enforce(limiters.dealsUpdate, user.id); + if (!ok) return new Response("Too many requests", { status: 429, headers }); + + const deal = await prisma.deal.update({ where: { id }, data: body }); + return NextResponse.json({ data: deal }, { headers }); +}).__ratelimit("dealsUpdate"); + + +export const DELETE = withAuth(async (req, user) => { + const { id } = (req as any).params as { id: string }; + + const { ok, headers } = await enforce(limiters.dealsDelete, user.id); + if (!ok) return new Response("Too many requests", { status: 429, headers }); + + await prisma.deal.delete({ where: { id } }); + return new Response(null, { status: 204, headers }); +}).__ratelimit("dealsDelete"); diff --git a/app/api/deals/[id]/status/route.ts b/app/api/deals/[id]/status/route.ts new file mode 100644 index 0000000..372c6d6 --- /dev/null +++ b/app/api/deals/[id]/status/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { withAuth } from "@/lib/withAuth"; +import { limiters, enforce } from "@/lib/rate-limit"; + +export const POST = withAuth(async (req, user) => { + const { id } = (req as any).params as { id: string }; + const { status } = await req.json(); // { status: "OPEN" | … } + + const { ok, headers } = await enforce(limiters.dealsStatus, user.id); + if (!ok) return new Response("Too many requests", { status: 429, headers }); + + const deal = await prisma.deal.update({ where: { id }, data: { status } }); + return NextResponse.json({ data: deal }, { headers }); +}).__ratelimit("dealsStatus"); diff --git a/app/api/deals/route.ts b/app/api/deals/route.ts new file mode 100644 index 0000000..6ac3759 --- /dev/null +++ b/app/api/deals/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { withAuth } from "@/lib/withAuth"; +import { limiters, enforce } from "@/lib/rate-limit"; + + +async function listDeals() { + const deals = await prisma.deal.findMany({ + orderBy: { createdAt: "desc" }, + take: 50, + }); + return NextResponse.json({ data: deals }); +} +export const GET = withAuth(listDeals).__ratelimit("dealsRead"); + +export const POST = withAuth(async (req, user) => { + const values = await req.json(); + + const { ok, headers } = await enforce(limiters.dealsCreate, user.id); + if (!ok) return new Response("Too many requests", { status: 429, headers }); + + const deal = await prisma.deal.create({ + data: { ...values, userId: user.id }, + }); + return NextResponse.json({ data: deal }, { status: 201, headers }); +}).__ratelimit("dealsCreate"); diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..89b0c45 --- /dev/null +++ b/app/api/notifications/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { withAuth } from "@/lib/withAuth"; +import { limiters, enforce } from "@/lib/rate-limit"; + +export const GET = withAuth(async (_req, user) => { + const { ok, headers } = await enforce(limiters.notifications, user.id); + if (!ok) return new Response("Too many requests", { status: 429, headers }); + + const notes = await prisma.notification.findMany({ + where: { recipientId: user.id }, + orderBy: { createdAt: "desc" }, + take: 25, + }); + return NextResponse.json({ data: notes }, { headers }); +}).__ratelimit("notifications"); diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..93d3c78 --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { withAuth } from "@/lib/withAuth"; + +// 50 requests / minute +async function handler(req: Request) { + const q = new URL(req.url).searchParams.get("q") ?? ""; + + const matches = await prisma.deal.findMany({ + where: { title: { contains: q, mode: "insensitive" } }, + take: 25, + }); + + return NextResponse.json({ data: matches }); +} + +export const GET = withAuth(handler).__ratelimit("search"); diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..a72c8ea --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { withAuth } from "@/lib/withAuth"; +import { limiters, enforce } from "@/lib/rate-limit"; +import { uploadFile } from "@/lib/upload"; + +export const POST = withAuth(async (req, user) => { + const form = await req.formData(); + const file = form.get("file") as File; + + const { ok, headers } = await enforce(limiters.fileUpload, user.id); + if (!ok) return new Response("Too many uploads", { status: 429, headers }); + + const url = await uploadFile(file, user.id); + return NextResponse.json({ url }, { status: 201, headers }); +}).__ratelimit("fileUpload"); diff --git a/app/types/ratelimit.d.ts b/app/types/ratelimit.d.ts new file mode 100644 index 0000000..3b8eb6d --- /dev/null +++ b/app/types/ratelimit.d.ts @@ -0,0 +1,9 @@ +import { limiters } from "@/lib/rate-limit"; + + +declare global { + interface Function { + __ratelimit?: (name: keyof typeof limiters) => any; + } +} +export {}; diff --git a/lib/ai.ts b/lib/ai.ts new file mode 100644 index 0000000..e8de466 --- /dev/null +++ b/lib/ai.ts @@ -0,0 +1,7 @@ +export async function analyzeDeal(id: string) { + return { + id, + summary: "AI analysis coming soon", + score: Math.floor(Math.random() * 100), + }; +} \ No newline at end of file diff --git a/lib/mailer.ts b/lib/mailer.ts new file mode 100644 index 0000000..ee0e299 --- /dev/null +++ b/lib/mailer.ts @@ -0,0 +1,7 @@ +/* Swap in Resend, Postmark, SES, etc. later */ +export async function sendReset(email: string) { + console.log(`[dev] sending password-reset link to ${email}`); + // simulate latency + await new Promise((r) => setTimeout(r, 400)); + return true; +} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 0000000..6a2209a --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,38 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; + +const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, +}); + +export const limiters = { + global: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(50, "1 m") }), + dealsRead: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(50, "1 m") }), + search: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(50, "1 m") }), + comments: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(50, "1 m") }), + authSignin: new Ratelimit({ redis, limiter: Ratelimit.fixedWindow (5, "1 m") }), + authForgot: new Ratelimit({ redis, limiter: Ratelimit.fixedWindow (5, "1 h") }), + authSession: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(30, "1 m") }), + dealsCreate: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(10, "1 m") }), + dealsUpdate: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(20, "1 m") }), + dealsDelete: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, "1 m") }), + dealsStatus: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(30, "1 m") }), + aiAnalyze: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, "1 m") }), + fileUpload: new Ratelimit({ redis, limiter: Ratelimit.fixedWindow (10, "1 h") }), + notifications: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(30, "1 m") }), + admin: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, "1 m") }), +}; + +export async function enforce(limiter: Ratelimit, key: string | undefined) { + const id = key ?? "anonymous"; + const { success, limit, remaining, reset } = await limiter.limit(id); + return { + ok: success, + headers: { + "X-RateLimit-Limit": String(limit), + "X-RateLimit-Remaining": String(remaining), + "X-RateLimit-Reset": String(reset), + }, + }; +} diff --git a/lib/upload.ts b/lib/upload.ts new file mode 100644 index 0000000..fed42a5 --- /dev/null +++ b/lib/upload.ts @@ -0,0 +1,7 @@ +/* Replace with S3, Cloudflare R2, or whatever you prefer */ +export async function uploadFile(file: File, userId: string): Promise { + // NOTE: File is a web-standard File object in Next 13/14 routes. + // Here we just pretend it uploads and return a fake URL. + const safeName = encodeURIComponent(file.name); + return `https://example.com/uploads/${userId}/${Date.now()}_${safeName}`; +} diff --git a/lib/withAuth.ts b/lib/withAuth.ts index 10ab3cd..3a8355f 100644 --- a/lib/withAuth.ts +++ b/lib/withAuth.ts @@ -1,87 +1,77 @@ -import { auth } from "@/auth"; -import { getUserById } from "./queries"; -import { User } from "@prisma/client"; +/* eslint-disable @typescript-eslint/no-misused-promises */ +import { auth } from "@/auth"; +import { getUserById } from "./queries"; +import { User } from "@prisma/client"; +import { limiters, enforce } from "@/lib/rate-limit"; -async function getUser(req: Request) { - const sessionToken = await auth(); - if (!sessionToken) { - return undefined; - } +async function getUser(): Promise { + const session = await auth(); + if (!session) return undefined; - try { - const foundUser = await getUserById(sessionToken.user.id!); - return foundUser; - } catch (error) { - console.log(error); - return undefined; - } + const dbUser = await getUserById(session.user.id!); // returns User|null + return dbUser ?? undefined; } -/** - * Higher-order function to wrap API routes with authentication checks. - * It verifies the user session and fetches user data before executing the route. - * - * @param handler - The API route function to wrap. It receives the authenticated User object as its second argument, followed by the original arguments. - * @returns An asynchronous function that takes the original API route arguments, performs authentication, and then executes the handler. Returns the handler's result or an error object. - */ + +type RLFunc = F & { + __ratelimit: (name: keyof typeof limiters) => RLFunc; +}; + export function withAuth( - handler: (request: Request, user: User) => Promise, -) { - return async (request: Request) => { - // Note: Assuming getUser() can work without the request object - // or the request object is needed by getUser internally. - // If getUser doesn't need request, it can be called as getUser() - const user = await getUser(request); + handler: (req: Request, user: User) => Promise, +): RLFunc<(req: Request) => Promise> { + const wrapped = (async (req: Request) => { + const user = await getUser(); if (!user) { - return Response.json( - { error: "Unauthorized" }, - { - status: 401, - }, - ); + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const lim = (handler as any).__rl as keyof typeof limiters | undefined; + if (lim) { + const { ok, headers } = await enforce(limiters[lim], user.id); + if (!ok) return new Response("Too many requests", { status: 429, headers }); } - return handler(request, user); + + return handler(req, user); + }) as RLFunc<(req: Request) => Promise>; + + wrapped.__ratelimit = (name) => { + (handler as any).__rl = name; + return wrapped; }; + + return wrapped; } -/** - * Higher-order function to wrap Server Actions with authentication checks. - * It verifies the user session and fetches user data before executing the action. - * - * @template TArgs - Tuple type representing the arguments of the server action. - * @template TReturn - Return type of the server action. - * @param handler - The server action function to wrap. It receives the authenticated User object as its first argument, followed by the original arguments. - * @returns An asynchronous function that takes the original server action arguments, performs authentication, and then executes the handler. Returns the handler's result or an error object. - */ export function withAuthServerAction( - // The handler takes User as the first arg, then the original action's args handler: (user: User, ...args: TArgs) => Promise, -) { - // The returned function takes the original action's args - return async (...args: TArgs): Promise => { - // Fetch the user using the existing getUser logic (which uses auth()) - // No request object is passed here as server actions don't inherently have one - const user = await getUser(undefined as any); // Pass undefined or adjust getUser if it doesn't need request - if (!user) { - // Return an error object, common pattern for server actions - return { error: "Unauthorized" }; - // Alternatively, could throw: throw new Error("Unauthorized"); +): RLFunc<(...args: TArgs) => Promise> { + let limiter: keyof typeof limiters | undefined; + + const action = (async (...args: TArgs) => { + const user = await getUser(); + if (!user) return { error: "Unauthorized" }; + + if (limiter) { + const { ok } = await enforce(limiters[limiter], user.id); + if (!ok) return { error: "Too many requests" }; } try { - // Call the original handler with the authenticated user and the rest of the arguments return await handler(user, ...args); - } catch (error) { - // Catch errors from the handler execution - console.error("Error in authenticated server action:", error); - // Return an error object if the handler fails + } catch (err) { + console.error("[Action error]", err); return { error: - error instanceof Error - ? error.message - : "An unexpected error occurred during the action", + err instanceof Error ? err.message : "Unexpected error in action", }; - // Alternatively, rethrow: throw error; } + }) as RLFunc<(...args: TArgs) => Promise>; + + action.__ratelimit = (name) => { + limiter = name; + return action; }; + + return action; } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7092699..909558d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,8 @@ model User { isBlocked Boolean @default(false) UserActionLog UserActionLog[] Deal Deal[] + comments Comment[] + notifications Notification[] } model Account { @@ -77,6 +79,8 @@ model Deal { updatedAt DateTime @default(now()) @updatedAt SIM SIM[] AiScreening AiScreening[] + comments Comment[] + status String @default("OPEN") bitrixId String? bitrixCreatedAt DateTime? @@ -109,6 +113,28 @@ model SIM { updatedAt DateTime @updatedAt } +model Notification { + id String @id @default(cuid()) + recipientId String + recipient User @relation(fields: [recipientId], references: [id]) + title String + body String + isRead Boolean @default(false) + createdAt DateTime @default(now()) +} + +model Comment { + id String @id @default(cuid()) + body String + createdAt DateTime @default(now()) + + deal Deal? @relation(fields: [dealId], references: [id]) + dealId String? + + author User @relation(fields: [authorId], references: [id]) + authorId String +} + model Questionnaire { id String @id @default(cuid()) fileUrl String