From 989f116d8b46751ee9a3e33c2366b53e8a4cfbb0 Mon Sep 17 00:00:00 2001 From: Kavish Shah Date: Wed, 13 Aug 2025 22:19:18 -0700 Subject: [PATCH 1/2] WIP: local changes before syncing with origin/main --- app/(protected)/admin/page.tsx | 2 +- .../inferred-deals/[uid]/edit/page.tsx | 2 +- app/(protected)/inferred-deals/[uid]/page.tsx | 2 +- app/(protected)/inferred-deals/page.tsx | 2 +- .../manual-deals/[uid]/edit/page.tsx | 2 +- app/(protected)/manual-deals/[uid]/page.tsx | 2 +- app/(protected)/manual-deals/page.tsx | 2 +- app/(protected)/profile/[uid]/page.tsx | 2 +- app/(protected)/questionnaires/page.tsx | 2 +- app/(protected)/raw-deals/[uid]/edit/page.tsx | 2 +- .../raw-deals/[uid]/screen/page.tsx | 2 +- app/actions/add-baseline.ts | 2 +- app/actions/add-deal.ts | 2 +- app/actions/add-poc.ts | 2 +- app/actions/block-account.ts | 2 +- app/actions/bulk-delete-deals.ts | 2 +- app/actions/bulk-upload-deal.ts | 2 +- app/actions/delete-ai-screening.ts | 2 +- app/actions/delete-baseline.ts | 2 +- app/actions/delete-deal.ts | 2 +- app/actions/delete-poc.ts | 2 +- app/actions/delete-sim.ts | 2 +- app/actions/edit-deal.ts | 2 +- app/actions/edit-screen-deal-result.ts | 2 +- app/actions/revert-deal-version.ts | 68 +++++ app/actions/save-infer-deal.ts | 2 +- app/actions/save-screen-deal.ts | 2 +- app/actions/screening-save-result.ts | 2 +- app/actions/unblock-account.ts | 2 +- app/actions/upload-bitrix.ts | 2 +- app/actions/upload-cim.ts | 2 +- app/api/deals/[id]/deal-documents/route.ts | 25 ++ app/api/deals/[id]/pocs/route.ts | 20 ++ app/api/deals/[id]/route.ts | 14 + app/api/deals/[id]/versions/route.ts | 41 +++ app/api/upload-deal-document/route.ts | 2 +- auth.config.ts | 2 +- auth.ts | 3 +- components/DealHistoryView.tsx | 113 +++++++ components/DealTabs.tsx | 284 ++++++++++++++++++ .../Dialogs/deal-document-upload-dialog.tsx | 3 + components/FetchDealAIScreenings.tsx | 110 ++++--- components/FetchDealPOC.tsx | 65 ++-- components/FetchDealSim.tsx | 58 ++-- components/ThemeToggle.tsx | 21 ++ components/fetch-deal-documents.tsx | 74 +++-- docker-compose.yml | 2 +- lib/data/current-user.ts | 2 +- lib/prisma.server.ts | 140 +++++++++ lib/prisma.ts | 17 -- lib/queries.ts | 2 +- lib/schemas.ts | 44 ++- lib/server/schemas.ts | 24 ++ package-lock.json | 143 ++++++++- package.json | 6 +- .../migration.sql | 200 ++++++++++++ .../migration.sql | 2 + .../migration.sql | 10 + .../migration.sql | 12 + prisma/migrations/migration_lock.toml | 2 +- prisma/queries.ts | 2 +- prisma/schema.prisma | 23 ++ prisma/scripts/deleteAllDeals.ts | 2 +- prisma/scripts/updateEbitdaMargin.ts | 2 +- prisma/seed.ts | 2 +- scripts/generateDummyDeals.cjs | 34 +++ scripts/seedFromProd.cjs | 48 +++ scripts/seedFromProd.ts | 64 ++++ 68 files changed, 1553 insertions(+), 193 deletions(-) create mode 100644 app/actions/revert-deal-version.ts create mode 100644 app/api/deals/[id]/deal-documents/route.ts create mode 100644 app/api/deals/[id]/pocs/route.ts create mode 100644 app/api/deals/[id]/route.ts create mode 100644 app/api/deals/[id]/versions/route.ts create mode 100644 components/DealHistoryView.tsx create mode 100644 components/DealTabs.tsx create mode 100644 components/ThemeToggle.tsx create mode 100644 lib/prisma.server.ts delete mode 100644 lib/prisma.ts create mode 100644 lib/server/schemas.ts create mode 100644 prisma/migrations/20250729061122_add_description/migration.sql create mode 100644 prisma/migrations/20250729061747_add_published_at/migration.sql create mode 100644 prisma/migrations/20250730071405_add_raw_deal_fields/migration.sql create mode 100644 prisma/migrations/20250730075042_add_deal_history/migration.sql create mode 100644 scripts/generateDummyDeals.cjs create mode 100644 scripts/seedFromProd.cjs create mode 100644 scripts/seedFromProd.ts diff --git a/app/(protected)/admin/page.tsx b/app/(protected)/admin/page.tsx index cb5f75d..905db58 100644 --- a/app/(protected)/admin/page.tsx +++ b/app/(protected)/admin/page.tsx @@ -3,7 +3,7 @@ import { redirect } from "next/navigation"; import React from "react"; import { DataTable } from "./data-table"; import { columns } from "./columns"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; const AdminPage = async () => { const currentUserRole = await getCurrentUserRole(); diff --git a/app/(protected)/inferred-deals/[uid]/edit/page.tsx b/app/(protected)/inferred-deals/[uid]/edit/page.tsx index b19699b..801819e 100644 --- a/app/(protected)/inferred-deals/[uid]/edit/page.tsx +++ b/app/(protected)/inferred-deals/[uid]/edit/page.tsx @@ -2,7 +2,7 @@ import EditDealForm from "@/components/forms/edit-deal-form"; import PreviousPageButton from "@/components/PreviousPageButton"; import { Card, CardContent } from "@/components/ui/card"; import { fetchSpecificInferredDeal } from "@/lib/firebase/db"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { Metadata } from "next"; import React from "react"; diff --git a/app/(protected)/inferred-deals/[uid]/page.tsx b/app/(protected)/inferred-deals/[uid]/page.tsx index 1a14123..1a53c5b 100644 --- a/app/(protected)/inferred-deals/[uid]/page.tsx +++ b/app/(protected)/inferred-deals/[uid]/page.tsx @@ -39,7 +39,7 @@ import { import { Badge } from "@/components/ui/badge"; import PreviousPageButton from "@/components/PreviousPageButton"; import ScreenDealDialog from "@/components/Dialogs/screen-deal-dialog"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { DealDetailItem } from "@/components/DealDetailItem"; import AIReasoning from "@/components/AiReasoning"; import SimUploadDialog from "@/components/Dialogs/sim-upload-dialog"; diff --git a/app/(protected)/inferred-deals/page.tsx b/app/(protected)/inferred-deals/page.tsx index 9a7fc1f..bfdacce 100644 --- a/app/(protected)/inferred-deals/page.tsx +++ b/app/(protected)/inferred-deals/page.tsx @@ -1,7 +1,7 @@ import React, { Suspense } from "react"; import DealCardSkeleton from "@/components/skeletons/DealCardSkeleton"; import { Metadata } from "next"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import DealCard from "@/components/DealCard"; import getCurrentUserRole from "@/lib/data/current-user-role"; import GetDeals from "@/app/actions/get-deal"; diff --git a/app/(protected)/manual-deals/[uid]/edit/page.tsx b/app/(protected)/manual-deals/[uid]/edit/page.tsx index fd9d076..6183785 100644 --- a/app/(protected)/manual-deals/[uid]/edit/page.tsx +++ b/app/(protected)/manual-deals/[uid]/edit/page.tsx @@ -2,7 +2,7 @@ import EditDealForm from "@/components/forms/edit-deal-form"; import PreviousPageButton from "@/components/PreviousPageButton"; import { Card, CardContent } from "@/components/ui/card"; import { fetchSpecificManualDeal } from "@/lib/firebase/db"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { Metadata } from "next"; import React from "react"; diff --git a/app/(protected)/manual-deals/[uid]/page.tsx b/app/(protected)/manual-deals/[uid]/page.tsx index bdf207b..3179185 100644 --- a/app/(protected)/manual-deals/[uid]/page.tsx +++ b/app/(protected)/manual-deals/[uid]/page.tsx @@ -28,7 +28,7 @@ import PreviousPageButton from "@/components/PreviousPageButton"; import { DealDetailItem } from "@/components/DealDetailItem"; import AIReasoning from "@/components/AiReasoning"; import SimItem from "@/components/SimItem"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import SimUploadDialog from "@/components/Dialogs/sim-upload-dialog"; import FetchDealSim from "@/components/FetchDealSim"; import SimItemSkeleton from "@/components/skeletons/SimItemSkeleton"; diff --git a/app/(protected)/manual-deals/page.tsx b/app/(protected)/manual-deals/page.tsx index 6f73aef..5771bbb 100644 --- a/app/(protected)/manual-deals/page.tsx +++ b/app/(protected)/manual-deals/page.tsx @@ -2,7 +2,7 @@ import DealCardSkeleton from "@/components/skeletons/DealCardSkeleton"; import { Metadata } from "next"; import React, { Suspense } from "react"; import FetchingManualDeals from "./FetchingManualDeals"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import DealCard from "@/components/DealCard"; import GetDeals from "@/app/actions/get-deal"; import Pagination from "@/components/pagination"; diff --git a/app/(protected)/profile/[uid]/page.tsx b/app/(protected)/profile/[uid]/page.tsx index 1f5a570..11fb71c 100644 --- a/app/(protected)/profile/[uid]/page.tsx +++ b/app/(protected)/profile/[uid]/page.tsx @@ -1,5 +1,5 @@ import ProfileForm from "@/components/forms/profile-form"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { Metadata } from "next"; import { notFound } from "next/navigation"; import React from "react"; diff --git a/app/(protected)/questionnaires/page.tsx b/app/(protected)/questionnaires/page.tsx index 2f9b55b..3da5e89 100644 --- a/app/(protected)/questionnaires/page.tsx +++ b/app/(protected)/questionnaires/page.tsx @@ -3,7 +3,7 @@ import BaseLineUploadForm from "@/components/forms/baseline-upload-form"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { fetchQuestionnaires } from "@/lib/firebase/db"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { Metadata } from "next"; import React from "react"; diff --git a/app/(protected)/raw-deals/[uid]/edit/page.tsx b/app/(protected)/raw-deals/[uid]/edit/page.tsx index 788656f..30cd57f 100644 --- a/app/(protected)/raw-deals/[uid]/edit/page.tsx +++ b/app/(protected)/raw-deals/[uid]/edit/page.tsx @@ -2,7 +2,7 @@ import EditDealForm from "@/components/forms/edit-deal-form"; import PreviousPageButton from "@/components/PreviousPageButton"; import { Card, CardContent } from "@/components/ui/card"; import { fetchSpecificManualDeal } from "@/lib/firebase/db"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { Metadata } from "next"; import React from "react"; diff --git a/app/(protected)/raw-deals/[uid]/screen/page.tsx b/app/(protected)/raw-deals/[uid]/screen/page.tsx index 1035431..5ea67fd 100644 --- a/app/(protected)/raw-deals/[uid]/screen/page.tsx +++ b/app/(protected)/raw-deals/[uid]/screen/page.tsx @@ -1,6 +1,6 @@ import PreviousPageButton from "@/components/PreviousPageButton"; import ScreenDealComponent from "@/components/ScreenDealComponent"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Metadata } from "next"; import React, { Suspense } from "react"; diff --git a/app/actions/add-baseline.ts b/app/actions/add-baseline.ts index 105d63e..b65932d 100644 --- a/app/actions/add-baseline.ts +++ b/app/actions/add-baseline.ts @@ -1,6 +1,6 @@ "use server"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { put } from "@vercel/blob"; import { revalidatePath } from "next/cache"; diff --git a/app/actions/add-deal.ts b/app/actions/add-deal.ts index 28bc288..519ef97 100644 --- a/app/actions/add-deal.ts +++ b/app/actions/add-deal.ts @@ -4,7 +4,7 @@ import { NewDealFormSchema, NewDealFormSchemaType, } from "@/components/forms/new-deal-form"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { withAuthServerAction } from "@/lib/withAuth"; import { DealType, User } from "@prisma/client"; import { addDoc, collection, serverTimestamp } from "firebase/firestore"; diff --git a/app/actions/add-poc.ts b/app/actions/add-poc.ts index c60ad89..7b70f50 100644 --- a/app/actions/add-poc.ts +++ b/app/actions/add-poc.ts @@ -4,7 +4,7 @@ import { addPocFormSchema, AddPocFormValues, } from "@/components/forms/add-poc-form"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { User } from "@prisma/client"; import { withAuthServerAction } from "@/lib/withAuth"; import { revalidatePath } from "next/cache"; diff --git a/app/actions/block-account.ts b/app/actions/block-account.ts index 716e2f5..4d837ea 100644 --- a/app/actions/block-account.ts +++ b/app/actions/block-account.ts @@ -2,7 +2,7 @@ import { auth } from "@/auth"; import getCurrentUserRole from "@/lib/data/current-user-role"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { revalidatePath } from "next/cache"; const blockAccount = async (userId: string) => { diff --git a/app/actions/bulk-delete-deals.ts b/app/actions/bulk-delete-deals.ts index 760ff58..b3f745b 100644 --- a/app/actions/bulk-delete-deals.ts +++ b/app/actions/bulk-delete-deals.ts @@ -1,7 +1,7 @@ "use server"; import { auth } from "@/auth"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { revalidatePath } from "next/cache"; /** diff --git a/app/actions/bulk-upload-deal.ts b/app/actions/bulk-upload-deal.ts index 4454234..0e7815d 100644 --- a/app/actions/bulk-upload-deal.ts +++ b/app/actions/bulk-upload-deal.ts @@ -3,7 +3,7 @@ import { db } from "@/lib/firebase/init"; import { addDoc, collection, serverTimestamp } from "firebase/firestore"; import { TransformedDeal } from "../types"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { DealType, User } from "@prisma/client"; import { withAuthServerAction } from "@/lib/withAuth"; import { auth } from "@/auth"; diff --git a/app/actions/delete-ai-screening.ts b/app/actions/delete-ai-screening.ts index 947e249..6772191 100644 --- a/app/actions/delete-ai-screening.ts +++ b/app/actions/delete-ai-screening.ts @@ -1,5 +1,5 @@ "use server"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { DealType } from "@prisma/client"; import { revalidatePath } from "next/cache"; diff --git a/app/actions/delete-baseline.ts b/app/actions/delete-baseline.ts index 83cf530..442f257 100644 --- a/app/actions/delete-baseline.ts +++ b/app/actions/delete-baseline.ts @@ -1,7 +1,7 @@ "use server"; import { db } from "@/lib/firebase/init"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { del } from "@vercel/blob"; import { revalidatePath } from "next/cache"; diff --git a/app/actions/delete-deal.ts b/app/actions/delete-deal.ts index 48111fc..ab8873f 100644 --- a/app/actions/delete-deal.ts +++ b/app/actions/delete-deal.ts @@ -5,7 +5,7 @@ import { NewDealFormSchemaType, } from "@/components/forms/new-deal-form"; import { db } from "@/lib/firebase/init"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { DealType } from "@prisma/client"; import { addDoc, diff --git a/app/actions/delete-poc.ts b/app/actions/delete-poc.ts index f84a54c..baacc93 100644 --- a/app/actions/delete-poc.ts +++ b/app/actions/delete-poc.ts @@ -1,7 +1,7 @@ "use server"; import { withAuthServerAction } from "@/lib/withAuth"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { User } from "@prisma/client"; import { revalidatePath } from "next/cache"; diff --git a/app/actions/delete-sim.ts b/app/actions/delete-sim.ts index dd24204..dfb529a 100644 --- a/app/actions/delete-sim.ts +++ b/app/actions/delete-sim.ts @@ -1,6 +1,6 @@ "use server"; import { auth } from "@/auth"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { DealType } from "@prisma/client"; import { del } from "@vercel/blob"; diff --git a/app/actions/edit-deal.ts b/app/actions/edit-deal.ts index 0a77a54..c9435c3 100644 --- a/app/actions/edit-deal.ts +++ b/app/actions/edit-deal.ts @@ -2,7 +2,7 @@ import { EditDealFormSchemaType } from "@/components/forms/edit-deal-form"; import { db } from "@/lib/firebase/init"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { DealType } from "@prisma/client"; import { doc, getDoc, serverTimestamp, updateDoc } from "firebase/firestore"; import { revalidatePath } from "next/cache"; diff --git a/app/actions/edit-screen-deal-result.ts b/app/actions/edit-screen-deal-result.ts index 4061fdf..8cc30fe 100644 --- a/app/actions/edit-screen-deal-result.ts +++ b/app/actions/edit-screen-deal-result.ts @@ -1,7 +1,7 @@ "use server"; import { auth } from "@/auth"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { screenDealSchemaType } from "@/lib/schemas"; import { DealType } from "@prisma/client"; import { revalidatePath } from "next/cache"; diff --git a/app/actions/revert-deal-version.ts b/app/actions/revert-deal-version.ts new file mode 100644 index 0000000..0787ead --- /dev/null +++ b/app/actions/revert-deal-version.ts @@ -0,0 +1,68 @@ +"use server"; + +import prismaDB from "@/lib/prisma.server"; +import { revalidatePath } from "next/cache"; +import { DealType } from "@prisma/client"; + +/** + * Revert a Deal record to the snapshot stored in `dealHistory`. + * @param versionId – id of the dealHistory row we want to restore + * @param dealId – id of the Deal to update (redundant but explicit) + */ +const revertDealVersion = async (versionId: string, dealId: string) => { + try { + // 1. fetch snapshot to restore + const version = await prismaDB.dealHistory.findUnique({ + where: { id: versionId }, + }); + + if (!version) { + return { type: "error" as const, message: "Version not found" }; + } + + // 2. Extract snapshot & drop immutable columns + const { snapshot } = version as { snapshot: Record }; + if (!snapshot) { + return { + type: "error" as const, + message: "Snapshot missing for the selected version.", + }; + } + + // Remove prisma-generated/immutable fields + const { + id: _discardId, + createdAt: _discardCreated, + updatedAt: _discardUpdated, + ...dataToRestore + } = snapshot as Record; + + // 3. Perform update (this will itself write a new version via middleware) + const updated = await prismaDB.deal.update({ + where: { id: dealId }, + data: dataToRestore, + }); + + // 4. Revalidate correct page route so UI updates + switch (updated.dealType as DealType) { + case "MANUAL": + revalidatePath(`/manual-deals/${dealId}`); + break; + case "SCRAPED": + revalidatePath(`/raw-deals/${dealId}`); + break; + case "AI_INFERRED": + revalidatePath(`/inferred-deals/${dealId}`); + break; + default: + break; + } + + return { type: "success" as const }; + } catch (err) { + console.error("Error reverting deal to version", err); + return { type: "error" as const, message: "Failed to revert deal." }; + } +}; + +export default revertDealVersion; diff --git a/app/actions/save-infer-deal.ts b/app/actions/save-infer-deal.ts index a3ae524..971fe44 100644 --- a/app/actions/save-infer-deal.ts +++ b/app/actions/save-infer-deal.ts @@ -2,7 +2,7 @@ import { auth } from "@/auth"; import { InferDealSchema } from "@/components/schemas/infer-deal-schema"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; export default async function SaveInferredDeal({ generation, diff --git a/app/actions/save-screen-deal.ts b/app/actions/save-screen-deal.ts index 2a0a1d5..f5d3c3c 100644 --- a/app/actions/save-screen-deal.ts +++ b/app/actions/save-screen-deal.ts @@ -5,7 +5,7 @@ import { NewDealFormSchema, NewDealFormSchemaType, } from "@/components/forms/new-deal-form"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { screenDealSchemaType } from "@/lib/schemas"; import { DealType } from "@prisma/client"; import { addDoc, collection, serverTimestamp } from "firebase/firestore"; diff --git a/app/actions/screening-save-result.ts b/app/actions/screening-save-result.ts index 7af27ea..f6806e6 100644 --- a/app/actions/screening-save-result.ts +++ b/app/actions/screening-save-result.ts @@ -2,7 +2,7 @@ import { auth } from "@/auth"; import { openai } from "@/lib/ai/available-models"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { Sentiment } from "@prisma/client"; import { generateObject } from "ai"; import { revalidatePath } from "next/cache"; diff --git a/app/actions/unblock-account.ts b/app/actions/unblock-account.ts index 2b4c2ae..57b92e7 100644 --- a/app/actions/unblock-account.ts +++ b/app/actions/unblock-account.ts @@ -2,7 +2,7 @@ import { auth } from "@/auth"; import getCurrentUserRole from "@/lib/data/current-user-role"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { revalidatePath } from "next/cache"; const unblockAccount = async (userId: string) => { diff --git a/app/actions/upload-bitrix.ts b/app/actions/upload-bitrix.ts index fffcce9..010c880 100644 --- a/app/actions/upload-bitrix.ts +++ b/app/actions/upload-bitrix.ts @@ -4,7 +4,7 @@ import axios from "axios"; import { Deal, User } from "@prisma/client"; import { auth } from "@/auth"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { revalidatePath } from "next/cache"; import { withAuthServerAction } from "@/lib/withAuth"; diff --git a/app/actions/upload-cim.ts b/app/actions/upload-cim.ts index 3ee364e..caf9325 100644 --- a/app/actions/upload-cim.ts +++ b/app/actions/upload-cim.ts @@ -4,7 +4,7 @@ import { put } from "@vercel/blob"; import { DealType, PrismaClient } from "@prisma/client"; import { revalidatePath } from "next/cache"; import { cimFormSchema } from "@/lib/schemas"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; export default async function UploadCim( data: FormData, diff --git a/app/api/deals/[id]/deal-documents/route.ts b/app/api/deals/[id]/deal-documents/route.ts new file mode 100644 index 0000000..fc8135c --- /dev/null +++ b/app/api/deals/[id]/deal-documents/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma.server"; + +// GET /api/deals/:id/deal-documents +export async function GET( + _req: Request, + context: { params: { id: string } }, +) { + // Next.js 15 dynamic params are async proxies – await before access to silence warning + const id = await context.params.id; + + try { + const data = await prisma.dealDocument.findMany({ + where: { dealId: id }, + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json(data); + } catch (error) { + console.error("Deal documents fetch error", error); + return NextResponse.json( + { error: "Failed to fetch deal documents" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/app/api/deals/[id]/pocs/route.ts b/app/api/deals/[id]/pocs/route.ts new file mode 100644 index 0000000..5565bd6 --- /dev/null +++ b/app/api/deals/[id]/pocs/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma.server"; + +// GET /api/deals/:id/pocs +export async function GET( + _req: Request, + context: { params: { id: string } }, +) { + const id = await context.params.id; + + try { + const data = await prisma.pOC.findMany({ + where: { dealId: id }, + }); + return NextResponse.json(data); + } catch (error) { + console.error("POCs fetch error", error); + return NextResponse.json({ error: "Failed to fetch POCs" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/deals/[id]/route.ts b/app/api/deals/[id]/route.ts new file mode 100644 index 0000000..7343f94 --- /dev/null +++ b/app/api/deals/[id]/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma.server"; + +export async function PATCH(request: Request, context: { params: { id: string } }) { + const id = await context.params.id; + const data = await request.json(); + // data can be { reviewed: boolean } or { seen: boolean } etc. + const updated = await prisma.deal.update({ + where: { id }, + data, + select: { id: true }, + }); + return NextResponse.json(updated); +} diff --git a/app/api/deals/[id]/versions/route.ts b/app/api/deals/[id]/versions/route.ts new file mode 100644 index 0000000..5c6026e --- /dev/null +++ b/app/api/deals/[id]/versions/route.ts @@ -0,0 +1,41 @@ +// app/api/deals/[id]/versions/route.ts +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma.server"; + +export async function GET( + request: Request, + context: { params: { id: string } }, +) { + // Next.js 15 dynamic params are async proxies – await before access to silence warning + const id = await context.params.id; + + const rawVersions = await prisma.dealHistory.findMany({ + where: { dealId: id }, + orderBy: { createdAt: "desc" }, + }); + + // ── Deduplicate snapshots that are byte-for-byte identical ── + const versions: typeof rawVersions = []; + const stripTimestamps = (obj: Record) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { createdAt: _c, updatedAt: _u, ...rest } = obj; + return rest; + }; + + const seenSeconds = new Set(); + rawVersions.forEach((v) => { + const tsSec = Math.floor(new Date(v.createdAt).getTime() / 1000); + if (seenSeconds.has(tsSec)) return; // skip other writes in same second window + + if ( + versions.length === 0 || + JSON.stringify(stripTimestamps(versions[versions.length - 1].snapshot)) !== + JSON.stringify(stripTimestamps(v.snapshot)) + ) { + versions.push(v); + seenSeconds.add(tsSec); + } + }); + + return NextResponse.json(versions); +} diff --git a/app/api/upload-deal-document/route.ts b/app/api/upload-deal-document/route.ts index 0c6cb98..8d1b98a 100644 --- a/app/api/upload-deal-document/route.ts +++ b/app/api/upload-deal-document/route.ts @@ -1,6 +1,6 @@ import { auth } from "@/auth"; import { dealDocumentFormSchema } from "@/lib/schemas"; -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; import { put } from "@vercel/blob"; import { NextRequest, NextResponse } from "next/server"; import { revalidatePath } from "next/cache"; diff --git a/auth.config.ts b/auth.config.ts index cf0ddd2..50ebcc2 100644 --- a/auth.config.ts +++ b/auth.config.ts @@ -1,7 +1,7 @@ import GitHub from "next-auth/providers/github"; import type { DefaultSession, NextAuthConfig } from "next-auth"; import Google from "next-auth/providers/google"; -import prismaDB from "./lib/prisma"; +import prismaDB from "./lib/prisma.server"; import { UserRole } from "@prisma/client"; // Notice this is only an object, not a full Auth.js instance diff --git a/auth.ts b/auth.ts index fa3b30b..9c8ea6f 100644 --- a/auth.ts +++ b/auth.ts @@ -1,6 +1,6 @@ import NextAuth, { DefaultSession } from "next-auth"; import { PrismaAdapter } from "@auth/prisma-adapter"; -import prismaDB from "./lib/prisma"; +import prismaDB from "./lib/prisma.server"; import { User, UserRole } from "@prisma/client"; import authConfig from "./auth.config"; import { getCurrentUserByEmail } from "./lib/data/current-user"; @@ -12,6 +12,7 @@ const adminEmails = [ "diligence@darkalphacapital.com", "da@darkalphacapital.com", "daigbe@gmail.com", + "kshah77@asu.edu" ]; declare module "next-auth" { diff --git a/components/DealHistoryView.tsx b/components/DealHistoryView.tsx new file mode 100644 index 0000000..a170781 --- /dev/null +++ b/components/DealHistoryView.tsx @@ -0,0 +1,113 @@ +// components/DealHistoryView.tsx +"use client"; + +import React, { useEffect, useState, useTransition } from "react"; +import revertDealVersion from "@/app/actions/revert-deal-version"; +import { useRouter } from "next/navigation"; +import { useToast } from "@/hooks/use-toast"; + +interface Version { + id: string; + snapshot: Record; + createdAt: string; +} + +function diffSnapshots(a: Record, b: Record | null) { + if (!b) return [] as Array<{ field: string; oldVal: any; newVal: any }>; + const fields = new Set([...Object.keys(a), ...Object.keys(b)]); + const changes: Array<{ field: string; oldVal: any; newVal: any }> = []; + fields.forEach((f) => { + const prevVal = b[f]; + const nextVal = a[f]; + if (JSON.stringify(prevVal) !== JSON.stringify(nextVal)) { + changes.push({ field: f, oldVal: prevVal, newVal: nextVal }); + } + }); + return changes; +} + +export default function DealHistoryView({ dealId }: { dealId: string }) { + const [versions, setVersions] = useState([]); + const [openId, setOpenId] = useState(null); + const [isPending, startTransition] = useTransition(); + const router = useRouter(); + const { toast } = useToast(); + + useEffect(() => { + fetch(`/api/deals/${dealId}/versions`) + .then((res) => res.json()) + .then(setVersions); + }, [dealId]); + + const getOlderSnapshot = (idx: number) => + idx + 1 < versions.length ? versions[idx + 1].snapshot : null; + + return ( +
+ {versions.map((v, idx) => { + const changes = diffSnapshots(v.snapshot, getOlderSnapshot(idx)); + return ( +
+ + {openId === v.id && ( +
+ {changes.length > 0 ? ( +
    + {changes.map((c) => ( +
  • + {c.field}: {JSON.stringify(c.oldVal)} → {JSON.stringify(c.newVal)} +
  • + ))} +
+ ) : ( +

No visible difference

+ )} + + +
+ )} +
+ ); + })} + {versions.length === 0 && ( +

No version history.

+ )} +
+ ); +} diff --git a/components/DealTabs.tsx b/components/DealTabs.tsx new file mode 100644 index 0000000..da4a0af --- /dev/null +++ b/components/DealTabs.tsx @@ -0,0 +1,284 @@ +"use client"; +// components/DealTabs.tsx + + +import React, { useState, Suspense } from 'react'; +import type { Deal } from '@prisma/client'; +import Link from 'next/link'; +import { + Hash, + Tag, + CheckCircle, + Briefcase, + MapPin, + Phone, + Mail, + Building, + DollarSign, + Percent, + CreditCard, + Plus, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { DealDetailItem } from '@/components/DealDetailItem'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +import DealHistoryView from '@/components/DealHistoryView'; +import dynamic from 'next/dynamic'; +import AIReasoningSkeleton from '@/components/skeletons/AIReasoningSkeleton'; +import SimUploadDialog from '@/components/Dialogs/sim-upload-dialog'; +import SimItemSkeleton from '@/components/skeletons/SimItemSkeleton'; +import DealDocumentUploadDialog from '@/components/Dialogs/deal-document-upload-dialog'; + +const FetchDealAIScreenings = dynamic(() => import('@/components/FetchDealAIScreenings'), { ssr: true }); +const FetchDealSim = dynamic(() => import('@/components/FetchDealSim'), { ssr: true }); +const FetchDealDocuments = dynamic(() => import('@/components/fetch-deal-documents'), { ssr: true }); +const FetchDealPOC = dynamic(() => import('@/components/FetchDealPOC'), { ssr: true }); + +interface Props { + deal: Deal; +} + +export default function DealTabs({ deal }: Props) { + const [active, setActive] = useState< + 'basic' | 'financial' | 'ai' | 'docs' | 'sims' | 'pocs' | 'history' + >('basic'); + + return ( +
+ {/* ── Tab Headers ── */} +
+ {[ + { id: 'basic', label: 'Basic Information' }, + { id: 'financial', label: 'Financial Details' }, + { id: 'ai', label: 'AI Reasoning' }, + { id: 'docs', label: 'Deal Documents' }, + { id: 'sims', label: 'SIMs' }, + { id: 'pocs', label: 'Point of Contacts' }, + { id: 'history', label: 'Version History' }, + ].map((tab) => ( + + ))} +
+ + {/* ── Tab Panels ── */} +
+ {/* Basic Information */} + {active === 'basic' && ( + + + Basic Information + + + } label="Deal ID" value={deal.id} /> + } + label="Bitrix ID" + value={deal.bitrixId ?? '—'} + /> + } + label="Status" + value={deal.status} + /> + } + label="Tags" + value={deal.tags.length ? deal.tags.join(', ') : '—'} + /> + } + label="Reviewed" + value={deal.reviewed ? 'Yes' : 'No'} + /> + } + label="Seen" + value={deal.seen ? 'Yes' : 'No'} + /> + } + label="Published" + value={deal.published ? 'Yes' : 'No'} + /> + } + label="Brokerage" + value={deal.brokerage} + /> + } + label="Location" + value={deal.companyLocation ?? '—'} + /> + } + label="Industry" + value={deal.industry} + /> + } + label="Phone" + value={deal.workPhone ?? '—'} + /> + } + label="Email" + value={deal.email ?? '—'} + /> + + + )} + + {/* Financial Details */} + {active === 'financial' && ( + + + Financial Details + + + } + label="Revenue" + value={`$${deal.revenue.toFixed(2)}`} + /> + } + label="EBITDA" + value={`$${deal.ebitda.toFixed(2)}`} + /> + } + label="EBITDA Margin" + value={`${((deal.ebitda / deal.revenue) * 100).toFixed(2)}%`} + /> + } + label="Asking Price" + value={ + deal.askingPrice != null + ? `$${deal.askingPrice.toFixed(2)}` + : '—' + } + /> + } + label="Gross Revenue" + value={ + deal.grossRevenue != null + ? `$${deal.grossRevenue.toFixed(2)}` + : '—' + } + /> + + + )} + + {/* AI Reasoning */} + {active === 'ai' && ( + + + AI Reasoning + + + + + }> + + + + + + )} + + {/* Deal Documents */} + {active === 'docs' && ( + + + Deal Documents + + + + + + + + + )} + + {/* SIMs */} + {active === 'sims' && ( + + + SIMs + + + + + }> + + + + + + )} + + {/* Point of Contacts */} + {active === 'pocs' && ( + + + Point of Contacts + + + + + + + + )} + + {/* Version History */} + {active === 'history' && ( + + + Version History + + + + + + )} +
+
+ ); +} diff --git a/components/Dialogs/deal-document-upload-dialog.tsx b/components/Dialogs/deal-document-upload-dialog.tsx index 4b05a7a..af2a306 100644 --- a/components/Dialogs/deal-document-upload-dialog.tsx +++ b/components/Dialogs/deal-document-upload-dialog.tsx @@ -40,11 +40,13 @@ import { toast } from "sonner"; interface DealDocumentUploadDialogProps { dealId: string; dealType: DealType; + onUploaded?: () => void; } const DealDocumentUploadDialog: React.FC = ({ dealId, dealType, + onUploaded, }) => { const [isPending, startTransition] = useTransition(); const [isOpen, setIsOpen] = React.useState(false); @@ -79,6 +81,7 @@ const DealDocumentUploadDialog: React.FC = ({ toast.success("Document uploaded successfully"); form.reset(); setIsOpen(false); + onUploaded?.(); } else { throw new Error(result.statusText); } diff --git a/components/FetchDealAIScreenings.tsx b/components/FetchDealAIScreenings.tsx index 8f254ae..57d7f13 100644 --- a/components/FetchDealAIScreenings.tsx +++ b/components/FetchDealAIScreenings.tsx @@ -1,54 +1,76 @@ -import prismaDB from "@/lib/prisma"; -import React from "react"; +"use client"; + +import React, { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; import { AlertTriangle } from "lucide-react"; +import { DealType, Sentiment } from "@prisma/client"; + +const AIReasoning = dynamic(() => import("./AiReasoning"), { ssr: false }); import { Button } from "./ui/button"; -import { DealType } from "@prisma/client"; -import AIReasoning from "./AiReasoning"; -const FetchDealAIScreenings = async ({ - dealId, - dealType, -}: { +interface Props { dealId: string; dealType: DealType; -}) => { - const screenings = await prismaDB.aiScreening.findMany({ - where: { - dealId: dealId, - }, - }); +} + +interface Screening { + id: string; + title: string; + explanation: string; + sentiment: Sentiment; +} + +export default function FetchDealAIScreenings({ dealId, dealType }: Props) { + const [screenings, setScreenings] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + try { + const res = await fetch(`/api/deals/${dealId}/ai-screenings`); + if (res.ok) { + const data = (await res.json()) as Screening[]; + setScreenings(data); + } + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + })(); + }, [dealId]); + + if (loading) { + return
Loading...
; + } return (
-
- {screenings.length > 0 ? ( - screenings.map((e, index) => ( - - )) - ) : ( -
- -

No AI Reasoning Available

-

- AI analysis for this deal has not been generated yet. Check back - later or request an analysis. -

- -
- )} -
+ {screenings.length > 0 ? ( + screenings.map((e) => ( + + )) + ) : ( +
+ +

No AI Reasoning Available

+

+ AI analysis for this deal has not been generated yet. Check back + later or request an analysis. +

+ +
+ )}
); -}; - -export default FetchDealAIScreenings; +} \ No newline at end of file diff --git a/components/FetchDealPOC.tsx b/components/FetchDealPOC.tsx index f999f4f..38a0e7b 100644 --- a/components/FetchDealPOC.tsx +++ b/components/FetchDealPOC.tsx @@ -1,39 +1,57 @@ -import { DealType } from "@prisma/client"; -import React from "react"; -import { getDealPOC } from "@/lib/queries"; -import { Plus, Trash } from "lucide-react"; -import { Button } from "./ui/button"; +"use client"; + +import React, { useEffect, useState } from "react"; +import { AlertTriangle } from "lucide-react"; import AddPocDialog from "./Dialogs/add-poc-dialog"; import DeletePocButton from "./Buttons/delete-poc-button"; -const FetchDealPOC = async ({ - dealId, - dealType, -}: { - dealId: string; - dealType: DealType; -}) => { - const pocs = await getDealPOC(dealId); +interface POC { + id: string; + name: string; + email: string; + workPhone?: string | null; +} + +const FetchDealPOC = ({ dealId }: { dealId: string }) => { + const [pocs, setPocs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + try { + const res = await fetch(`/api/deals/${dealId}/pocs`); + if (res.ok) { + const data = (await res.json()) as POC[]; + setPocs(data); + } + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + })(); + }, [dealId]); + + if (loading) { + return
Loading...
; + } + return (
{pocs.length > 0 ? ( -
    +
      {pocs.map((poc) => (
    • -

      - {poc.name} -

      +

      {poc.name}

      {poc.email}

      {poc.workPhone && ( -

      - {poc.workPhone} -

      +

      {poc.workPhone}

      )}
      @@ -41,12 +59,13 @@ const FetchDealPOC = async ({ ))}
    ) : ( -
    - No points of contact found for this deal. +
    + +

    No points of contact found

    )}
    ); }; -export default FetchDealPOC; +export default FetchDealPOC; \ No newline at end of file diff --git a/components/FetchDealSim.tsx b/components/FetchDealSim.tsx index 3b89afd..b6fef3d 100644 --- a/components/FetchDealSim.tsx +++ b/components/FetchDealSim.tsx @@ -1,24 +1,48 @@ -import prismaDB from "@/lib/prisma"; -import React from "react"; -import SimItem from "@/components/SimItem"; +"use client"; + +import React, { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; import { AlertTriangle } from "lucide-react"; -import { Button } from "./ui/button"; import { DealType } from "@prisma/client"; -// this component will be used to fetch and display all sims for a particular deal +const SimItem = dynamic(() => import("@/components/SimItem"), { ssr: false }); -const FetchDealSim = async ({ - dealId, - dealType, -}: { +interface Props { dealId: string; dealType: DealType; -}) => { - const sims = await prismaDB.sIM.findMany({ - where: { - dealId: dealId, - }, - }); +} + +interface Sim { + id: string; + title: string; + caption: string; + status: string; + fileUrl: string; +} + +export default function FetchDealSim({ dealId, dealType }: Props) { + const [sims, setSims] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + try { + const res = await fetch(`/api/deals/${dealId}/sims`); + if (res.ok) { + const data = (await res.json()) as Sim[]; + setSims(data); + } + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + })(); + }, [dealId]); + + if (loading) { + return
    Loading...
    ; + } return (
    @@ -47,6 +71,4 @@ const FetchDealSim = async ({ )}
    ); -}; - -export default FetchDealSim; +} \ No newline at end of file diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx new file mode 100644 index 0000000..ff9a83c --- /dev/null +++ b/components/ThemeToggle.tsx @@ -0,0 +1,21 @@ +// components/ThemeToggle.tsx +"use client"; + +import React from "react"; +import { useTheme } from "next-themes"; +import { Sun, Moon } from "lucide-react"; + +export default function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + + return ( + + ); +} diff --git a/components/fetch-deal-documents.tsx b/components/fetch-deal-documents.tsx index 89715c6..bf74d88 100644 --- a/components/fetch-deal-documents.tsx +++ b/components/fetch-deal-documents.tsx @@ -1,35 +1,63 @@ -import prismaDB from "@/lib/prisma"; -import React from "react"; +"use client"; + +import React, { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; import { AlertTriangle } from "lucide-react"; import { DealType } from "@prisma/client"; -import DealDocumentItem from "./DealDocumentItem"; -const FetchDealDocuments = async ({ - dealId, - dealType, -}: { +const DealDocumentItem = dynamic(() => import("./DealDocumentItem"), { ssr: false }); + +interface Props { dealId: string; dealType: DealType; -}) => { - const dealDocuments = await prismaDB.dealDocument.findMany({ - where: { - dealId: dealId, - }, - }); + refreshKey?: number; +} + +interface DealDocument { + id: string; + title: string; + description: string | null; + category: string; + documentUrl: string; +} + +export default function FetchDealDocuments({ dealId, dealType, refreshKey }: Props) { + const [documents, setDocuments] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + try { + const res = await fetch(`/api/deals/${dealId}/deal-documents`); + if (res.ok) { + const data = (await res.json()) as DealDocument[]; + setDocuments(data); + } + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + })(); + }, [dealId, refreshKey]); + + if (loading) { + return
    Loading...
    ; + } return (
    - {dealDocuments.length > 0 ? ( - dealDocuments.map((dealDocument) => ( + {documents.length > 0 ? ( + documents.map((doc) => ( )) ) : ( @@ -44,6 +72,4 @@ const FetchDealDocuments = async ({ )}
    ); -}; - -export default FetchDealDocuments; +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 64de4c2..00a20e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: restart: always container_name: the-bitrix24-db ports: - - 5432:5432 + - 5433:5432 environment: POSTGRES_PASSWORD: example PGDATA: /data/postgres diff --git a/lib/data/current-user.ts b/lib/data/current-user.ts index 1cb653b..22d46da 100644 --- a/lib/data/current-user.ts +++ b/lib/data/current-user.ts @@ -1,6 +1,6 @@ // helper function to get the current User -import prismaDB from "../prisma"; +import prismaDB from "../prisma.server"; /** * get the current user by using their id diff --git a/lib/prisma.server.ts b/lib/prisma.server.ts new file mode 100644 index 0000000..17c826c --- /dev/null +++ b/lib/prisma.server.ts @@ -0,0 +1,140 @@ +// lib/prisma.server.ts +import { Prisma, PrismaClient } from "@prisma/client"; + +const prismaClientSingleton = () => + new PrismaClient({ + log: ["info"], + }); + +declare const globalThis: { + prismaGlobal?: ReturnType; +} & typeof global; + +const prismaDB = globalThis.prismaGlobal ?? prismaClientSingleton(); +if (process.env.NODE_ENV !== "production") { + globalThis.prismaGlobal = prismaDB; +} + +/* -------------------------------------------------------------------------- */ +/* Deal Version-History Middleware */ +/* -------------------------------------------------------------------------- */ + +/** + * Recursively remove Prisma-managed timestamp fields so they don’t trigger + * false-positive diffs in history snapshots. + */ +function stripTimestampsDeep(val: T): T { + if (Array.isArray(val)) { + // @ts-ignore – narrow to any[] for recursion then cast back to T + return val.map((v) => stripTimestampsDeep(v)) as unknown as T; + } + if (val && typeof val === "object") { + const obj: Record = {}; + Object.entries(val as Record).forEach(([k, v]) => { + if (k === "createdAt" || k === "updatedAt") return; + obj[k] = stripTimestampsDeep(v); + }); + return obj as T; + } + return val; +} + +// Models whose mutations should create a history snapshot for the parent Deal +const TRACKED_MODELS = [ + "Deal", + "AiScreening", + "SIM", + "POC", + "DealDocument", +] as const satisfies ReadonlyArray; + +const MUTATING_ACTIONS = [ + "create", + "update", + "upsert", + "delete", +] as const satisfies ReadonlyArray; + +prismaDB.$use(async (params, next) => { + const isTracked = + (TRACKED_MODELS as readonly string[]).includes(params.model ?? "") && + (MUTATING_ACTIONS as readonly string[]).includes(params.action); + + let dealId: string | null = null; + + /* ---------------------- Determine affected dealId ---------------------- */ + if (isTracked) { + if (params.model === "Deal") { + // For Deal itself we know the id either from where or result + if (params.action === "create") { + // new deal – will get id from result later + } else if (params.args?.where?.id) { + dealId = params.args.where.id as string; + } + } else { + // Child models contain dealId column + if (params.action === "delete") { + // Need to fetch existing row to read its dealId before deletion + const existing: any = await ( + // @ts-ignore dynamic access to model delegate + prismaDB[params.model!.charAt(0).toLowerCase() + params.model!.slice(1)].findUnique({ + where: { id: params.args.where.id as string }, + }) + ); + dealId = existing?.dealId ?? null; + } else { + // create/update/upsert + dealId = + (params.args?.data?.dealId as string | undefined) ?? + (params.args?.where?.dealId as string | undefined) ?? + null; + } + } + } + + // Perform the actual DB mutation first + const result = await next(params); + + if (isTracked) { + // For Deal.create we now have the id in result + if (!dealId && params.model === "Deal" && typeof result?.id === "string") { + dealId = result.id; + } + + if (dealId) { + // Fetch a fresh, full snapshot of the deal including children + const snapshot = await prismaDB.deal.findUnique({ + where: { id: dealId }, + include: { + AiScreening: true, + POC: true, + DealDocument: true, + SIM: true, + }, + }); + + if (snapshot) { + const latest = await prismaDB.dealHistory.findFirst({ + where: { dealId }, + orderBy: { createdAt: "desc" }, + }); + + const cleanedNew = stripTimestampsDeep(snapshot); + const cleanedPrev = latest ? stripTimestampsDeep(latest.snapshot) : null; + + if (!cleanedPrev || JSON.stringify(cleanedPrev) !== JSON.stringify(cleanedNew)) { + await prismaDB.dealHistory.create({ + data: { + dealId, + snapshot: cleanedNew, + }, + }); + } + } + } + } + + return result; +}); + +export default prismaDB; \ No newline at end of file diff --git a/lib/prisma.ts b/lib/prisma.ts deleted file mode 100644 index 7405b61..0000000 --- a/lib/prisma.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { PrismaClient } from "@prisma/client"; - -const prismaClientSingleton = () => { - return new PrismaClient({ - log: ["info"], - }); -}; - -declare const globalThis: { - prismaGlobal: ReturnType; -} & typeof global; - -const prismaDB = globalThis.prismaGlobal ?? prismaClientSingleton(); - -export default prismaDB; - -if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prismaDB; diff --git a/lib/queries.ts b/lib/queries.ts index d53f52f..3a646ad 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -1,7 +1,7 @@ // all our database queries for the app import { User } from "next-auth"; -import prismaDB from "./prisma"; +import prismaDB from "./prisma.server"; import { withAuthServerAction } from "./withAuth"; import axios from "axios"; import { BitrixDealGET } from "@/app/types"; diff --git a/lib/schemas.ts b/lib/schemas.ts index db06535..63d4bdc 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -1,3 +1,4 @@ +// lib/schemas.ts import * as z from "zod"; import { DealDocumentCategory, DealStatus } from "@prisma/client"; @@ -12,32 +13,49 @@ export type dealSpecificationsFormSchemaType = z.infer< typeof dealSpecificationsFormSchema >; +// define a helper that is either "instanceof File + size check" in the browser, +// or just a no-op z.any() on the server where File === undefined +const fileSchema = typeof File !== "undefined" + ? z + .instanceof(File) + .refine((file) => file.size <= 20 * 1024 * 1024, { + message: "File size must be less than 20MB", + }) + : z.any(); + export const dealDocumentFormSchema = z.object({ - title: z.string().min(1, "Title is required"), + title: z.string().min(1, "Title is required"), description: z.string().min(1, "Description is required"), - category: z.nativeEnum(DealDocumentCategory), - file: z.instanceof(File).refine((file) => file.size <= 20 * 1024 * 1024, { - message: "File size must be less than 20MB", - }), + category: z.nativeEnum(DealDocumentCategory), + file: fileSchema, }); -export type DealDocumentFormValues = z.infer; +export type DealDocumentFormValues = z.infer< + typeof dealDocumentFormSchema +>; + +// and do the same for your CIM upload +const cimFileSchema = typeof File !== "undefined" + ? z + .instanceof(File) + .refine((file) => file.size <= 10 * 1024 * 1024, { + message: "File size must be less than 10MB", + }) + : z.any(); export const cimFormSchema = z.object({ - title: z.string().min(1, "Title is required"), + title: z.string().min(1, "Title is required"), caption: z.string().min(1, "Caption is required"), - status: z.enum(["IN_PROGRESS", "COMPLETED"]), - file: z.instanceof(File).refine((file) => file.size <= 10 * 1024 * 1024, { - message: "File size must be less than 10MB", - }), + status: z.enum(["IN_PROGRESS", "COMPLETED"]), + file: cimFileSchema, }); export type CimFormValues = z.infer; export const screenDealSchema = z.object({ - title: z.string(), + title: z.string(), explanation: z.string(), - sentiment: z.enum(["POSITIVE", "NEUTRAL", "NEGATIVE"]), + sentiment: z.enum(["POSITIVE", "NEUTRAL", "NEGATIVE"]), }); export type screenDealSchemaType = z.infer; diff --git a/lib/server/schemas.ts b/lib/server/schemas.ts new file mode 100644 index 0000000..9e26f80 --- /dev/null +++ b/lib/server/schemas.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +// Define or import the DealDocumentCategory enum +export enum DealDocumentCategory { + SALES = 'SALES', + PURCHASE = 'PURCHASE', + OTHER = 'OTHER', +} + +// lib/server/schemas.ts (no File references) +export const dealDocumentServerSchema = z.object({ + title: z.string().min(1), + description: z.string().min(1), + category: z.nativeEnum(DealDocumentCategory), +}) + +// lib/client/schemas.ts (file validation lives here) +export const dealDocumentClientSchema = dealDocumentServerSchema.extend({ + file: z + .instanceof(File) + .refine((f) => f.size <= 20 * 1024 * 1024, { + message: 'File size must be < 20 MB', + }), +}) diff --git a/package-lock.json b/package-lock.json index 0e942d3..e2ba9f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@ai-sdk/react": "^1.2.9", "@auth/prisma-adapter": "^2.7.4", "@clerk/nextjs": "^6.5.1", + "@faker-js/faker": "^9.9.0", + "@fontsource-variable/geist-mono": "^5.2.6", "@hookform/resolvers": "^3.9.1", "@prisma/client": "^6.1.0", "@radix-ui/react-accordion": "^1.2.1", @@ -62,6 +64,7 @@ "next": "15.0.3", "next-auth": "^5.0.0-beta.25", "next-themes": "^0.4.6", + "node-fetch": "^3.3.2", "openai": "^4.97.0", "pdf-parse": "^1.1.1", "react": "19.0.0-rc-66855b96-20241106", @@ -540,6 +543,22 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@faker-js/faker": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz", + "integrity": "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -1144,6 +1163,15 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" }, + "node_modules/@fontsource-variable/geist-mono": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.2.6.tgz", + "integrity": "sha512-vw6T9JGTrYJ980bn7W8iTPhe2jVK5ifunVs7xh9dfTVArjDSkJs03JjeZrH5LKEpGABLXSlSlNU57HRm4tmFMg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.9.15", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", @@ -5190,6 +5218,26 @@ "node-fetch": "^2.7.0" } }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", @@ -5343,6 +5391,15 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -6423,6 +6480,38 @@ "node": ">=0.8.0" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fetch-blob/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -6609,6 +6698,18 @@ "node": ">= 12.20" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/frac": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", @@ -9102,23 +9203,21 @@ "license": "MIT" }, "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "license": "MIT", "dependencies": { - "whatwg-url": "^5.0.0" + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" }, "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, "node_modules/normalize-path": { @@ -9305,6 +9404,26 @@ "undici-types": "~5.26.4" } }, + "node_modules/openai/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/openai/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/package.json b/package.json index 3343d23..afe589e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "@ai-sdk/react": "^1.2.9", "@auth/prisma-adapter": "^2.7.4", "@clerk/nextjs": "^6.5.1", + "@faker-js/faker": "^9.9.0", + "@fontsource-variable/geist-mono": "^5.2.6", "@hookform/resolvers": "^3.9.1", "@prisma/client": "^6.1.0", "@radix-ui/react-accordion": "^1.2.1", @@ -70,6 +72,7 @@ "next": "15.0.3", "next-auth": "^5.0.0-beta.25", "next-themes": "^0.4.6", + "node-fetch": "^3.3.2", "openai": "^4.97.0", "pdf-parse": "^1.1.1", "react": "19.0.0-rc-66855b96-20241106", @@ -106,5 +109,6 @@ "tailwindcss": "^3.4.1", "ts-node": "^10.9.2", "typescript": "^5.8.3" - } + }, + "type": "module" } diff --git a/prisma/migrations/20250729061122_add_description/migration.sql b/prisma/migrations/20250729061122_add_description/migration.sql new file mode 100644 index 0000000..bb95213 --- /dev/null +++ b/prisma/migrations/20250729061122_add_description/migration.sql @@ -0,0 +1,200 @@ +/* + 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 "DealDocumentCategory" AS ENUM ('LEGAL', 'DOCUMENTATION', 'MARKETING', 'INVESTOR_RELATIONSHIPS', 'TECHNICAL', 'TOOLS', 'LEGISLATION', 'RESEARCH', 'PROSPECTUS', 'FINANCIALS', 'OTHER'); + +-- CreateEnum +CREATE TYPE "DealType" AS ENUM ('SCRAPED', 'MANUAL', 'AI_INFERRED'); + +-- 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, + "description" TEXT, + "dealTeaser" 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, + "bitrixId" TEXT, + "bitrixCreatedAt" TIMESTAMP(3), + "userId" TEXT, + + CONSTRAINT "Deal_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DealDocument" ( + "id" TEXT NOT NULL, + "dealId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "category" "DealDocumentCategory" NOT NULL DEFAULT 'OTHER', + "documentUrl" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DealDocument_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "POC" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "workPhone" TEXT, + "email" TEXT NOT NULL, + "dealId" TEXT, + + CONSTRAINT "POC_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, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" 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, + "score" INTEGER, + "content" TEXT, + "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") +); + +-- CreateTable +CREATE TABLE "UserActionLog" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserActionLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Employee" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "dealId" TEXT, + + CONSTRAINT "Employee_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 "Deal" ADD CONSTRAINT "Deal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DealDocument" ADD CONSTRAINT "DealDocument_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "POC" ADD CONSTRAINT "POC_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SIM" ADD CONSTRAINT "SIM_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AiScreening" ADD CONSTRAINT "AiScreening_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserActionLog" ADD CONSTRAINT "UserActionLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Employee" ADD CONSTRAINT "Employee_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20250729061747_add_published_at/migration.sql b/prisma/migrations/20250729061747_add_published_at/migration.sql new file mode 100644 index 0000000..df027bd --- /dev/null +++ b/prisma/migrations/20250729061747_add_published_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Deal" ADD COLUMN "publishedAt" TIMESTAMP(3); diff --git a/prisma/migrations/20250730071405_add_raw_deal_fields/migration.sql b/prisma/migrations/20250730071405_add_raw_deal_fields/migration.sql new file mode 100644 index 0000000..17867d4 --- /dev/null +++ b/prisma/migrations/20250730071405_add_raw_deal_fields/migration.sql @@ -0,0 +1,10 @@ +-- CreateEnum +CREATE TYPE "DealStatus" AS ENUM ('AVAILABLE', 'SOLD', 'UNDER_CONTRACT'); + +-- AlterTable +ALTER TABLE "Deal" ADD COLUMN "bitrixLink" TEXT, +ADD COLUMN "published" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "reviewed" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "seen" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "status" "DealStatus" NOT NULL DEFAULT 'AVAILABLE', +ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/migrations/20250730075042_add_deal_history/migration.sql b/prisma/migrations/20250730075042_add_deal_history/migration.sql new file mode 100644 index 0000000..b48c889 --- /dev/null +++ b/prisma/migrations/20250730075042_add_deal_history/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "DealHistory" ( + "id" TEXT NOT NULL, + "dealId" TEXT NOT NULL, + "snapshot" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DealHistory_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "DealHistory" ADD CONSTRAINT "DealHistory_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index fbffa92..648c57f 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) +# It should be added in your version-control system (e.g., Git) provider = "postgresql" \ No newline at end of file diff --git a/prisma/queries.ts b/prisma/queries.ts index f489e04..75716c2 100644 --- a/prisma/queries.ts +++ b/prisma/queries.ts @@ -1,4 +1,4 @@ -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; /** * Get a user by their ID diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8cee3fc..7714f6e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,6 +68,7 @@ model Deal { revenue Float ebitda Float title String? + description String? dealTeaser String? grossRevenue Float? askingPrice Float? @@ -78,8 +79,16 @@ model Deal { companyLocation String? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + publishedAt DateTime? SIM SIM[] AiScreening AiScreening[] + history DealHistory[] + status DealStatus @default(AVAILABLE) + tags String[] @default([]) + reviewed Boolean @default(false) + seen Boolean @default(false) + bitrixLink String? + published Boolean @default(false) bitrixLink String? status DealStatus @default(NOT_SPECIFIED) @@ -170,6 +179,12 @@ enum SIMStatus { COMPLETED } +enum DealStatus { + AVAILABLE + SOLD + UNDER_CONTRACT +} + model AiScreening { id String @id @default(cuid()) dealId String @@ -206,3 +221,11 @@ model Employee { Deal Deal? @relation(fields: [dealId], references: [id]) dealId String? } + +model DealHistory { + id String @id @default(cuid()) + dealId String + deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade) + snapshot Json // JSON of the Deal at that moment + createdAt DateTime @default(now()) +} diff --git a/prisma/scripts/deleteAllDeals.ts b/prisma/scripts/deleteAllDeals.ts index 037b33c..924526f 100644 --- a/prisma/scripts/deleteAllDeals.ts +++ b/prisma/scripts/deleteAllDeals.ts @@ -1,4 +1,4 @@ -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; async function main() { console.log("deleting all deals"); diff --git a/prisma/scripts/updateEbitdaMargin.ts b/prisma/scripts/updateEbitdaMargin.ts index 0079113..30cd1f0 100644 --- a/prisma/scripts/updateEbitdaMargin.ts +++ b/prisma/scripts/updateEbitdaMargin.ts @@ -1,4 +1,4 @@ -import prismaDB from "@/lib/prisma"; +import prismaDB from "@/lib/prisma.server"; async function main() { const deals = await prismaDB.deal.findMany(); diff --git a/prisma/seed.ts b/prisma/seed.ts index aeb6711..3eeca58 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,4 +1,4 @@ -import prismaDB from "../lib/prisma"; +import prismaDB from "../lib/prisma.server"; async function main() { console.log("Starting seeding"); diff --git a/scripts/generateDummyDeals.cjs b/scripts/generateDummyDeals.cjs new file mode 100644 index 0000000..47bd462 --- /dev/null +++ b/scripts/generateDummyDeals.cjs @@ -0,0 +1,34 @@ +// scripts/generateDummyDeals.cjs + +;(async () => { + const { PrismaClient } = require('@prisma/client'); + const { faker } = require('@faker-js/faker'); + const prisma = new PrismaClient(); + + try { + // generate 10 fake deals + for (let i = 0; i < 10; i++) { + await prisma.deal.create({ + data: { + brokerage: faker.company.name(), + dealCaption: faker.lorem.sentence(), + revenue: parseFloat(faker.finance.amount(1e6, 1e8, 2)), + ebitda: parseFloat(faker.finance.amount(1e5, 1e7, 2)), + ebitdaMargin: parseFloat(faker.finance.amount(0, 50, 2)), + industry: faker.commerce.department(), + sourceWebsite: faker.internet.url(), + title: faker.lorem.words(3), + description: faker.lorem.paragraph(), + publishedAt: faker.date.recent(), + }, + }); + } + + console.log('✔ Inserted 10 dummy deals'); + } catch (err) { + console.error(err); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +})(); diff --git a/scripts/seedFromProd.cjs b/scripts/seedFromProd.cjs new file mode 100644 index 0000000..410b4e8 --- /dev/null +++ b/scripts/seedFromProd.cjs @@ -0,0 +1,48 @@ +// scripts/seedFromProd.cjs +;(async () => { + // Node 18+ has fetch built‑in + const { PrismaClient } = require('@prisma/client') + const prisma = new PrismaClient() + + try { + // 1. fetch live deals + const res = await fetch('http://localhost:3000/api/raw-deals') + if (!res.ok) { + throw new Error(`Fetch failed: ${res.status} ${res.statusText}`) + } + const deals = await res.json() + + // 2. upsert + for (const d of deals) { + await prisma.deal.upsert({ + where: { id: d.id }, + create: { + id: d.id, + brokerage: d.brokerage, + dealCaption: d.dealCaption, + revenue: d.revenue, + ebitda: d.ebitda, + title: d.title ?? '', + description: d.description ?? '', + ebitdaMargin: d.ebitdaMargin, + industry: d.industry, + sourceWebsite: d.sourceWebsite, + publishedAt: new Date(d.publishedAt), + }, + update: { + title: d.title, + description: d.description, + ebitdaMargin: d.ebitdaMargin, + publishedAt: new Date(d.publishedAt), + }, + }) + } + + console.log(`✔ Upserted ${deals.length} deals`) + } catch (err) { + console.error(err) + process.exit(1) + } finally { + await prisma.$disconnect() + } +})() diff --git a/scripts/seedFromProd.ts b/scripts/seedFromProd.ts new file mode 100644 index 0000000..fc96169 --- /dev/null +++ b/scripts/seedFromProd.ts @@ -0,0 +1,64 @@ +// scripts/seedFromProd.ts +import fetch from 'node-fetch' +import { PrismaClient } from '@prisma/client' + +type DealDTO = { + id: string + brokerage: string + dealCaption: string + revenue: number + ebitda: number + title?: string + description?: string + ebitdaMargin: number + industry: string + sourceWebsite: string + publishedAt: string +} + +const prisma = new PrismaClient() + +async function main() { + // 1. Fetch the live deals JSON + const res = await fetch('https://bitrix24-three.vercel.app/api/raw-deals') + if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${res.statusText}`) + const deals = (await res.json()) as DealDTO[] + + + // 2. Upsert each deal + for (const d of deals) { + await prisma.deal.upsert({ + where: { id: d.id }, + create: { + id: d.id, + brokerage: d.brokerage, + dealCaption: d.dealCaption, + revenue: d.revenue, + ebitda: d.ebitda, + title: d.title ?? '', + description: d.description ?? '', + ebitdaMargin: d.ebitdaMargin, + industry: d.industry, + sourceWebsite: d.sourceWebsite, + publishedAt: new Date(d.publishedAt), + }, + update: { + title: d.title, + description: d.description, + ebitdaMargin: d.ebitdaMargin, + publishedAt: new Date(d.publishedAt), + }, + }) + } + + console.log(`✔ Upserted ${deals.length} deals`) +} + +main() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) From 761f4dd66aac584caa60e73d1d54c1df80affdbd Mon Sep 17 00:00:00 2001 From: Kavish Shah Date: Wed, 13 Aug 2025 22:37:48 -0700 Subject: [PATCH 2/2] Fix rebase conflicts; align fields to published/reviewed; add prisma shim; type fixes; ambient redis types; update Redis usage --- app/(protected)/raw-deals/[uid]/page.tsx | 34 +++++++++--------- app/actions/get-deal.ts | 4 +-- app/actions/update-deal-specifications.ts | 4 +-- app/api/deals/[id]/versions/route.ts | 4 +-- app/api/deals/pending/route.ts | 42 +++++++++++++---------- app/api/screen-all/route.ts | 6 ++-- components/DealCard.tsx | 8 ++--- components/DealListItem.tsx | 4 +-- components/FetchDealPOC.tsx | 3 +- components/fetch-deal-documents.tsx | 2 +- lib/prisma.ts | 6 ++++ lib/redis.ts | 13 ++++--- tsconfig.json | 2 +- types/redis.d.ts | 11 ++++++ 14 files changed, 87 insertions(+), 56 deletions(-) create mode 100644 lib/prisma.ts create mode 100644 types/redis.d.ts diff --git a/app/(protected)/raw-deals/[uid]/page.tsx b/app/(protected)/raw-deals/[uid]/page.tsx index 8258df3..2d99628 100644 --- a/app/(protected)/raw-deals/[uid]/page.tsx +++ b/app/(protected)/raw-deals/[uid]/page.tsx @@ -124,8 +124,8 @@ export default async function ManualDealSpecificPage(props: { companyLocation, industry, ebitdaMargin, - isReviewed, - isPublished, + reviewed, + published, tags, status, askingPrice, @@ -179,23 +179,23 @@ export default async function ManualDealSpecificPage(props: { Reviewed - - {isReviewed ? "Yes" : "No"} - + + {reviewed ? "Yes" : "No"} +
Published - - {isPublished ? "Yes" : "No"} - + + {published ? "Yes" : "No"} +
@@ -216,7 +216,7 @@ export default async function ManualDealSpecificPage(props: {
{tags && tags.length > 0 ? (
- {tags.map((tag) => ( + {tags.map((tag: string) => ( {tag} ))}
@@ -232,8 +232,8 @@ export default async function ManualDealSpecificPage(props: { diff --git a/app/actions/get-deal.ts b/app/actions/get-deal.ts index 7488c8c..49ac4d4 100644 --- a/app/actions/get-deal.ts +++ b/app/actions/get-deal.ts @@ -129,8 +129,8 @@ export const GetAllDeals = async ({ ? { ebitdaMargin: { gte: ebitdaMarginValue } } : {}), ...(showSeen ? { seen: { equals: showSeen } } : {}), - ...(showReviewed ? { isReviewed: { equals: showReviewed } } : {}), - ...(showPublished ? { isPublished: { equals: showPublished } } : {}), + ...(showReviewed ? { reviewed: { equals: showReviewed } } : {}), + ...(showPublished ? { published: { equals: showPublished } } : {}), ...(status ? { status: { equals: status } } : {}), ...(tags && tags.length > 0 ? { tags: { hasSome: tags } } : {}), }; diff --git a/app/actions/update-deal-specifications.ts b/app/actions/update-deal-specifications.ts index 81dd619..33e1295 100644 --- a/app/actions/update-deal-specifications.ts +++ b/app/actions/update-deal-specifications.ts @@ -47,8 +47,8 @@ export async function updateDealSpecificationsAction( data: { seen: validatedFields.data.seen, status: validatedFields.data.status, - isReviewed: validatedFields.data.isReviewed, - isPublished: validatedFields.data.isPublished, + reviewed: validatedFields.data.isReviewed, + published: validatedFields.data.isPublished, }, }); diff --git a/app/api/deals/[id]/versions/route.ts b/app/api/deals/[id]/versions/route.ts index 5c6026e..69ec9f2 100644 --- a/app/api/deals/[id]/versions/route.ts +++ b/app/api/deals/[id]/versions/route.ts @@ -29,8 +29,8 @@ export async function GET( if ( versions.length === 0 || - JSON.stringify(stripTimestamps(versions[versions.length - 1].snapshot)) !== - JSON.stringify(stripTimestamps(v.snapshot)) + JSON.stringify(stripTimestamps(versions[versions.length - 1].snapshot as Record)) !== + JSON.stringify(stripTimestamps(v.snapshot as Record)) ) { versions.push(v); seenSeconds.add(tsSec); diff --git a/app/api/deals/pending/route.ts b/app/api/deals/pending/route.ts index a68b81e..cf5e648 100644 --- a/app/api/deals/pending/route.ts +++ b/app/api/deals/pending/route.ts @@ -1,7 +1,14 @@ import { auth } from "@/auth"; -import { redisClient } from "@/lib/redis"; +import { getRedisClient } from "@/lib/redis"; import { NextResponse } from "next/server"; +interface RedisDealListing { + id: string; + title: string; + ebitda: number; + userId: string; +} + export async function GET() { const userSession = await auth(); @@ -10,10 +17,8 @@ export async function GET() { } try { - // ensure connected - if (!redisClient.isOpen) { - await redisClient.connect(); - } + const redisClient = await getRedisClient(); + if (!redisClient.isOpen) await redisClient.connect(); } catch (error) { console.error("Error connecting to Redis:", error); return NextResponse.json( @@ -23,25 +28,26 @@ export async function GET() { } try { - const raw = await redisClient.lRange("dealListings", 0, -1); + const raw = await (await getRedisClient()).lRange("dealListings", 0, -1); - // parse and filter by this user - const all = raw - .map((s) => { + // parse and filter by this user with typing + const all: RedisDealListing[] = raw + .map((s: string) => { try { - return JSON.parse(s); + return JSON.parse(s) as RedisDealListing; } catch { return null; } }) - .filter((d) => d && d.userId === userSession.user.id); - - // map to shape: { id, productName, status: "Pending" } - const pending = all.map(({ id, title, ebitda }) => ({ - id, - title: title, - ebitda: ebitda, - status: "Pending", + .filter((d: RedisDealListing | null): d is RedisDealListing => !!d) + .filter((d: RedisDealListing) => d.userId === userSession.user.id); + + // map to response shape + const pending = all.map((d: RedisDealListing) => ({ + id: d.id, + title: d.title, + ebitda: d.ebitda, + status: "Pending" as const, })); console.log("pending", pending); diff --git a/app/api/screen-all/route.ts b/app/api/screen-all/route.ts index a2eb6f0..7062f09 100644 --- a/app/api/screen-all/route.ts +++ b/app/api/screen-all/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/auth"; -import { redisClient } from "@/lib/redis"; +import { getRedisClient } from "@/lib/redis"; import { NextResponse } from "next/server"; export async function POST(request: Request) { @@ -22,6 +22,7 @@ export async function POST(request: Request) { try { console.log("connecting to redis"); + const redisClient = await getRedisClient(); if (!redisClient.isOpen) { await redisClient.connect(); } @@ -37,6 +38,7 @@ export async function POST(request: Request) { try { console.log("pushing all the deals to bitrix"); + const redisClient = await getRedisClient(); dealListings.forEach(async (dealListing: any) => { const dealListingWithUserId = { ...dealListing, @@ -50,7 +52,7 @@ export async function POST(request: Request) { }); // publish the message that a new screening call request was made - await redisClient.publish( + await (await getRedisClient()).publish( "new_screen_call", JSON.stringify({ userId: userSession.user.id, diff --git a/components/DealCard.tsx b/components/DealCard.tsx index bb00171..801d1c8 100644 --- a/components/DealCard.tsx +++ b/components/DealCard.tsx @@ -165,27 +165,27 @@ const DealCard = ({ ) : ( ) } label="Published" - value={deal.isPublished ? "Yes" : "No"} + value={deal.published ? "Yes" : "No"} className="whitespace-nowrap" /> ) : ( ) } label="Reviewed" - value={deal.isReviewed ? "Yes" : "No"} + value={deal.reviewed ? "Yes" : "No"} className="whitespace-nowrap" /> diff --git a/components/DealListItem.tsx b/components/DealListItem.tsx index a97571f..9adf9f5 100644 --- a/components/DealListItem.tsx +++ b/components/DealListItem.tsx @@ -35,8 +35,8 @@ export default function DealListItem({ deal, selected, onToggle }: Props) {

Email: {deal.email}

Status: {deal.status}

-

Reviewed: {deal.isReviewed ? "Yes" : "No"}

-

Published: {deal.isPublished ? "Yes" : "No"}

+

Reviewed: {deal.reviewed ? "Yes" : "No"}

+

Published: {deal.published ? "Yes" : "No"}

LinkedIn: {deal.linkedinUrl}

Phone: {deal.workPhone}

diff --git a/components/FetchDealPOC.tsx b/components/FetchDealPOC.tsx index 38a0e7b..3912ce6 100644 --- a/components/FetchDealPOC.tsx +++ b/components/FetchDealPOC.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react"; import { AlertTriangle } from "lucide-react"; import AddPocDialog from "./Dialogs/add-poc-dialog"; import DeletePocButton from "./Buttons/delete-poc-button"; +import type { DealType } from "@prisma/client"; interface POC { id: string; @@ -12,7 +13,7 @@ interface POC { workPhone?: string | null; } -const FetchDealPOC = ({ dealId }: { dealId: string }) => { +const FetchDealPOC = ({ dealId, dealType }: { dealId: string; dealType: DealType }) => { const [pocs, setPocs] = useState([]); const [loading, setLoading] = useState(true); diff --git a/components/fetch-deal-documents.tsx b/components/fetch-deal-documents.tsx index bf74d88..e81c59b 100644 --- a/components/fetch-deal-documents.tsx +++ b/components/fetch-deal-documents.tsx @@ -17,7 +17,7 @@ interface DealDocument { id: string; title: string; description: string | null; - category: string; + category: import("@prisma/client").DealDocumentCategory; documentUrl: string; } diff --git a/lib/prisma.ts b/lib/prisma.ts new file mode 100644 index 0000000..5111401 --- /dev/null +++ b/lib/prisma.ts @@ -0,0 +1,6 @@ +// Re-export the Prisma client for backward compatibility with imports from "@/lib/prisma" +import prismaDB from "./prisma.server"; + +export default prismaDB; + + diff --git a/lib/redis.ts b/lib/redis.ts index ae13037..f74fd8a 100644 --- a/lib/redis.ts +++ b/lib/redis.ts @@ -1,5 +1,10 @@ -import { createClient } from "redis"; +let cachedClient: any; -export const redisClient = createClient({ - url: "redis://localhost:6379", -}); +export async function getRedisClient() { + if (cachedClient) return cachedClient; + const { createClient } = await import("redis"); + const url = process.env.REDIS_URL || "redis://localhost:6379"; + const client = createClient({ url }); + cachedClient = client; + return client; +} diff --git a/tsconfig.json b/tsconfig.json index d8b9323..90b988a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,6 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "types/**/*.d.ts"], "exclude": ["node_modules"] } diff --git a/types/redis.d.ts b/types/redis.d.ts new file mode 100644 index 0000000..7091c99 --- /dev/null +++ b/types/redis.d.ts @@ -0,0 +1,11 @@ +declare module "redis" { + export function createClient(options?: { url?: string }): { + isOpen: boolean; + connect: () => Promise; + lRange: (key: string, start: number, stop: number) => Promise; + lPush: (key: string, value: string) => Promise; + publish: (channel: string, message: string) => Promise; + }; +} + +