diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6ddcdb6..a483d5f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,10 +52,13 @@ model ResponseCard { ocrError String? rawOcrResponse Json? + mondayItemId String? + @@index([ocrStatus]) @@index([reviewStatus]) @@index([name]) @@index([createdAt]) + @@index([mondayItemId]) } model ProcessingJob { @@ -86,6 +89,18 @@ model AppSettings { aiProvider String @default("gateway") aiModel String @default("") + mondayApiToken String @default("") + mondayBoardId String @default("") + mondayEnabled Boolean @default(false) + mondayColumnMap Json? + mondayWebhookId String @default("") + mondayWebhookUrl String @default("") + + webhookUrl String @default("") + webhookSecret String @default("") + webhookEnabled Boolean @default(false) + webhookEvents Json? + emailImapHost String @default("imap.dreamhost.com") emailImapPort Int @default(993) emailImapUser String @default("echo-ocr@stillwell.cloud") @@ -96,3 +111,31 @@ model AppSettings { emailProcessed String @default("mark_read") emailProcessedFolder String @default("Processed") } + +model ActivityLog { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + cardId String + action String + source String + summary String + changes Json? + + @@index([cardId, createdAt]) +} + +model Notification { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + read Boolean @default(false) + dismissed Boolean @default(false) + type String + title String + message String + cardId String? + actionUrl String? + meta Json? + + @@index([read, dismissed, createdAt]) + @@index([cardId]) +} diff --git a/src/app/api/cards/[id]/activity/route.ts b/src/app/api/cards/[id]/activity/route.ts new file mode 100644 index 0000000..99dc938 --- /dev/null +++ b/src/app/api/cards/[id]/activity/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCardActivity } from "@/lib/activity-log"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const activity = await getCardActivity(id); + return NextResponse.json(activity); + } catch (error) { + console.error("[cards/[id]/activity GET]", error); + return NextResponse.json( + { error: "Failed to fetch activity" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/cards/[id]/export/route.ts b/src/app/api/cards/[id]/export/route.ts index 395197a..c314cc7 100644 --- a/src/app/api/cards/[id]/export/route.ts +++ b/src/app/api/cards/[id]/export/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; +import { fireIntegrationEvent } from "@/lib/integrations"; export async function POST( _request: NextRequest, @@ -20,6 +21,10 @@ export async function POST( data: { reviewStatus: "exported" }, }); + fireIntegrationEvent("card_exported", id, { + oldCard: card as unknown as Record, + }).catch(() => {}); + return NextResponse.json(updated); } catch (error) { console.error("[cards/[id]/export POST]", error); diff --git a/src/app/api/cards/[id]/route.ts b/src/app/api/cards/[id]/route.ts index 320f405..47933fe 100644 --- a/src/app/api/cards/[id]/route.ts +++ b/src/app/api/cards/[id]/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { getPresignedUrl, deleteObject } from "@/lib/minio"; +import { fireIntegrationEvent } from "@/lib/integrations"; +import { logActivity, diffCardFields } from "@/lib/activity-log"; export async function GET( _request: NextRequest, @@ -90,11 +92,29 @@ export async function PUT( if (body.howHeard != null) data.howHeard = body.howHeard; if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse; + const oldCard = card as unknown as Record; + const updated = await prisma.responseCard.update({ where: { id }, data: data as Parameters[0]["data"], }); + const newCard = updated as unknown as Record; + const changes = diffCardFields(oldCard, newCard); + if (changes.length > 0) { + logActivity(id, "manual_edit", "user", `${changes.length} field(s) updated manually`, changes).catch(() => {}); + } + + const oldStatus = card.reviewStatus; + const newStatus = updated.reviewStatus; + if (oldStatus !== newStatus) { + if (newStatus === "reviewed") { + fireIntegrationEvent("card_reviewed", id, { oldCard }).catch(() => {}); + } else if (newStatus === "exported") { + fireIntegrationEvent("card_exported", id, { oldCard }).catch(() => {}); + } + } + return NextResponse.json(updated); } catch (error) { console.error("[cards/[id] PUT]", error); @@ -119,6 +139,8 @@ export async function DELETE( return NextResponse.json({ error: "Card not found" }, { status: 404 }); } + fireIntegrationEvent("card_deleted", id).catch(() => {}); + const deletePromises: Promise[] = []; if (card.frontImagePath) deletePromises.push(deleteObject(card.frontImagePath)); if (card.backImagePath) deletePromises.push(deleteObject(card.backImagePath)); diff --git a/src/app/api/integrations/monday/columns/route.ts b/src/app/api/integrations/monday/columns/route.ts new file mode 100644 index 0000000..6231f18 --- /dev/null +++ b/src/app/api/integrations/monday/columns/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { fetchBoardColumns } from "@/lib/monday"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const token = String(body.token || ""); + const boardId = String(body.boardId || ""); + + if (!token || !boardId) { + return NextResponse.json( + { error: "API token and board ID are required" }, + { status: 400 } + ); + } + + const columns = await fetchBoardColumns(token, boardId); + return NextResponse.json({ columns }); + } catch (error) { + console.error("[monday/columns POST]", error); + const message = error instanceof Error ? error.message : "Failed to fetch columns"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/integrations/monday/subscribe/route.ts b/src/app/api/integrations/monday/subscribe/route.ts new file mode 100644 index 0000000..77d41f3 --- /dev/null +++ b/src/app/api/integrations/monday/subscribe/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { createWebhookSubscription, deleteWebhookSubscription } from "@/lib/monday"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const action = body.action as string; + + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + }); + if (!settings?.mondayApiToken || !settings?.mondayBoardId) { + return NextResponse.json( + { error: "Monday.com API token and board ID are required" }, + { status: 400 } + ); + } + + if (action === "subscribe") { + const callbackUrl = String(body.callbackUrl || settings.mondayWebhookUrl || ""); + if (!callbackUrl) { + return NextResponse.json( + { error: "Callback URL is required" }, + { status: 400 } + ); + } + + if (settings.mondayWebhookId) { + try { + await deleteWebhookSubscription(settings.mondayApiToken, settings.mondayWebhookId); + } catch { /* old webhook may not exist */ } + } + + const webhookId = await createWebhookSubscription( + settings.mondayApiToken, + settings.mondayBoardId, + callbackUrl + ); + + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: { mondayWebhookId: webhookId, mondayWebhookUrl: callbackUrl }, + }); + + return NextResponse.json({ subscribed: true, webhookId }); + } else if (action === "unsubscribe") { + if (settings.mondayWebhookId) { + try { + await deleteWebhookSubscription(settings.mondayApiToken, settings.mondayWebhookId); + } catch { /* may already be deleted */ } + } + + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: { mondayWebhookId: "", mondayWebhookUrl: "" }, + }); + + return NextResponse.json({ subscribed: false }); + } + + return NextResponse.json({ error: "Invalid action" }, { status: 400 }); + } catch (error) { + console.error("[monday/subscribe POST]", error); + const message = error instanceof Error ? error.message : "Failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/integrations/monday/webhook/route.ts b/src/app/api/integrations/monday/webhook/route.ts new file mode 100644 index 0000000..51e5067 --- /dev/null +++ b/src/app/api/integrations/monday/webhook/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { readItem, mapItemToCardFields } from "@/lib/monday"; +import { logActivity, diffCardFields } from "@/lib/activity-log"; +import { createNotification } from "@/lib/notifications"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + if (body.challenge) { + return NextResponse.json({ challenge: body.challenge }); + } + + const event = body.event; + if (!event) { + return NextResponse.json({ ok: true }); + } + + const itemId = String(event.pulseId || event.itemId || ""); + if (!itemId) { + return NextResponse.json({ ok: true }); + } + + const card = await prisma.responseCard.findFirst({ + where: { mondayItemId: itemId }, + }); + if (!card) { + return NextResponse.json({ ok: true }); + } + + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + }); + if (!settings?.mondayApiToken) { + return NextResponse.json({ ok: true }); + } + + const mondayItem = await readItem(settings.mondayApiToken, itemId); + if (!mondayItem) { + return NextResponse.json({ ok: true }); + } + + const columnMap = (settings.mondayColumnMap as Record) ?? {}; + const cardFields = mapItemToCardFields(mondayItem.columnValues, columnMap); + + if (Object.keys(cardFields).length === 0) { + return NextResponse.json({ ok: true }); + } + + const oldCard = card as unknown as Record; + const data: Record = {}; + for (const [key, val] of Object.entries(cardFields)) { + data[key] = val; + } + + const updated = await prisma.responseCard.update({ + where: { id: card.id }, + data: data as Parameters[0]["data"], + }); + + const newCard = updated as unknown as Record; + const changes = diffCardFields(oldCard, newCard); + if (changes.length > 0) { + await logActivity(card.id, "monday_sync", "monday.com", + `${changes.length} field(s) synced from Monday.com`, changes); + + await createNotification({ + type: "monday_sync", + title: "Monday.com Sync", + message: `${changes.length} field(s) updated for ${card.name || "Unnamed Card"}`, + cardId: card.id, + actionUrl: `/cards/${card.id}`, + }); + } + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("[monday/webhook POST]", error); + return NextResponse.json({ ok: true }); + } +} diff --git a/src/app/api/integrations/webhook/test/route.ts b/src/app/api/integrations/webhook/test/route.ts new file mode 100644 index 0000000..63cf799 --- /dev/null +++ b/src/app/api/integrations/webhook/test/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { sendWebhook } from "@/lib/webhook"; + +export async function POST() { + try { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + }); + + if (!settings?.webhookUrl) { + return NextResponse.json( + { error: "Webhook URL is not configured" }, + { status: 400 } + ); + } + + const testCard = { + id: "test_123", + name: "Test Card", + email: "test@example.com", + ocrStatus: "complete", + reviewStatus: "unreviewed", + ocrConfidence: 92, + }; + + const result = await sendWebhook( + settings.webhookUrl, + settings.webhookSecret, + "test", + testCard + ); + + if (result.ok) { + return NextResponse.json({ ok: true, message: "Test webhook sent successfully" }); + } else { + return NextResponse.json( + { ok: false, error: result.error }, + { status: 400 } + ); + } + } catch (error) { + console.error("[webhook/test POST]", error); + const message = error instanceof Error ? error.message : "Test failed"; + return NextResponse.json({ ok: false, error: message }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..7da683a --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getNotifications, + getUnreadCount, + markRead, + markAllRead, + dismissNotification, +} from "@/lib/notifications"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const unreadOnly = searchParams.get("unreadOnly") === "true"; + const limit = Math.min(100, parseInt(searchParams.get("limit") || "50")); + + const [notifications, unreadCount] = await Promise.all([ + getNotifications({ unreadOnly, limit }), + getUnreadCount(), + ]); + + return NextResponse.json({ notifications, unreadCount }); + } catch (error) { + console.error("[notifications GET]", error); + return NextResponse.json( + { error: "Failed to fetch notifications" }, + { status: 500 } + ); + } +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const action = body.action as string; + + if (action === "mark_read" && body.id) { + await markRead(body.id); + } else if (action === "mark_all_read") { + await markAllRead(); + } else if (action === "dismiss" && body.id) { + await dismissNotification(body.id); + } else { + return NextResponse.json({ error: "Invalid action" }, { status: 400 }); + } + + const unreadCount = await getUnreadCount(); + return NextResponse.json({ ok: true, unreadCount }); + } catch (error) { + console.error("[notifications PUT]", error); + return NextResponse.json( + { error: "Failed to update notification" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index f2d64d8..93b9515 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -38,6 +38,17 @@ export async function PUT(request: NextRequest) { if (body.aiProvider != null) data.aiProvider = String(body.aiProvider); if (body.aiModel != null) data.aiModel = String(body.aiModel); + if (body.mondayApiToken != null) data.mondayApiToken = String(body.mondayApiToken); + if (body.mondayBoardId != null) data.mondayBoardId = String(body.mondayBoardId); + if (body.mondayEnabled != null) data.mondayEnabled = Boolean(body.mondayEnabled); + if (body.mondayColumnMap !== undefined) data.mondayColumnMap = body.mondayColumnMap; + if (body.mondayWebhookUrl != null) data.mondayWebhookUrl = String(body.mondayWebhookUrl); + + if (body.webhookUrl != null) data.webhookUrl = String(body.webhookUrl); + if (body.webhookSecret != null) data.webhookSecret = String(body.webhookSecret); + if (body.webhookEnabled != null) data.webhookEnabled = Boolean(body.webhookEnabled); + if (body.webhookEvents !== undefined) data.webhookEvents = body.webhookEvents; + if (body.emailImapHost != null) data.emailImapHost = String(body.emailImapHost); if (body.emailImapPort != null) data.emailImapPort = Math.max(1, parseInt(String(body.emailImapPort)) || 993); if (body.emailImapUser != null) data.emailImapUser = String(body.emailImapUser); diff --git a/src/app/cards/[id]/page.tsx b/src/app/cards/[id]/page.tsx index 3b6a2a0..e562a60 100644 --- a/src/app/cards/[id]/page.tsx +++ b/src/app/cards/[id]/page.tsx @@ -19,6 +19,10 @@ import { RefreshCw, Loader2, AlertCircle, + Activity, + Clock, + Monitor, + LayoutGrid, } from "lucide-react"; import { Header } from "@/components/layout/header"; @@ -76,6 +80,15 @@ type CardData = { backImageUrl: string | null; }; +type ActivityEntry = { + id: string; + createdAt: string; + action: string; + source: string; + summary: string; + changes: { field: string; from: string | null; to: string | null }[] | null; +}; + export default function CardDetailPage() { const params = useParams(); const router = useRouter(); @@ -87,6 +100,10 @@ export default function CardDetailPage() { const [reprocessing, setReprocessing] = React.useState(false); const [edits, setEdits] = React.useState>({}); const [showRawOcr, setShowRawOcr] = React.useState(false); + const [showActivity, setShowActivity] = React.useState(false); + const [activityLog, setActivityLog] = React.useState([]); + const [activityLoading, setActivityLoading] = React.useState(false); + const [expandedEntry, setExpandedEntry] = React.useState(null); const fetchCard = React.useCallback(async () => { setLoading(true); @@ -187,6 +204,19 @@ export default function CardDetailPage() { } }; + const fetchActivity = React.useCallback(async () => { + setActivityLoading(true); + try { + const res = await fetch(`/api/cards/${id}/activity`); + if (res.ok) setActivityLog(await res.json()); + } catch { /* ignore */ } + finally { setActivityLoading(false); } + }, [id]); + + React.useEffect(() => { + if (showActivity) fetchActivity(); + }, [showActivity, fetchActivity]); + if (loading) { return (
@@ -429,6 +459,88 @@ export default function CardDetailPage() { )}
+ + + + + {showActivity && ( + + {activityLoading ? ( +
+ +
+ ) : activityLog.length === 0 ? ( +

No activity recorded yet

+ ) : ( +
+ {activityLog.map((entry) => { + const isExpanded = expandedEntry === entry.id; + const hasChanges = entry.changes && entry.changes.length > 0; + const sourceColor = + entry.source === "monday.com" ? "bg-blue-500/10 text-blue-700 dark:text-blue-300" : + entry.source === "user" ? "bg-amber-500/10 text-amber-700 dark:text-amber-300" : + "bg-muted text-muted-foreground"; + const sourceIcon = + entry.source === "monday.com" ? : + entry.source === "user" ? : + ; + + return ( +
+
+
+ {sourceIcon} +
+
+
+
+
+ + {entry.source} + + + + {formatTimeAgo(entry.createdAt)} + +
+ + {isExpanded && entry.changes && ( +
+ {entry.changes.map((ch, i) => ( +
+ {ch.field}: + {ch.from && {ch.from}} + {ch.from && ch.to && } + {ch.to && {ch.to}} +
+ ))} +
+ )} +
+
+ ); + })} +
+ )} + + )} + +
+ + {settings.mondayWebhookId && ( + Subscribed + )} +
+ + {mondayColumns.length > 0 && ( +
+

Column Mapping

+
+ {["name", "email", "cellPhone", "gender", "visitType", "city", "state", "reviewStatus"].map((field) => ( +
+ {field} + +
+ ))} +
+ Files (images) + +
+
+
+ )} + +
+ + setSettings((s) => ({ ...s, mondayEnabled: val }))} + /> +
+ + + + + +
+
+ + + Webhook Integration + + POST card data to any external URL on events +
+ {settings.webhookEnabled && ( + + Enabled + + )} +
+
+ +
+ + setSettings((s) => ({ ...s, webhookUrl: e.target.value }))} + placeholder="https://example.com/webhook" + /> +
+
+ + setSettings((s) => ({ ...s, webhookSecret: e.target.value }))} + placeholder="••••••••" + /> +
+
+ +
+ {[ + { value: "ocr_complete", label: "OCR Complete" }, + { value: "card_reviewed", label: "Card Reviewed" }, + { value: "card_exported", label: "Card Exported" }, + { value: "card_deleted", label: "Card Deleted" }, + ].map((evt) => ( + + ))} +
+
+
+ + setSettings((s) => ({ ...s, webhookEnabled: val }))} + /> +
+
diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 89bc4bf..ba21947 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -4,7 +4,6 @@ import Link from "next/link"; import { useTheme } from "next-themes"; import { Upload, - Bell, Moon, Sun, Settings, @@ -32,6 +31,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useUserProfile } from "@/lib/user-profile"; +import { NotificationCenter } from "@/components/notifications/notification-center"; export function TopBar() { const { theme, setTheme } = useTheme(); @@ -93,21 +93,7 @@ export function TopBar() { Upload documents - - - } - > - - Notifications - - Notifications - + ([]); + const [unreadCount, setUnreadCount] = React.useState(0); + + const fetchUnreadCount = React.useCallback(async () => { + try { + const res = await fetch("/api/notifications?unreadOnly=true&limit=1"); + if (res.ok) { + const data = await res.json(); + setUnreadCount(data.unreadCount ?? 0); + } + } catch { /* ignore */ } + }, []); + + React.useEffect(() => { + fetchUnreadCount(); + const interval = setInterval(fetchUnreadCount, 30_000); + return () => clearInterval(interval); + }, [fetchUnreadCount]); + + const fetchNotifications = React.useCallback(async () => { + try { + const res = await fetch("/api/notifications?limit=30"); + if (res.ok) { + const data = await res.json(); + setNotifications(data.notifications ?? []); + setUnreadCount(data.unreadCount ?? 0); + } + } catch { /* ignore */ } + }, []); + + React.useEffect(() => { + if (open) fetchNotifications(); + }, [open, fetchNotifications]); + + const markAllRead = async () => { + try { + await fetch("/api/notifications", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "mark_all_read" }), + }); + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + setUnreadCount(0); + } catch { /* ignore */ } + }; + + const markRead = async (id: string) => { + try { + await fetch("/api/notifications", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "mark_read", id }), + }); + setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n)); + setUnreadCount((c) => Math.max(0, c - 1)); + } catch { /* ignore */ } + }; + + return ( + + + } + > + + {unreadCount > 0 && ( + + {unreadCount > 9 ? "9+" : unreadCount} + + )} + Notifications + + +
+ Notifications + {unreadCount > 0 && ( + + )} +
+
+ {notifications.length === 0 ? ( +
+ +

No notifications

+
+ ) : ( + notifications.map((n) => ( + setOpen(false)} /> + )) + )} +
+
+
+ ); +} + +function NotificationItem({ + notification: n, + onRead, + onClose, +}: { + notification: Notification; + onRead: (id: string) => void; + onClose: () => void; +}) { + const iconMap: Record = { + ocr_complete: { icon: , color: "text-emerald-600 bg-emerald-500/10" }, + ocr_error: { icon: , color: "text-red-600 bg-red-500/10" }, + card_needs_review: { icon: , color: "text-amber-600 bg-amber-500/10" }, + monday_sync: { icon: , color: "text-blue-600 bg-blue-500/10" }, + monday_error: { icon: , color: "text-red-600 bg-red-500/10" }, + webhook_error: { icon: , color: "text-red-600 bg-red-500/10" }, + email_watcher: { icon: , color: "text-purple-600 bg-purple-500/10" }, + system: { icon: , color: "text-muted-foreground bg-muted" }, + }; + + const { icon, color } = iconMap[n.type] ?? iconMap.system; + + const handleClick = () => { + if (!n.read) onRead(n.id); + if (n.actionUrl) onClose(); + }; + + const content = ( +
+
+ {icon} +
+
+
+ {n.title} + {!n.read && } +
+

{n.message}

+ + + {formatTimeAgo(n.createdAt)} + +
+
+ ); + + if (n.actionUrl) { + return {content}; + } + return content; +} + +function formatTimeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return new Date(dateStr).toLocaleDateString(); +} diff --git a/src/lib/activity-log.ts b/src/lib/activity-log.ts new file mode 100644 index 0000000..7c622dd --- /dev/null +++ b/src/lib/activity-log.ts @@ -0,0 +1,62 @@ +import { prisma } from "./db"; + +const SKIP_FIELDS = new Set([ + "id", "createdAt", "updatedAt", "rawOcrResponse", + "frontImagePath", "backImagePath", "sourceFile", "mondayItemId", +]); + +type FieldChange = { field: string; from: string | null; to: string | null }; + +export function diffCardFields( + oldCard: Record, + newCard: Record +): FieldChange[] { + const changes: FieldChange[] = []; + const allKeys = new Set([...Object.keys(oldCard), ...Object.keys(newCard)]); + + for (const key of allKeys) { + if (SKIP_FIELDS.has(key)) continue; + const oldVal = normalize(oldCard[key]); + const newVal = normalize(newCard[key]); + if (oldVal !== newVal) { + changes.push({ field: key, from: oldVal, to: newVal }); + } + } + + return changes; +} + +function normalize(v: unknown): string | null { + if (v === null || v === undefined) return null; + if (typeof v === "object") return JSON.stringify(v); + return String(v); +} + +export async function logActivity( + cardId: string, + action: string, + source: string, + summary: string, + changes?: FieldChange[] | null +) { + try { + await prisma.activityLog.create({ + data: { + cardId, + action, + source, + summary, + changes: changes && changes.length > 0 ? changes : undefined, + }, + }); + } catch (err) { + console.error("[activity-log] Failed to create entry:", err); + } +} + +export async function getCardActivity(cardId: string) { + return prisma.activityLog.findMany({ + where: { cardId }, + orderBy: { createdAt: "desc" }, + }); +} diff --git a/src/lib/integrations.ts b/src/lib/integrations.ts new file mode 100644 index 0000000..e2fb54c --- /dev/null +++ b/src/lib/integrations.ts @@ -0,0 +1,208 @@ +import { prisma } from "./db"; +import { getBuffer } from "./minio"; +import { logActivity, diffCardFields } from "./activity-log"; +import { createNotification } from "./notifications"; +import { + createItem, + updateItem, + uploadFileToItem, + mapCardToColumnValues, +} from "./monday"; +import { sendWebhook } from "./webhook"; + +export type IntegrationEvent = + | "ocr_complete" + | "ocr_error" + | "card_reviewed" + | "card_exported" + | "card_deleted"; + +export async function fireIntegrationEvent( + event: IntegrationEvent, + cardId: string, + extra?: { oldCard?: Record } +) { + try { + const [settings, card] = await Promise.all([ + prisma.appSettings.findUnique({ where: { id: "singleton" } }), + prisma.responseCard.findUnique({ where: { id: cardId } }), + ]); + if (!card) return; + + const cardData = card as unknown as Record; + + // --- Activity Log --- + logActivityForEvent(event, cardId, cardData, extra?.oldCard).catch(() => {}); + + // --- Notifications --- + createNotificationForEvent(event, cardId, cardData).catch(() => {}); + + if (!settings) return; + + // --- Monday.com --- + if (settings.mondayEnabled && settings.mondayApiToken && settings.mondayBoardId) { + handleMonday(event, settings, card as unknown as Record, cardId).catch((err) => { + console.error("[integrations] Monday.com error:", err); + createNotification({ + type: "monday_error", + title: "Monday.com Sync Failed", + message: err instanceof Error ? err.message : "Unknown error", + cardId, + actionUrl: `/cards/${cardId}`, + }).catch(() => {}); + }); + } + + // --- Generic Webhook --- + if (settings.webhookEnabled && settings.webhookUrl) { + const events = (settings.webhookEvents as string[] | null) ?? []; + if (events.includes(event)) { + sendWebhook(settings.webhookUrl, settings.webhookSecret, event, cardData).then((result) => { + if (!result.ok) { + console.error("[integrations] Webhook error:", result.error); + createNotification({ + type: "webhook_error", + title: "Webhook Delivery Failed", + message: `${result.error} (${settings.webhookUrl})`, + cardId, + actionUrl: `/cards/${cardId}`, + }).catch(() => {}); + } + }).catch(() => {}); + } + } + } catch (err) { + console.error("[integrations] fireIntegrationEvent error:", err); + } +} + +async function logActivityForEvent( + event: IntegrationEvent, + cardId: string, + card: Record, + oldCard?: Record +) { + const name = (card.name as string) || "Unnamed"; + const confidence = card.ocrConfidence as number | null; + + switch (event) { + case "ocr_complete": + await logActivity(cardId, "ocr_complete", "system", + `Card created via OCR processing${confidence != null ? ` (${Math.round(confidence)}% confidence)` : ""}`); + break; + case "ocr_error": + await logActivity(cardId, "ocr_error", "system", + `OCR processing failed: ${card.ocrError || "Unknown error"}`); + break; + case "card_reviewed": + await logActivity(cardId, "status_change", "user", `Status changed to reviewed`, + [{ field: "reviewStatus", from: "unreviewed", to: "reviewed" }]); + break; + case "card_exported": + await logActivity(cardId, "status_change", "user", `Status changed to exported`, + [{ field: "reviewStatus", from: oldCard?.reviewStatus as string ?? "reviewed", to: "exported" }]); + break; + case "card_deleted": + await logActivity(cardId, "status_change", "user", `Card "${name}" deleted`); + break; + } +} + +async function createNotificationForEvent( + event: IntegrationEvent, + cardId: string, + card: Record +) { + const name = (card.name as string) || "Unnamed Card"; + const confidence = card.ocrConfidence as number | null; + + switch (event) { + case "ocr_complete": + await createNotification({ + type: "ocr_complete", + title: "OCR Complete", + message: `Card for ${name} processed${confidence != null ? ` (${Math.round(confidence)}% confidence)` : ""}`, + cardId, + actionUrl: `/cards/${cardId}`, + }); + await createNotification({ + type: "card_needs_review", + title: "Card Needs Review", + message: `${name} is ready for review`, + cardId, + actionUrl: `/cards/${cardId}`, + }); + break; + case "ocr_error": + await createNotification({ + type: "ocr_error", + title: "OCR Error", + message: `Processing failed for card: ${card.ocrError || "Unknown error"}`, + cardId, + actionUrl: `/cards/${cardId}`, + }); + break; + case "card_reviewed": + await createNotification({ + type: "ocr_complete", + title: "Card Reviewed", + message: `${name} has been marked as reviewed`, + cardId, + actionUrl: `/cards/${cardId}`, + }); + break; + case "card_exported": + await createNotification({ + type: "ocr_complete", + title: "Card Exported", + message: `${name} has been exported`, + cardId, + actionUrl: `/cards/${cardId}`, + }); + break; + } +} + +async function handleMonday( + event: IntegrationEvent, + settings: { mondayApiToken: string; mondayBoardId: string; mondayColumnMap: unknown }, + card: Record, + cardId: string +) { + const token = settings.mondayApiToken; + const boardId = settings.mondayBoardId; + const columnMap = (settings.mondayColumnMap as Record) ?? {}; + + if (event === "ocr_complete") { + const columnValues = mapCardToColumnValues(card, columnMap); + const itemName = (card.name as string) || "Unnamed Card"; + + const itemId = await createItem(token, boardId, itemName, columnValues); + await prisma.responseCard.update({ + where: { id: cardId }, + data: { mondayItemId: itemId }, + }); + + // Upload image attachments if a files column is mapped + const filesColId = columnMap._files; + if (filesColId) { + for (const imgPath of [card.backImagePath, card.frontImagePath]) { + if (typeof imgPath === "string" && imgPath) { + try { + const buffer = await getBuffer(imgPath); + const fileName = imgPath.split("/").pop() || "scan.jpg"; + await uploadFileToItem(token, itemId, filesColId, buffer, fileName); + } catch (err) { + console.error("[integrations] File upload to Monday failed:", err); + } + } + } + } + } else if (event === "card_reviewed" || event === "card_exported") { + const mondayItemId = card.mondayItemId as string | null; + if (mondayItemId) { + const columnValues = mapCardToColumnValues(card, columnMap); + await updateItem(token, boardId, mondayItemId, columnValues); + } + } +} diff --git a/src/lib/monday.ts b/src/lib/monday.ts new file mode 100644 index 0000000..ffb850b --- /dev/null +++ b/src/lib/monday.ts @@ -0,0 +1,189 @@ +const MONDAY_API = "https://api.monday.com/v2"; +const MONDAY_FILE_API = "https://api.monday.com/v2/file"; + +type MondayColumn = { id: string; title: string; type: string }; + +async function gql(token: string, query: string, variables?: Record) { + const res = await fetch(MONDAY_API, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token, + }, + body: JSON.stringify({ query, variables }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Monday.com API ${res.status}: ${text}`); + } + const json = await res.json(); + if (json.errors?.length) { + throw new Error(`Monday.com GraphQL: ${json.errors[0].message}`); + } + return json.data; +} + +export async function fetchBoardColumns(token: string, boardId: string): Promise { + const data = await gql(token, ` + query ($boardId: [ID!]!) { + boards(ids: $boardId) { + columns { id title type } + } + } + `, { boardId: [boardId] }); + return data?.boards?.[0]?.columns ?? []; +} + +export async function createItem( + token: string, + boardId: string, + itemName: string, + columnValues: Record +): Promise { + const data = await gql(token, ` + mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) { + create_item( + board_id: $boardId, + item_name: $itemName, + column_values: $columnValues, + create_labels_if_missing: true + ) { id } + } + `, { + boardId, + itemName, + columnValues: JSON.stringify(columnValues), + }); + return String(data.create_item.id); +} + +export async function updateItem( + token: string, + boardId: string, + itemId: string, + columnValues: Record +): Promise { + await gql(token, ` + mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) { + change_multiple_column_values( + board_id: $boardId, + item_id: $itemId, + column_values: $columnValues, + create_labels_if_missing: true + ) { id } + } + `, { + boardId, + itemId, + columnValues: JSON.stringify(columnValues), + }); +} + +export async function uploadFileToItem( + token: string, + itemId: string, + columnId: string, + fileBuffer: Buffer, + fileName: string +): Promise { + const query = `mutation ($file: File!) { add_file_to_column(file: $file, item_id: ${itemId}, column_id: "${columnId}") { id } }`; + + const form = new FormData(); + form.append("query", query); + form.append("variables[file]", new Blob([new Uint8Array(fileBuffer)]), fileName); + + const res = await fetch(MONDAY_FILE_API, { + method: "POST", + headers: { Authorization: token }, + body: form, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Monday.com file upload ${res.status}: ${text}`); + } +} + +type ItemColumnValue = { id: string; text: string; value: string | null }; + +export async function readItem( + token: string, + itemId: string +): Promise<{ name: string; columnValues: ItemColumnValue[] } | null> { + const data = await gql(token, ` + query ($itemId: [ID!]!) { + items(ids: $itemId) { + name + column_values { id text value } + } + } + `, { itemId: [itemId] }); + const item = data?.items?.[0]; + if (!item) return null; + return { + name: item.name, + columnValues: item.column_values.map((cv: { id: string; text: string; value: string | null }) => ({ + id: cv.id, + text: cv.text, + value: cv.value, + })), + }; +} + +export function mapCardToColumnValues( + card: Record, + columnMap: Record +): Record { + const values: Record = {}; + + for (const [cardField, colId] of Object.entries(columnMap)) { + if (!colId || !cardField) continue; + const val = card[cardField]; + if (val === null || val === undefined) continue; + values[colId] = String(val); + } + + return values; +} + +export function mapItemToCardFields( + columnValues: ItemColumnValue[], + columnMap: Record +): Record { + const reverseMap: Record = {}; + for (const [cardField, colId] of Object.entries(columnMap)) { + reverseMap[colId] = cardField; + } + + const cardData: Record = {}; + for (const cv of columnValues) { + const cardField = reverseMap[cv.id]; + if (cardField && cv.text) { + cardData[cardField] = cv.text; + } + } + return cardData; +} + +export async function createWebhookSubscription( + token: string, + boardId: string, + callbackUrl: string +): Promise { + const data = await gql(token, ` + mutation ($boardId: ID!, $url: String!) { + create_webhook(board_id: $boardId, url: $url, event: change_column_value) { id } + } + `, { boardId, url: callbackUrl }); + return String(data.create_webhook.id); +} + +export async function deleteWebhookSubscription( + token: string, + webhookId: string +): Promise { + await gql(token, ` + mutation ($webhookId: ID!) { + delete_webhook(id: $webhookId) { id } + } + `, { webhookId }); +} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 0000000..c4acede --- /dev/null +++ b/src/lib/notifications.ts @@ -0,0 +1,70 @@ +import { prisma } from "./db"; +import { Prisma } from "@/generated/prisma/client"; + +type CreateNotificationInput = { + type: string; + title: string; + message: string; + cardId?: string; + actionUrl?: string; + meta?: Prisma.InputJsonValue; +}; + +export async function createNotification(input: CreateNotificationInput) { + try { + return await prisma.notification.create({ + data: { + type: input.type, + title: input.title, + message: input.message, + cardId: input.cardId, + actionUrl: input.actionUrl, + meta: input.meta, + }, + }); + } catch (err) { + console.error("[notifications] Failed to create:", err); + return null; + } +} + +export async function getNotifications({ + unreadOnly = false, + limit = 50, +}: { unreadOnly?: boolean; limit?: number } = {}) { + const where: Record = { dismissed: false }; + if (unreadOnly) where.read = false; + + return prisma.notification.findMany({ + where, + orderBy: { createdAt: "desc" }, + take: limit, + }); +} + +export async function getUnreadCount() { + return prisma.notification.count({ + where: { read: false, dismissed: false }, + }); +} + +export async function markRead(id: string) { + return prisma.notification.update({ + where: { id }, + data: { read: true }, + }); +} + +export async function markAllRead() { + return prisma.notification.updateMany({ + where: { read: false, dismissed: false }, + data: { read: true }, + }); +} + +export async function dismissNotification(id: string) { + return prisma.notification.update({ + where: { id }, + data: { dismissed: true }, + }); +} diff --git a/src/lib/ocr.ts b/src/lib/ocr.ts index 0354569..7cb0fef 100644 --- a/src/lib/ocr.ts +++ b/src/lib/ocr.ts @@ -3,6 +3,7 @@ import { Prisma } from "@/generated/prisma/client"; import { uploadBuffer, getBuffer, deleteObject } from "./minio"; import { ocrImage } from "./ai-ocr"; import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf"; +import { fireIntegrationEvent } from "./integrations"; export async function processFile( jobId: string, @@ -134,12 +135,16 @@ export async function processFile( rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })), }, }); + + fireIntegrationEvent("ocr_complete", card.id).catch(() => {}); } catch (err) { const message = err instanceof Error ? err.message : "Unknown OCR error"; await prisma.responseCard.update({ where: { id: card.id }, data: { ocrStatus: "error", ocrError: message }, }); + + fireIntegrationEvent("ocr_error", card.id).catch(() => {}); } await prisma.processingJob.update({ diff --git a/src/lib/webhook.ts b/src/lib/webhook.ts new file mode 100644 index 0000000..3a609c4 --- /dev/null +++ b/src/lib/webhook.ts @@ -0,0 +1,39 @@ +import { createHmac } from "crypto"; + +export async function sendWebhook( + url: string, + secret: string, + event: string, + card: Record +): Promise<{ ok: boolean; status?: number; error?: string }> { + const payload = JSON.stringify({ + event, + timestamp: new Date().toISOString(), + card, + }); + + const headers: Record = { + "Content-Type": "application/json", + }; + + if (secret) { + const sig = createHmac("sha256", secret).update(payload).digest("hex"); + headers["X-Webhook-Signature"] = sig; + } + + try { + const res = await fetch(url, { + method: "POST", + headers, + body: payload, + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + return { ok: false, status: res.status, error: `HTTP ${res.status}` }; + } + return { ok: true, status: res.status }; + } catch (err) { + const message = err instanceof Error ? err.message : "Request failed"; + return { ok: false, error: message }; + } +}