From e42168e742ad5f7cec8a8e7b1bab280f87b57947 Mon Sep 17 00:00:00 2001 From: Matthew Moen Date: Mon, 13 Oct 2025 21:26:04 -0600 Subject: [PATCH 1/2] Screener Notifcation Update --- app/(protected)/notifications/page.tsx | 178 +++++++++++++++++++ app/(protected)/raw-deals/[uid]/page.tsx | 2 +- app/api/notifications/route.ts | 106 ++++++++++++ app/api/screen-all/route.ts | 178 +++++++++++++------ components/NotificationPopover.tsx | 208 +++++++++++++++++------ docker-compose.yml | 15 ++ prisma/schema.prisma | 15 ++ 7 files changed, 597 insertions(+), 105 deletions(-) create mode 100644 app/(protected)/notifications/page.tsx create mode 100644 app/api/notifications/route.ts diff --git a/app/(protected)/notifications/page.tsx b/app/(protected)/notifications/page.tsx new file mode 100644 index 0000000..1daade8 --- /dev/null +++ b/app/(protected)/notifications/page.tsx @@ -0,0 +1,178 @@ +"use client"; + +import React, { useEffect, useState, useCallback, useRef } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { Clock, FileText } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type ScreenerNotification = { + id: string; + title: string; + status: string; +}; + +type WebSocketMessage = + | { type: "new_screen_call"; userId: string } + | { + type: "problem_done"; + userId: string; + productId: string; + status: string; + productName?: string; + } + | { type: "job_update"; jobId: string; status?: string; result?: string }; + +const NotificationsPage = ({ userId }: { userId: string }) => { + const [notifications, setNotifications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [wsConnected, setWsConnected] = useState(false); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const retryDelayRef = useRef(1000); + + const fetchNotifications = useCallback(async () => { + setIsLoading(true); + try { + const res = await fetch("/api/notifications"); + if (!res.ok) throw new Error("Failed to fetch notifications"); + const data: ScreenerNotification[] = await res.json(); + setNotifications(data); + } catch (err) { + console.error("❌ Error fetching notifications:", err); + } finally { + setIsLoading(false); + } + }, []); + + // WebSocket connection + const connectWebSocket = useCallback(() => { + if (!userId) return; + + const url = process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://localhost:8080"; + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + setWsConnected(true); + ws.send(JSON.stringify({ type: "register", userId })); + retryDelayRef.current = 1000; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }; + + ws.onmessage = (e) => { + try { + const msg: WebSocketMessage = JSON.parse(e.data); + if (msg.type === "new_screen_call" || (msg.type === "problem_done" && msg.productId)) { + fetchNotifications(); + } + } catch { + // ignore parsing errors + } + }; + + const scheduleReconnect = () => { + if (reconnectTimeoutRef.current) return; + const delay = Math.min(retryDelayRef.current, 10000); + reconnectTimeoutRef.current = setTimeout(() => { + reconnectTimeoutRef.current = null; + connectWebSocket(); + }, delay); + retryDelayRef.current = Math.min(delay * 2, 10000); + }; + + ws.onclose = () => { + setWsConnected(false); + scheduleReconnect(); + }; + ws.onerror = () => { + setWsConnected(false); + scheduleReconnect(); + }; + }, [userId, fetchNotifications]); + + useEffect(() => { + fetchNotifications(); // initial fetch + connectWebSocket(); + return () => { + wsRef.current?.close(); + wsRef.current = null; + if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); + retryDelayRef.current = 1000; + }; + }, [connectWebSocket, fetchNotifications]); + + useEffect(() => { + const onFocus = () => fetchNotifications(); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [fetchNotifications]); + + return ( +
+

+ + Notifications +

+ + {!wsConnected && ( +
+ ⚠ Connection lost. Attempting to reconnect... +
+ )} + + {isLoading ? ( +
+
+
+ Loading notifications... +
+
+ ) : notifications.length === 0 ? ( +
+ +

+ No notifications +

+

+ Any new notifications will appear here +

+
+ ) : ( + +
+ {notifications.map((n) => ( +
+
+
+ {n.title} +
+
+
+ + {n.status} + +
+
+ ))} +
+
+ )} +
+ ); +}; + +export default NotificationsPage; diff --git a/app/(protected)/raw-deals/[uid]/page.tsx b/app/(protected)/raw-deals/[uid]/page.tsx index 7054ce4..8db88e6 100644 --- a/app/(protected)/raw-deals/[uid]/page.tsx +++ b/app/(protected)/raw-deals/[uid]/page.tsx @@ -77,7 +77,7 @@ export default async function ManualDealSpecificPage(props: { const { uid } = await props.params; const userSession = await auth(); - if (!userSession) redirect("/login"); + if (!userSession) redirect("/login"); const fetchedDeal = await prismaDB.deal.findUnique({ where: { diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..0bd7288 --- /dev/null +++ b/app/api/notifications/route.ts @@ -0,0 +1,106 @@ +// app/api/notifications/route.ts + +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import prisma from "@/lib/prisma"; + +/** + * Handles GET requests to `/api/notifications` + * ------------------------------------------------------------ + * This route is used by the frontend to fetch the most recent + * notifications for the currently authenticated user. + * + * Workflow: + * 1. Verify user session using `auth()` + * 2. Fetch the latest 100 notifications from the database + * belonging to that user (via Prisma) + * 3. Shape and sanitize the data before returning JSON + * + * This route is *read-only* and does not modify any data. + * It is called frequently (e.g., on app load or via SSE/websocket refresh), + * so it should remain lightweight and performant. + */ +export async function GET(req: Request) { + // Step 1: Validate the current session + // ------------------------------------------------------------ + // The `auth()` helper should return the current session object + // or `null` if no user is logged in. + // We rely on the `session.user.id` field to scope the DB query. + const session = await auth(); + if (!session?.user?.id) { + // If no valid session is found, return 401 Unauthorized. + return NextResponse.json( + { error: { code: "UNAUTHORIZED", message: "User not logged in" } }, + { status: 401 } + ); + } + + const userId = session.user.id; + + try { + // Step 2: Query the database for notifications + // ------------------------------------------------------------ + // Fetch up to the 100 most recent notifications belonging + // to the current user. We explicitly `select` only fields + // needed by the frontend to minimize payload size. + const notifications = await prisma.notification.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + take: 100, + select: { id: true, type: true, data: true, createdAt: true }, + }); + + // Step 3: Transform raw database rows into frontend shape + // ------------------------------------------------------------ + // Each notification record contains: + // - id: unique identifier + // - type: notification type (e.g., "DEAL_APPROVED") + // - data: JSON payload with optional metadata + // - createdAt: timestamp + // + // The `data` field may contain arbitrary JSON, so we perform + // a safe type check before destructuring. + const screeners = notifications.map((n) => { + const data = + typeof n.data === "object" && n.data !== null ? (n.data as any) : {}; + + return { + id: n.id, + // Use `data.title` if present, otherwise fall back to the + // notification type. This keeps the UI resilient to missing data. + title: data.title ?? n.type, + // Use `data.status` if available, otherwise default to "Pending". + status: data.status ?? "Pending", + // Convert Date → ISO string to avoid timezone discrepancies + createdAt: n.createdAt.toISOString(), + }; + }); + + // Step 4: Return notifications as JSON + // ------------------------------------------------------------ + // `no-store` disables any form of caching, which ensures users + // always see the most up-to-date notifications in a real-time system. + return NextResponse.json(screeners, { + headers: { "Cache-Control": "no-store" }, + }); + } catch (err: any) { + // Step 5: Error handling + // ------------------------------------------------------------ + // If Prisma fails (e.g., DB unavailable or schema mismatch), + // we log a structured error for Cloud Logging / Stackdriver. + console.error( + JSON.stringify({ + severity: "ERROR", + message: "Failed to fetch notifications", + error: err.message, + }) + ); + + // Return a generic error message to the client. + // Avoid exposing internal Prisma error details. + return NextResponse.json( + { error: { code: "INTERNAL", message: "Internal Server Error" } }, + { status: 500 } + ); + } +} diff --git a/app/api/screen-all/route.ts b/app/api/screen-all/route.ts index 126acf7..77350ee 100644 --- a/app/api/screen-all/route.ts +++ b/app/api/screen-all/route.ts @@ -1,94 +1,160 @@ -import { auth } from "@/auth"; -import { pubSubClient } from "@/lib/pubsub-client"; -import { redisClient } from "@/lib/redis"; -import { NextResponse } from "next/server"; - -// const topicName = process.env.PUBSUB_TOPIC_NAME; - -const WORKER_URL = process.env.WORKER_URL; +// app/api/screen-all/route.ts +import { auth } from "@/auth"; +import { pubSubClient } from "@/lib/pubsub-client"; +import { NextResponse } from "next/server"; +import { redisClient } from "@/lib/redis"; + +// Name of the Pub/Sub topic to which screening jobs will be published +// Must be defined in environment variables. +const topicName = process.env.PUBSUB_TOPIC_NAME; + +/** + * POST /api/screen-all + * ------------------------------------------------------------------------- + * Queues multiple deals for automated screening. + * + * High-level flow: + * 1. Authenticate user via session. + * 2. Validate incoming payload (deals + screener info). + * 3. Publish each deal as a Pub/Sub message. + * 4. Create initial job entries in Redis for tracking. + * 5. Return success once all messages are queued. + * + * This endpoint is typically triggered when a user + * clicks “Screen All Deals” in the UI. + * ------------------------------------------------------------------------- + */ export async function POST(request: Request) { + // Step 1: Authenticate user + // ----------------------------------------------------------------------- + // The auth helper should provide a session object with `user.id`. const userSession = await auth(); + if (!userSession?.user?.id) { + return NextResponse.json( + { message: "Unauthorized - missing user id" }, + { status: 401 } + ); + } + + const userId = userSession.user.id; - if (!userSession) { + // Ensure topic name is available + if (!topicName) { + console.error("❌ PUBSUB_TOPIC_NAME not configured in environment variables."); return NextResponse.json( - { - message: "Unauthorized", - }, - { - status: 401, - }, + { message: "Server misconfiguration: missing Pub/Sub topic" }, + { status: 500 } ); } + // Step 2: Parse and validate request body + // ----------------------------------------------------------------------- + // The frontend should send an object like: + // { + // dealListings: [ { title: "...", url: "...", ... }, ... ], + // screenerId: "abc123", + // screenerContent: "...", + // screenerName: "Quick Filter" + // } const { dealListings, screenerId, screenerContent, screenerName } = await request.json(); - if ( - !dealListings || - !Array.isArray(dealListings) || - dealListings.length === 0 - ) { + // Validate deal listings + if (!dealListings || !Array.isArray(dealListings) || dealListings.length === 0) { return NextResponse.json( { message: "Invalid deal listings" }, - { status: 400 }, + { status: 400 } ); } + // Validate screener metadata if (!screenerId || !screenerContent || !screenerName) { - console.log( - "screener information is not present inside screen all function", - ); - + console.log("❌ Screener information missing in screen-all request"); return NextResponse.json({ message: "Invalid screener" }, { status: 400 }); } try { - // We'll collect all the publish promises to run them in parallel - // const publishPromises: Promise[] = []; - - // Enqueue each deal (await to ensure completion before publish) + // Step 3: Prepare asynchronous operations + // ----------------------------------------------------------------------- + // We'll collect all Pub/Sub publish and Redis writes into arrays + // so they can be executed concurrently with Promise.all(). + const publishPromises: Promise[] = []; + const redisPromises: Promise[] = []; + + // Iterate through all deals and queue each one for (const dealListing of dealListings) { + const jobId = crypto.randomUUID(); // Unique ID for tracking this job + const timestamp = Date.now(); + + // Construct the message payload that workers will consume const payload = { - ...dealListing, - userId: userSession.user.id, - screenerId, + ...dealListing, // Deal-specific data (e.g., title, URL, etc.) + userId, // Identify which user owns this job + screenerId, // Screener configuration being used screenerContent, screenerName, + jobId, // Used to correlate with Redis state }; - await redisClient.rpush("dealListings", JSON.stringify(payload)); - // const dataBuffer = Buffer.from(JSON.stringify(payload)); - // const publishPromise = pubSubClient - // .topic(topicName!) - // .publishMessage({ data: dataBuffer }); - - // publishPromises.push(publishPromise); + // Convert to binary data (Pub/Sub requires this) + const dataBuffer = Buffer.from(JSON.stringify(payload)); + + // Step 3A: Publish to Pub/Sub + // ------------------------------------------------------------------- + // The message will be picked up by a Cloud Function or worker that + // performs the screening process asynchronously. + const publishPromise = pubSubClient + .topic(topicName) + .publishMessage({ + data: dataBuffer, + attributes: { jobType: "screenAll" }, // optional metadata for filtering + }); + publishPromises.push(publishPromise); + + // Step 3B: Initialize job state in Redis + // ------------------------------------------------------------------- + // Redis stores a hash for each job with initial metadata. + const redisKey = `deal:${jobId}`; + const redisPromise = redisClient.hset(redisKey, { + userId, + title: dealListing.title ?? "Untitled Deal", + status: "Pending", // Worker updates this to “Success” or “Failed” + result: "N/A", + timestamp, + }); + redisPromises.push(redisPromise); + + // Step 3C: Add job ID to the user’s job list for easy lookup + const lpushPromise = redisClient.lpush(`user:${userId}:jobs`, jobId); + redisPromises.push(lpushPromise); } - // Notify via pub/sub that new items are available for this user - await redisClient.publish( - "new_screen_call", - JSON.stringify({ userId: userSession.user.id }), - ); + // Step 4: Execute all async tasks concurrently + // ----------------------------------------------------------------------- + // Using Promise.all() ensures the function waits until *all* + // messages are published and Redis states are initialized. + await Promise.all([...publishPromises, ...redisPromises]); - // Await all messages to be published - console.log("before promise.all"); - // await Promise.all(publishPromises); + console.log( + `✅ User ${userId}: published ${dealListings.length} deals and initialized Redis successfully` + ); - fetch(`${WORKER_URL}/process-queue`, { method: "POST" }).catch((err) => { - console.error("Failed to trigger worker:", err.message); - // You might want to add more robust error handling/logging here + // Step 5: Respond to frontend + // ----------------------------------------------------------------------- + // The frontend can now start polling for updates via `/api/notifications` + // or Redis-backed websocket/SSE for progress updates. + return NextResponse.json({ + message: "Jobs queued successfully", + totalQueued: dealListings.length, }); - - console.log("all promises ran successfully"); - - return NextResponse.json({ message: "Jobs Published Successfully" }); } catch (error) { - console.error("Error enqueuing deals:", error); + // Step 6: Error handling + // ----------------------------------------------------------------------- + console.error("❌ Error publishing deals to Pub/Sub / Redis:", error); return NextResponse.json( { message: "Internal Server Error" }, - { status: 500 }, + { status: 500 } ); } } diff --git a/components/NotificationPopover.tsx b/components/NotificationPopover.tsx index 9d086da..7d782d6 100644 --- a/components/NotificationPopover.tsx +++ b/components/NotificationPopover.tsx @@ -6,6 +6,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Badge } from "@/components/ui/badge"; +import Link from "next/link"; import { BellIcon, Clock, TrendingUp, FileText } from "lucide-react"; import { cn } from "@/lib/utils"; import React, { @@ -17,6 +18,10 @@ import React, { } from "react"; import { ScrollArea } from "./ui/scroll-area"; +/** + * Types used for local state + * These mirror the expected JSON structures returned by the API. + */ type PendingDeal = { id: string; title: string; @@ -24,77 +29,139 @@ type PendingDeal = { status: string; }; -type WebSocketMessage = { - type: string; - productId?: string; - status?: string; - userId?: string; +type ScreenerNotification = { + id: string; + title: string; + status: string; }; +/** + * The WebSocket message schema. + * Each message type triggers different updates in the UI. + */ +type WebSocketMessage = + | { type: "new_screen_call"; userId: string } + | { + type: "problem_done"; + userId: string; + productId: string; + status: string; + productName?: string; + } + | { type: "job_update"; jobId: string; status: string; result?: string }; + +/** + * NotificationPopover + * + * This component: + * - Displays a bell icon with live notification count + * - Shows deals and screener updates in a popover + * - Connects to a WebSocket server for real-time updates + * - Syncs with /api/notifications (for persisted data) + * - Reconnects automatically if the socket drops + */ const NotificationPopover = ({ userId }: { userId: string }) => { + // --- STATE --- const [open, setOpen] = useState(false); const [deals, setDeals] = useState([]); + const [screeners, setScreeners] = useState([]); const [wsConnected, setWsConnected] = useState(false); const [isPending, startTransition] = useTransition(); + + // --- WEBSOCKET CONTROL --- const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); - const retryDelayRef = useRef(1000); + const retryDelayRef = useRef(1000); // grows exponentially up to 10s + /** + * Fetches all pending deals. + * Called when the popover opens or when a WebSocket update is received. + */ const fetchDeals = useCallback(async () => { try { const res = await fetch("/api/deals/pending"); - if (!res.ok) { - throw new Error(`HTTP error! status: ${res.status}`); - } + if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); const data: PendingDeal[] = await res.json(); - console.log("📊 Fetched deals:", data); setDeals(data); } catch (error) { console.error("❌ Error fetching deals:", error); } }, []); + /** + * Fetches the latest screener notifications. + * Matches the structure returned by your `/api/notifications` route. + */ + const fetchScreeners = useCallback(async () => { + try { + const res = await fetch("/api/notifications"); + if (!res.ok) throw new Error("Failed to fetch notifications"); + const data: ScreenerNotification[] = await res.json(); // ✅ route returns array directly + setScreeners(data); + } catch (err) { + console.error("❌ Error fetching screeners:", err); + } + }, []); + + /** + * Triggers both API fetches concurrently within a React transition. + * This ensures smoother UI updates (no blocking re-renders). + */ const fetchAndTransition = useCallback(() => { startTransition(() => { fetchDeals(); + fetchScreeners(); }); - }, [fetchDeals]); + }, [fetchDeals, fetchScreeners]); + /** + * When the popover opens, refresh both data sets. + */ useEffect(() => { - fetchAndTransition(); + if (open) fetchAndTransition(); }, [open, fetchAndTransition]); + /** + * Utility: format EBITDA values to human-readable currency strings. + */ const formatEbitda = (ebitda: number) => { - if (ebitda >= 1000000) { - return `$${(ebitda / 1000000).toFixed(1)}M`; - } else if (ebitda >= 1000) { - return `$${(ebitda / 1000).toFixed(1)}K`; - } + if (ebitda >= 1_000_000) return `$${(ebitda / 1_000_000).toFixed(1)}M`; + if (ebitda >= 1_000) return `$${(ebitda / 1_000).toFixed(1)}K`; return `$${ebitda.toLocaleString()}`; }; + // --- REAL-TIME CONNECTION (WebSocket) --- const connectWebSocket = useCallback(() => { const url = process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://localhost:8080"; - const ws = new WebSocket(url); wsRef.current = ws; ws.onopen = () => { setWsConnected(true); - ws.send(JSON.stringify({ type: "register", userId })); + ws.send(JSON.stringify({ type: "register", userId })); // identify this client retryDelayRef.current = 1000; if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } }; + ws.onmessage = (e) => { try { const msg: WebSocketMessage = JSON.parse(e.data); + + // Different message types can trigger different refreshes if (msg.type === "new_screen_call") fetchAndTransition(); if (msg.type === "problem_done" && msg.productId) fetchAndTransition(); - } catch {} + } catch { + /* ignore malformed messages */ + } }; + + /** + * Handle reconnect logic with exponential backoff. + * Prevents flooding the server when connection drops. + */ const scheduleReconnect = () => { if (reconnectTimeoutRef.current) return; const delay = Math.min(retryDelayRef.current, 10000); @@ -104,16 +171,22 @@ const NotificationPopover = ({ userId }: { userId: string }) => { }, delay); retryDelayRef.current = Math.min(delay * 2, 10000); }; + ws.onclose = () => { setWsConnected(false); scheduleReconnect(); }; + ws.onerror = () => { setWsConnected(false); scheduleReconnect(); }; }, [userId, fetchAndTransition]); + /** + * Initialize WebSocket connection when the component mounts. + * Clean up on unmount. + */ useEffect(() => { if (!userId) return; connectWebSocket(); @@ -128,71 +201,78 @@ const NotificationPopover = ({ userId }: { userId: string }) => { }; }, [userId, connectWebSocket]); + /** + * Refresh notifications whenever the window regains focus. + * Keeps UI up to date even if WebSocket missed some messages. + */ useEffect(() => { const onFocus = () => fetchAndTransition(); window.addEventListener("focus", onFocus); return () => window.removeEventListener("focus", onFocus); }, [fetchAndTransition]); + const totalNotifications = deals.length + screeners.length; + + // --- RENDER --- return (
+ {/* --- TRIGGER BUTTON --- */} - + + {/* --- POPOVER CONTENT --- */} -
+ {/* Header */} +
- Your Deals Queue + Notifications
-
- {deals.length > 0 && ( - - {deals.length} pending - - )} -
-
+ {totalNotifications > 0 && ( + + {totalNotifications} pending + + )}
+ {/* Scrollable list of notifications */} + {/* Loading state */} {isPending && (
-
- Loading deals... +
+ Loading notifications...
)} + {/* Connection lost state */} {!wsConnected && !isPending && (
-
+

Connection lost @@ -203,28 +283,30 @@ const NotificationPopover = ({ userId }: { userId: string }) => {

)} - {deals.length === 0 && !isPending && wsConnected && ( + {/* Empty state */} + {totalNotifications === 0 && !isPending && wsConnected && (

- No pending deals + No notifications

- Your deals will appear here when they're ready + Your notifications will appear here

)} + {/* --- DEAL NOTIFICATIONS --- */} {deals.length > 0 && !isPending && (
{deals.map((deal) => (
-
+
{deal.title || `Deal #${deal.id}`}

@@ -243,7 +325,7 @@ const NotificationPopover = ({ userId }: { userId: string }) => { className={cn( "px-2 py-0.5 text-xs", deal.status === "Pending" && - "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950/20 dark:text-yellow-300", + "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950/20 dark:text-yellow-300" )} > {deal.status} @@ -254,7 +336,37 @@ const NotificationPopover = ({ userId }: { userId: string }) => { ))}

)} + + {/* --- SCREENER NOTIFICATIONS --- */} + {screeners.length > 0 && !isPending && ( +
+ {screeners.map((s) => ( +
+
+
{s.title}
+ + {s.status} + +
+
+ ))} +
+ )} + + {/* Footer: Link to full notifications page */} +
+ setOpen(false)} + > + View All Notifications + +
diff --git a/docker-compose.yml b/docker-compose.yml index fc99569..b87794a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,21 @@ services: volumes: - redis-data:/data + postgres: + image: postgres:latest + container_name: postgres-service + restart: always + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: example + POSTGRES_DB: postgres + volumes: + - postgres-data:/var/lib/postgresql/data + volumes: redis-data: driver: local + postgres-data: + driver: local diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 645e9e8..c22fa6c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { isBlocked Boolean @default(false) UserActionLog UserActionLog[] Deal Deal[] + Notification Notification[] } model Account { @@ -221,3 +222,17 @@ model Employee { Deal Deal? @relation(fields: [dealId], references: [id]) dealId String? } + +model Notification { + id String @id @default(cuid()) + userId String + title String + message String + status String // e.g. "processing", "completed", "failed" + dealId String? // optional, for linking a specific deal + dealName String? // store title or caption for quick reference + createdAt DateTime @default(now()) + read Boolean @default(false) + + user User @relation(fields: [userId], references: [id]) +} From e433e9bcaf2c92a0d38b9540c1b581b7f0aa5265 Mon Sep 17 00:00:00 2001 From: Matthew Moen Date: Mon, 13 Oct 2025 21:39:09 -0600 Subject: [PATCH 2/2] fixed the rollup code and made it better --- app/(protected)/rollup-details/[id]/page.tsx | 221 ------------------ app/(protected)/rollups/[id]/loading.tsx | 16 ++ app/(protected)/rollups/[id]/page.tsx | 86 +++++++ app/(protected)/rollups/loading.tsx | 21 ++ app/(protected)/rollups/page.tsx | 51 ++++ app/(protected)/view-rollups/page.tsx | 188 --------------- app/actions/rollup-actions.ts | 147 ++++++++++++ components/Buttons/delete-rollup-button.tsx | 49 ++++ .../remove-deal-from-rollup-button.tsx | 52 +++++ components/Dialogs/edit-rollup-dialog.tsx | 139 +++++++---- components/Header.tsx | 2 +- components/NotificationPopover.tsx | 218 +---------------- components/RollupCard.tsx | 69 ++++++ components/RollupDealsList.tsx | 103 ++++++++ lib/queries.ts | 57 +++++ lib/rollup/infer-rollup-details.ts | 27 ++- 16 files changed, 764 insertions(+), 682 deletions(-) delete mode 100644 app/(protected)/rollup-details/[id]/page.tsx create mode 100644 app/(protected)/rollups/[id]/loading.tsx create mode 100644 app/(protected)/rollups/[id]/page.tsx create mode 100644 app/(protected)/rollups/loading.tsx create mode 100644 app/(protected)/rollups/page.tsx delete mode 100644 app/(protected)/view-rollups/page.tsx create mode 100644 app/actions/rollup-actions.ts create mode 100644 components/Buttons/delete-rollup-button.tsx create mode 100644 components/Buttons/remove-deal-from-rollup-button.tsx create mode 100644 components/RollupCard.tsx create mode 100644 components/RollupDealsList.tsx diff --git a/app/(protected)/rollup-details/[id]/page.tsx b/app/(protected)/rollup-details/[id]/page.tsx deleted file mode 100644 index c1cc5cd..0000000 --- a/app/(protected)/rollup-details/[id]/page.tsx +++ /dev/null @@ -1,221 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { ChevronLeft, ChevronDown, ChevronUp } from "lucide-react"; -import { toast } from "sonner"; -import EditRollupDialog from "@/components/Dialogs/edit-rollup-dialog"; -import { Rollup as RollupType, Deal as DealType, User as UserType } from "@prisma/client"; -import { RollupUpdatePayload, DealUpdatePayload } from "@/components/Dialogs/edit-rollup-dialog"; - -// Augmented Deal type including frontend-only AI fields -export type DealWithAI = DealType & { - score?: number; - grossRevenue?: number; - dealTeaser?: string; - confidence_business_strategy?: number; - confidence_growth_stage?: number; -}; - -// Rollup with relations + AI-enhanced deals -export type RollupWithRelations = RollupType & { - deals: DealWithAI[]; - users: UserType[]; -}; - -interface UserSession extends UserType {} - -interface RollupDetailsPageProps { - params: { id: string } | Promise<{ id: string }>; -} - -export default function RollupDetailsPage({ params }: RollupDetailsPageProps) { - const [rollup, setRollup] = useState(null); - const [loading, setLoading] = useState(true); - const [currentUser, setCurrentUser] = useState(null); - const [expandedDeals, setExpandedDeals] = useState>({}); - const router = useRouter(); - - useEffect(() => { - async function fetchRollup() { - const resolvedParams = await Promise.resolve(params); - - try { - const res = await fetch(`/api/rollups/${resolvedParams.id}`); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Failed to fetch rollup"); - - // Cast deals to DealWithAI - const enrichedDeals: DealWithAI[] = (data.rollup.deals || []).map((deal: DealType) => ({ - ...deal, - score: Math.random(), - grossRevenue: deal.revenue * 1.1, - confidence_business_strategy: 0.8, - confidence_growth_stage: 0.7, - dealTeaser: deal.dealTeaser || "AI-generated teaser", - chunk_text: deal.chunk_text || "AI-enriched chunk text", - description: deal.description || "AI description placeholder", - })); - - setRollup({ ...data.rollup, deals: enrichedDeals }); - } catch (err) { - console.error(err); - toast.error("Error fetching rollup details"); - setRollup(null); - } finally { - setLoading(false); - } - - // Fetch current user session - try { - const resUser = await fetch("/api/auth/session"); - const dataUser = await resUser.json(); - setCurrentUser(dataUser?.user ?? null); - } catch {} - } - - fetchRollup(); - }, [params]); - - const handleUpdateRollup = async (updated: RollupUpdatePayload) => { - if (!rollup) return; - - try { - const res = await fetch(`/api/rollups/${rollup.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updated), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Failed to update rollup"); - - // Merge updated deals if returned - const updatedDeals: DealWithAI[] = - data.rollup.deals?.map((d: DealType) => ({ - ...d, - score: Math.random(), - grossRevenue: d.revenue * 1.1, - confidence_business_strategy: 0.8, - confidence_growth_stage: 0.7, - dealTeaser: d.dealTeaser || "AI-generated teaser", - chunk_text: d.chunk_text || "AI-enriched chunk text", - description: d.description || "AI description placeholder", - })) || []; - - setRollup({ ...data.rollup, deals: updatedDeals }); - toast.success("Rollup updated!"); - } catch (err) { - console.error(err); - toast.error("Failed to update rollup"); - } - }; - - const toggleDeal = (dealId: string) => - setExpandedDeals((prev) => ({ ...prev, [dealId]: !prev[dealId] })); - - const handleRemoveDeal = async (dealId: string) => { - if (currentUser?.role !== "ADMIN") { - toast.error("Only admins can remove deals."); - return; - } - toast.info(`Pretend removing deal ${dealId} (API not wired yet).`); - }; - - if (loading) return
Loading rollup details...
; - if (!rollup) return
Rollup not found.
; - - return ( -
- - -
-

{rollup.name}

- -
- - {rollup.description &&

{rollup.description}

} - {rollup.summary && ( -
-

AI Summary

-

{rollup.summary}

-
- )} - -

- Created: {new Date(rollup.createdAt).toLocaleString()} | Updated:{" "} - {new Date(rollup.updatedAt).toLocaleString()} -

- - {/* Deals Section */} -
-

Deals

-
    - {rollup.deals.map((deal) => { - const expanded = expandedDeals[deal.id]; - return ( -
  • -
    toggleDeal(deal.id)} - > - {deal.title || deal.dealCaption} - {expanded ? : } -
    - - {expanded && ( -
    -
    - Brokerage: {deal.brokerage} | Revenue: ${deal.revenue.toLocaleString()} | Gross Revenue: $ - {deal.grossRevenue?.toLocaleString()} | EBITDA: ${deal.ebitda.toLocaleString()} | EBITDA Margin:{" "} - {deal.ebitdaMargin ?? "—"}% | Industry: {deal.industry} -
    - {deal.score !== undefined &&
    Score: {(deal.score * 100).toFixed(0)}%
    } - {deal.confidence_business_strategy !== undefined && ( -
    Confidence (Business Strategy): {(deal.confidence_business_strategy * 100).toFixed(0)}%
    - )} - {deal.confidence_growth_stage !== undefined && ( -
    Confidence (Growth Stage): {(deal.confidence_growth_stage * 100).toFixed(0)}%
    - )} - {deal.dealTeaser &&
    {deal.dealTeaser}
    } - {deal.chunk_text &&
    {deal.chunk_text}
    } - {deal.description &&
    {deal.description}
    } - - -
    - )} -
  • - ); - })} -
-
- - {/* Users Section */} -
-

Saved by Users

-
    - {rollup.users.map((user) => ( -
  • - {user.name || user.email} {user.role ? `(${user.role})` : ""} -
  • - ))} -
-
-
- ); -} diff --git a/app/(protected)/rollups/[id]/loading.tsx b/app/(protected)/rollups/[id]/loading.tsx new file mode 100644 index 0000000..1fa1b53 --- /dev/null +++ b/app/(protected)/rollups/[id]/loading.tsx @@ -0,0 +1,16 @@ +export default function RollupDetailsLoading() { + return ( +
+
+
+
+
+
+ {[1, 2].map((i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/app/(protected)/rollups/[id]/page.tsx b/app/(protected)/rollups/[id]/page.tsx new file mode 100644 index 0000000..954d08e --- /dev/null +++ b/app/(protected)/rollups/[id]/page.tsx @@ -0,0 +1,86 @@ +import { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { getRollupById } from "@/lib/queries"; +import PreviousPageButton from "@/components/PreviousPageButton"; +import EditRollupDialog from "@/components/Dialogs/edit-rollup-dialog"; +import RollupDealsList from "@/components/RollupDealsList"; + +type Params = Promise<{ id: string }>; + +export async function generateMetadata(props: { + params: Params; +}): Promise { + const { id } = await props.params; + + const rollup = await getRollupById(id); + + return { + title: rollup?.name || "Rollup Details", + description: rollup?.description || "View rollup details", + }; +} + +export default async function RollupDetailsPage(props: { params: Params }) { + const { id } = await props.params; + const session = await auth(); + + if (!session?.user) { + redirect("/auth/login"); + } + + const rollup = await getRollupById(id); + + if (!rollup) { + notFound(); + } + + return ( +
+ + +
+

{rollup.name}

+ +
+ + {rollup.description && ( +

{rollup.description}

+ )} + + {rollup.summary && ( +
+

AI Summary

+

{rollup.summary}

+
+ )} + +

+ Created: {new Date(rollup.createdAt).toLocaleString()} | Updated:{" "} + {new Date(rollup.updatedAt).toLocaleString()} +

+ + {/* Deals Section */} +
+

Deals

+ +
+ + {/* Users Section */} +
+

Saved by Users

+
    + {rollup.users.map((user) => ( +
  • + {user.name || user.email} {user.role ? `(${user.role})` : ""} +
  • + ))} +
+
+
+ ); +} diff --git a/app/(protected)/rollups/loading.tsx b/app/(protected)/rollups/loading.tsx new file mode 100644 index 0000000..5d02c39 --- /dev/null +++ b/app/(protected)/rollups/loading.tsx @@ -0,0 +1,21 @@ +export default function RollupsLoading() { + return ( +
+
+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/app/(protected)/rollups/page.tsx b/app/(protected)/rollups/page.tsx new file mode 100644 index 0000000..a54523a --- /dev/null +++ b/app/(protected)/rollups/page.tsx @@ -0,0 +1,51 @@ +import { Suspense } from "react"; +import { ChevronUp } from "lucide-react"; +import { Metadata } from "next"; +import { getAllRollups } from "@/lib/queries"; +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import RollupCard from "@/components/RollupCard"; + +export const metadata: Metadata = { + title: "Rollups", + description: "View all rollups", +}; + +export default async function RollupsPage() { + const session = await auth(); + + if (!session?.user) { + redirect("/auth/login"); + } + + const rollups = await getAllRollups(); + + if (!rollups || rollups.length === 0) { + return ( +
+

+ + All Rollups +

+
No rollups found.
+
+ ); + } + + return ( +
+

+ + All Rollups +

+ +
+ {rollups.map((rollup) => ( + Loading...
}> + + + ))} +
+
+ ); +} diff --git a/app/(protected)/view-rollups/page.tsx b/app/(protected)/view-rollups/page.tsx deleted file mode 100644 index 57397c5..0000000 --- a/app/(protected)/view-rollups/page.tsx +++ /dev/null @@ -1,188 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { ChevronUp } from "lucide-react"; -import { toast } from "sonner"; - -interface Deal { - id: string; - title?: string | null; - dealCaption: string; - brokerage: string; - revenue: number; - ebitda: number; - industry: string; - score?: number; - bitrixStatus?: string; - business_strategy?: string; - growth_stage?: string; - dealTeaser?: string; - chunk_text?: string; - firstName?: string; - lastName?: string; - email?: string; - linkedinUrl?: string; - workPhone?: string; - sourceWebsite?: string; -} - -interface Rollup { - id: string; - name: string; - description?: string; - createdAt: string; - updatedAt: string; - deals: Deal[]; - users: { id: string; name?: string | null; email: string; role?: string }[]; -} - -interface UserSession { - id: string; - name?: string | null; - email: string; - role?: string; -} - -export default function ViewRollupsPage() { - const [rollups, setRollups] = useState([]); - const [loading, setLoading] = useState(true); - const [currentUser, setCurrentUser] = useState(null); - - useEffect(() => { - async function fetchData() { - try { - // Fetch all rollups - const resRollups = await fetch("/api/rollups"); - const dataRollups = resRollups.ok ? await resRollups.json() : null; - setRollups(dataRollups?.rollups ?? []); - - // Fetch current logged-in user session - const resUser = await fetch("/api/auth/session"); - if (resUser.ok) { - const dataUser = await resUser.json(); - // safely set currentUser only if user exists - setCurrentUser(dataUser?.user ?? null); - } else { - // if session fetch fails set null - setCurrentUser(null); - } - - // --- Uncomment below to bypass admin restrictions for testing --- - // setCurrentUser({ id: "demo", email: "demo@test.com", role: "ADMIN" }); - - } catch (error) { - console.error("Error fetching data:", error); - setCurrentUser(null); - } finally { - setLoading(false); - } - } - - fetchData(); - }, []); - - async function handleDelete(rollupId: string, userRole?: string) { - if (userRole !== "ADMIN") { - toast.error("Only admins can delete rollups."); - return; - } - - try { - const res = await fetch(`/api/rollups/${rollupId}`, { method: "DELETE" }); - const data = await res.json(); - - if (res.ok) { - toast.success("Rollup deleted successfully!"); - setRollups((prev) => prev.filter((r) => r.id !== rollupId)); - } else { - toast.error(data.error || "Failed to delete rollup."); - } - } catch (error) { - console.error(error); - toast.error("Failed to delete rollup."); - } - } - - if (loading) return
Loading rollups...
; - if (!rollups.length) return
No rollups found.
; - - return ( -
-

- - All Rollups -

- -
- {rollups.map((rollup) => ( -
-
-

{rollup.name}

- - {new Date(rollup.createdAt).toLocaleDateString()} - -
- - {rollup.description && ( -

- {rollup.description} -

- )} - -
- Deals in this rollup: -
    - {rollup.deals.map((deal) => ( -
  • -
    - {deal.title || deal.dealCaption} -
    -
    - Brokerage: {deal.brokerage} | Revenue: ${deal.revenue.toLocaleString()} | EBITDA: ${deal.ebitda.toLocaleString()} | Industry: {deal.industry} -
    - {deal.score !== undefined && ( -
    - Score: {(deal.score * 100).toFixed(0)}% -
    - )} - {deal.bitrixStatus && ( -
    - Status: {deal.bitrixStatus} -
    - )} -
  • - ))} -
-
- -
- Saved by:{" "} - {rollup.users.map((user) => user.name || user.email).join(", ")} -
- - - - -
- ))} -
-
- ); -} diff --git a/app/actions/rollup-actions.ts b/app/actions/rollup-actions.ts new file mode 100644 index 0000000..c8f13a8 --- /dev/null +++ b/app/actions/rollup-actions.ts @@ -0,0 +1,147 @@ +"use server"; + +import prismaDB from "@/lib/prisma"; +import { auth } from "@/auth"; +import { revalidatePath } from "next/cache"; + +/** + * Delete a rollup (Admin only) + * @param rollupId - the id of the rollup to delete + * @returns success status + */ +export async function deleteRollup(rollupId: string) { + try { + const session = await auth(); + + if (!session?.user) { + return { success: false, error: "Unauthorized" }; + } + + // Check if user is admin + if (session.user.role !== "ADMIN") { + return { success: false, error: "Only admins can delete rollups" }; + } + + await prismaDB.rollup.delete({ + where: { id: rollupId }, + }); + + revalidatePath("/rollups"); + return { success: true }; + } catch (error) { + console.error("Error deleting rollup", error); + return { success: false, error: "Failed to delete rollup" }; + } +} + +/** + * Update a rollup + * @param rollupId - the id of the rollup to update + * @param data - the data to update + * @returns success status and updated rollup + */ +export async function updateRollup( + rollupId: string, + data: { + name?: string; + description?: string; + summary?: string; + }, +) { + try { + const session = await auth(); + + if (!session?.user) { + return { success: false, error: "Unauthorized" }; + } + + const updatedRollup = await prismaDB.rollup.update({ + where: { id: rollupId }, + data, + include: { + users: { + select: { + id: true, + name: true, + email: true, + role: true, + }, + }, + deals: true, + }, + }); + + revalidatePath("/rollups"); + revalidatePath(`/rollups/${rollupId}`); + return { success: true, rollup: updatedRollup }; + } catch (error) { + console.error("Error updating rollup", error); + return { success: false, error: "Failed to update rollup" }; + } +} + +/** + * Update deal within a rollup + * @param dealId - the id of the deal to update + * @param data - the data to update + * @returns success status + */ +export async function updateDealInRollup( + dealId: string, + data: { + chunk_text?: string; + description?: string; + }, +) { + try { + const session = await auth(); + + if (!session?.user) { + return { success: false, error: "Unauthorized" }; + } + + await prismaDB.deal.update({ + where: { id: dealId }, + data, + }); + + revalidatePath("/rollups"); + return { success: true }; + } catch (error) { + console.error("Error updating deal", error); + return { success: false, error: "Failed to update deal" }; + } +} + +/** + * Remove a deal from a rollup + * @param dealId - the id of the deal to remove + * @param rollupId - the id of the rollup + * @returns success status + */ +export async function removeDealFromRollup(dealId: string, rollupId: string) { + try { + const session = await auth(); + + if (!session?.user) { + return { success: false, error: "Unauthorized" }; + } + + // Check if user is admin + if (session.user.role !== "ADMIN") { + return { success: false, error: "Only admins can remove deals" }; + } + + await prismaDB.deal.update({ + where: { id: dealId }, + data: { rollupId: null }, + }); + + revalidatePath("/rollups"); + revalidatePath(`/rollups/${rollupId}`); + return { success: true }; + } catch (error) { + console.error("Error removing deal from rollup", error); + return { success: false, error: "Failed to remove deal" }; + } +} diff --git a/components/Buttons/delete-rollup-button.tsx b/components/Buttons/delete-rollup-button.tsx new file mode 100644 index 0000000..a68d392 --- /dev/null +++ b/components/Buttons/delete-rollup-button.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { deleteRollup } from "@/app/actions/rollup-actions"; +import { toast } from "sonner"; +import { useTransition } from "react"; + +interface DeleteRollupButtonProps { + rollupId: string; + userRole?: string; +} + +export default function DeleteRollupButton({ + rollupId, + userRole, +}: DeleteRollupButtonProps) { + const [isPending, startTransition] = useTransition(); + + const handleDelete = () => { + if (userRole !== "ADMIN") { + toast.error("Only admins can delete rollups."); + return; + } + + startTransition(async () => { + const result = await deleteRollup(rollupId); + if (result.success) { + toast.success("Rollup deleted successfully!"); + } else { + toast.error(result.error || "Failed to delete rollup."); + } + }); + }; + + return ( + + ); +} diff --git a/components/Buttons/remove-deal-from-rollup-button.tsx b/components/Buttons/remove-deal-from-rollup-button.tsx new file mode 100644 index 0000000..551b65c --- /dev/null +++ b/components/Buttons/remove-deal-from-rollup-button.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { removeDealFromRollup } from "@/app/actions/rollup-actions"; +import { toast } from "sonner"; +import { useTransition } from "react"; + +interface RemoveDealFromRollupButtonProps { + dealId: string; + rollupId: string; + userRole?: string; +} + +export default function RemoveDealFromRollupButton({ + dealId, + rollupId, + userRole, +}: RemoveDealFromRollupButtonProps) { + const [isPending, startTransition] = useTransition(); + + const handleRemove = () => { + if (userRole !== "ADMIN") { + toast.error("Only admins can remove deals."); + return; + } + + startTransition(async () => { + const result = await removeDealFromRollup(dealId, rollupId); + if (result.success) { + toast.success("Deal removed successfully!"); + } else { + toast.error(result.error || "Failed to remove deal."); + } + }); + }; + + return ( + + ); +} diff --git a/components/Dialogs/edit-rollup-dialog.tsx b/components/Dialogs/edit-rollup-dialog.tsx index d5f2d22..8f38af3 100644 --- a/components/Dialogs/edit-rollup-dialog.tsx +++ b/components/Dialogs/edit-rollup-dialog.tsx @@ -1,12 +1,19 @@ "use client"; -import { useState } from "react"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { useState, useTransition } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Rollup as RollupType, Deal as DealType } from "@prisma/client"; import { toast } from "sonner"; +import { updateRollup, updateDealInRollup } from "@/app/actions/rollup-actions"; // --- Custom types --- export type DealUpdatePayload = { @@ -24,52 +31,78 @@ export type RollupUpdatePayload = { interface EditRollupDialogProps { rollup: RollupType & { deals?: DealType[] }; - onSave: (updated: RollupUpdatePayload) => void; + onSave?: (updated: RollupUpdatePayload) => void; // kept for backward compatibility } -export default function EditRollupDialog({ rollup, onSave }: EditRollupDialogProps) { +export default function EditRollupDialog({ + rollup, + onSave, +}: EditRollupDialogProps) { const [open, setOpen] = useState(false); const [name, setName] = useState(rollup.name); const [description, setDescription] = useState(rollup.description ?? ""); const [summary, setSummary] = useState(rollup.summary ?? ""); const [deals, setDeals] = useState(rollup.deals ?? []); - const [saving, setSaving] = useState(false); + const [isPending, startTransition] = useTransition(); const handleDealChange = ( id: string, field: keyof Pick, - value: string + value: string, ) => { setDeals((prev) => - prev.map((deal) => (deal.id === id ? { ...deal, [field]: value } : deal)) + prev.map((deal) => (deal.id === id ? { ...deal, [field]: value } : deal)), ); }; const handleSave = async () => { - setSaving(true); - try { - // Build partial deal updates - const updatedDeals: DealUpdatePayload[] = deals.map((d) => ({ - id: d.id, - chunk_text: d.chunk_text ?? null, - description: d.description ?? null, - })); - - await onSave({ - name, - description, - summary, - deals: updatedDeals.length ? updatedDeals : undefined, - }); - - toast.success("Rollup updated!"); - setOpen(false); - } catch (err) { - console.error(err); - toast.error("Failed to save rollup"); - } finally { - setSaving(false); - } + startTransition(async () => { + try { + // Update rollup + const rollupResult = await updateRollup(rollup.id, { + name, + description, + summary, + }); + + if (!rollupResult.success) { + toast.error(rollupResult.error || "Failed to update rollup"); + return; + } + + // Update deals if provided + if (deals.length > 0) { + for (const deal of deals) { + await updateDealInRollup(deal.id, { + chunk_text: deal.chunk_text ?? undefined, + description: deal.description ?? undefined, + }); + } + } + + // Call onSave callback if provided (for backward compatibility) + if (onSave) { + const updatedDeals: DealUpdatePayload[] = deals.map((d) => ({ + id: d.id, + chunk_text: d.chunk_text ?? null, + description: d.description ?? null, + })); + + onSave({ + name, + description, + summary, + deals: updatedDeals.length ? updatedDeals : undefined, + }); + } + + toast.success("Rollup updated!"); + setOpen(false); + } catch (err) { + console.error(err); + toast.error("Failed to save rollup"); + } + }); }; return ( @@ -82,19 +115,24 @@ export default function EditRollupDialog({ rollup, onSave }: EditRollupDialogPro Edit Rollup -
+
- + setName(e.target.value)} />
- -