From f2bc8f9483fa295be3a27ffe64c39e1c9ddf98ff Mon Sep 17 00:00:00 2001 From: Kavish Shah Date: Wed, 14 May 2025 16:47:21 -0700 Subject: [PATCH 1/3] Implement Upstash Redis rate-limiters for all endpoints --- app/actions/move-to-in-process.ts | 17 +++ app/api/ai/analyze/route.ts | 14 ++ app/api/auth/forgot-password/route.ts | 13 ++ app/api/auth/session/route.ts | 13 ++ app/api/auth/signin/route.ts | 17 +++ app/api/deals/[id]/comment/route.ts | 27 ++++ app/api/deals/[id]/status/route.ts | 15 ++ app/api/deals/route.ts | 26 ++++ app/api/notifications/route.ts | 16 +++ app/api/search/route.ts | 17 +++ app/api/upload/route.ts | 15 ++ app/types/ratelimit.d.ts | 9 ++ lib/ai.ts | 7 + lib/mailer.ts | 7 + lib/rate-limit.ts | 38 +++++ lib/upload.ts | 7 + lib/withAuth.ts | 120 +++++++--------- .../migration.sql | 136 ++++++++++++++++++ prisma/schema.prisma | 26 ++++ 19 files changed, 475 insertions(+), 65 deletions(-) create mode 100644 app/actions/move-to-in-process.ts create mode 100644 app/api/ai/analyze/route.ts create mode 100644 app/api/auth/forgot-password/route.ts create mode 100644 app/api/auth/session/route.ts create mode 100644 app/api/auth/signin/route.ts create mode 100644 app/api/deals/[id]/comment/route.ts create mode 100644 app/api/deals/[id]/status/route.ts create mode 100644 app/api/deals/route.ts create mode 100644 app/api/notifications/route.ts create mode 100644 app/api/search/route.ts create mode 100644 app/api/upload/route.ts create mode 100644 app/types/ratelimit.d.ts create mode 100644 lib/ai.ts create mode 100644 lib/mailer.ts create mode 100644 lib/rate-limit.ts create mode 100644 lib/upload.ts create mode 100644 prisma/migrations/20250423065727_add_deal_status/migration.sql diff --git a/app/actions/move-to-in-process.ts b/app/actions/move-to-in-process.ts new file mode 100644 index 0000000..82da5cd --- /dev/null +++ b/app/actions/move-to-in-process.ts @@ -0,0 +1,17 @@ +// /app/actions/move-to-in-process.ts +"use server"; + +import prismaDB from "../../lib/prisma"; // <- relative import +import { DealStatus } from "@prisma/client"; +import { revalidatePath } from "next/cache"; + +export default async function moveToInProcess(dealId: string): Promise { + await prismaDB.deal.update({ + where: { id: dealId }, + data: { status: DealStatus.IN_PROCESS }, + }); + + // re-validate both lists + revalidatePath("/raw-deals"); + revalidatePath("/in-process"); +} 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/migrations/20250423065727_add_deal_status/migration.sql b/prisma/migrations/20250423065727_add_deal_status/migration.sql new file mode 100644 index 0000000..2803c5d --- /dev/null +++ b/prisma/migrations/20250423065727_add_deal_status/migration.sql @@ -0,0 +1,136 @@ +/* + Warnings: + + - A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - Added the required column `email` to the `User` table without a default value. This is not possible if the table is not empty. + - Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN'); + +-- CreateEnum +CREATE TYPE "DealType" AS ENUM ('SCRAPED', 'MANUAL', 'AI_INFERRED'); + +-- CreateEnum +CREATE TYPE "DealStatus" AS ENUM ('RAW', 'IN_PROCESS', 'SCREENED', 'PUBLISHED'); + +-- CreateEnum +CREATE TYPE "SIMStatus" AS ENUM ('IN_PROGRESS', 'COMPLETED'); + +-- CreateEnum +CREATE TYPE "Sentiment" AS ENUM ('POSITIVE', 'NEUTRAL', 'NEGATIVE'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "email" TEXT NOT NULL, +ADD COLUMN "emailVerified" TIMESTAMP(3), +ADD COLUMN "image" TEXT, +ADD COLUMN "isBlocked" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "role" "UserRole" NOT NULL DEFAULT 'USER', +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL, +ALTER COLUMN "name" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "Account" ( + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("provider","providerAccountId") +); + +-- CreateTable +CREATE TABLE "Deal" ( + "id" TEXT NOT NULL, + "brokerage" TEXT NOT NULL, + "firstName" TEXT, + "lastName" TEXT, + "email" TEXT, + "linkedinUrl" TEXT, + "workPhone" TEXT, + "dealCaption" TEXT NOT NULL, + "revenue" DOUBLE PRECISION NOT NULL, + "ebitda" DOUBLE PRECISION NOT NULL, + "title" TEXT, + "grossRevenue" DOUBLE PRECISION, + "askingPrice" DOUBLE PRECISION, + "ebitdaMargin" DOUBLE PRECISION NOT NULL, + "industry" TEXT NOT NULL, + "dealType" "DealType" NOT NULL DEFAULT 'MANUAL', + "sourceWebsite" TEXT NOT NULL, + "companyLocation" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "DealStatus" NOT NULL DEFAULT 'RAW', + "bitrixId" TEXT, + "bitrixCreatedAt" TIMESTAMP(3), + + CONSTRAINT "Deal_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SIM" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "caption" TEXT NOT NULL, + "status" "SIMStatus" NOT NULL, + "fileName" TEXT NOT NULL, + "fileType" TEXT NOT NULL, + "fileUrl" TEXT NOT NULL, + "dealId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SIM_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "questionnaires" ( + "id" TEXT NOT NULL, + "fileUrl" TEXT NOT NULL, + "title" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "author" TEXT NOT NULL, + "version" TEXT NOT NULL, + "isPublished" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "questionnaires_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AiScreening" ( + "id" TEXT NOT NULL, + "dealId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "explanation" TEXT NOT NULL, + "sentiment" "Sentiment" NOT NULL DEFAULT 'NEUTRAL', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AiScreening_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SIM" ADD CONSTRAINT "SIM_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AiScreening" ADD CONSTRAINT "AiScreening_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 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 From 0b999b1cdb76f9754c0cb583207edc814400239b Mon Sep 17 00:00:00 2001 From: Kavish Shah <76742201+thekavishshah@users.noreply.github.com> Date: Wed, 14 May 2025 17:12:12 -0700 Subject: [PATCH 2/3] update pull request --- app/actions/move-to-in-process.ts | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 app/actions/move-to-in-process.ts diff --git a/app/actions/move-to-in-process.ts b/app/actions/move-to-in-process.ts deleted file mode 100644 index 82da5cd..0000000 --- a/app/actions/move-to-in-process.ts +++ /dev/null @@ -1,17 +0,0 @@ -// /app/actions/move-to-in-process.ts -"use server"; - -import prismaDB from "../../lib/prisma"; // <- relative import -import { DealStatus } from "@prisma/client"; -import { revalidatePath } from "next/cache"; - -export default async function moveToInProcess(dealId: string): Promise { - await prismaDB.deal.update({ - where: { id: dealId }, - data: { status: DealStatus.IN_PROCESS }, - }); - - // re-validate both lists - revalidatePath("/raw-deals"); - revalidatePath("/in-process"); -} From c19fab05f8e8c458ed51ae952e9b99e74b4447e6 Mon Sep 17 00:00:00 2001 From: Kavish Shah <76742201+thekavishshah@users.noreply.github.com> Date: Wed, 14 May 2025 17:12:51 -0700 Subject: [PATCH 3/3] update pull request --- .../migration.sql | 136 ------------------ 1 file changed, 136 deletions(-) delete mode 100644 prisma/migrations/20250423065727_add_deal_status/migration.sql diff --git a/prisma/migrations/20250423065727_add_deal_status/migration.sql b/prisma/migrations/20250423065727_add_deal_status/migration.sql deleted file mode 100644 index 2803c5d..0000000 --- a/prisma/migrations/20250423065727_add_deal_status/migration.sql +++ /dev/null @@ -1,136 +0,0 @@ -/* - Warnings: - - - A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail. - - Added the required column `email` to the `User` table without a default value. This is not possible if the table is not empty. - - Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty. - -*/ --- CreateEnum -CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN'); - --- CreateEnum -CREATE TYPE "DealType" AS ENUM ('SCRAPED', 'MANUAL', 'AI_INFERRED'); - --- CreateEnum -CREATE TYPE "DealStatus" AS ENUM ('RAW', 'IN_PROCESS', 'SCREENED', 'PUBLISHED'); - --- CreateEnum -CREATE TYPE "SIMStatus" AS ENUM ('IN_PROGRESS', 'COMPLETED'); - --- CreateEnum -CREATE TYPE "Sentiment" AS ENUM ('POSITIVE', 'NEUTRAL', 'NEGATIVE'); - --- AlterTable -ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, -ADD COLUMN "email" TEXT NOT NULL, -ADD COLUMN "emailVerified" TIMESTAMP(3), -ADD COLUMN "image" TEXT, -ADD COLUMN "isBlocked" BOOLEAN NOT NULL DEFAULT false, -ADD COLUMN "role" "UserRole" NOT NULL DEFAULT 'USER', -ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL, -ALTER COLUMN "name" DROP NOT NULL; - --- CreateTable -CREATE TABLE "Account" ( - "userId" TEXT NOT NULL, - "type" TEXT NOT NULL, - "provider" TEXT NOT NULL, - "providerAccountId" TEXT NOT NULL, - "refresh_token" TEXT, - "access_token" TEXT, - "expires_at" INTEGER, - "token_type" TEXT, - "scope" TEXT, - "id_token" TEXT, - "session_state" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Account_pkey" PRIMARY KEY ("provider","providerAccountId") -); - --- CreateTable -CREATE TABLE "Deal" ( - "id" TEXT NOT NULL, - "brokerage" TEXT NOT NULL, - "firstName" TEXT, - "lastName" TEXT, - "email" TEXT, - "linkedinUrl" TEXT, - "workPhone" TEXT, - "dealCaption" TEXT NOT NULL, - "revenue" DOUBLE PRECISION NOT NULL, - "ebitda" DOUBLE PRECISION NOT NULL, - "title" TEXT, - "grossRevenue" DOUBLE PRECISION, - "askingPrice" DOUBLE PRECISION, - "ebitdaMargin" DOUBLE PRECISION NOT NULL, - "industry" TEXT NOT NULL, - "dealType" "DealType" NOT NULL DEFAULT 'MANUAL', - "sourceWebsite" TEXT NOT NULL, - "companyLocation" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "status" "DealStatus" NOT NULL DEFAULT 'RAW', - "bitrixId" TEXT, - "bitrixCreatedAt" TIMESTAMP(3), - - CONSTRAINT "Deal_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "SIM" ( - "id" TEXT NOT NULL, - "title" TEXT NOT NULL, - "caption" TEXT NOT NULL, - "status" "SIMStatus" NOT NULL, - "fileName" TEXT NOT NULL, - "fileType" TEXT NOT NULL, - "fileUrl" TEXT NOT NULL, - "dealId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "SIM_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "questionnaires" ( - "id" TEXT NOT NULL, - "fileUrl" TEXT NOT NULL, - "title" TEXT NOT NULL, - "purpose" TEXT NOT NULL, - "author" TEXT NOT NULL, - "version" TEXT NOT NULL, - "isPublished" BOOLEAN NOT NULL DEFAULT false, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "questionnaires_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "AiScreening" ( - "id" TEXT NOT NULL, - "dealId" TEXT NOT NULL, - "title" TEXT NOT NULL, - "explanation" TEXT NOT NULL, - "sentiment" "Sentiment" NOT NULL DEFAULT 'NEUTRAL', - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "AiScreening_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); - --- AddForeignKey -ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SIM" ADD CONSTRAINT "SIM_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AiScreening" ADD CONSTRAINT "AiScreening_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE RESTRICT ON UPDATE CASCADE;