Add Integration Hub: Monday.com sync, webhooks, activity log, notifications

- Bidirectional Monday.com integration with column mapping and file uploads
- Generic webhook system with HMAC-SHA256 signing and configurable events
- Field-level activity log with diff tracking on card detail page
- Persistent notification center with unread badge in header
- Event dispatcher wired into OCR pipeline, card edits, exports, and deletes
- New Prisma models: ActivityLog, Notification; new fields on AppSettings and ResponseCard
- Settings UI with full Monday.com and webhook configuration panels

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-07 12:23:29 -05:00
parent 6fdf68bc89
commit 72375d2fae
20 changed files with 1592 additions and 32 deletions

View file

@ -52,10 +52,13 @@ model ResponseCard {
ocrError String? ocrError String?
rawOcrResponse Json? rawOcrResponse Json?
mondayItemId String?
@@index([ocrStatus]) @@index([ocrStatus])
@@index([reviewStatus]) @@index([reviewStatus])
@@index([name]) @@index([name])
@@index([createdAt]) @@index([createdAt])
@@index([mondayItemId])
} }
model ProcessingJob { model ProcessingJob {
@ -86,6 +89,18 @@ model AppSettings {
aiProvider String @default("gateway") aiProvider String @default("gateway")
aiModel String @default("") 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") emailImapHost String @default("imap.dreamhost.com")
emailImapPort Int @default(993) emailImapPort Int @default(993)
emailImapUser String @default("echo-ocr@stillwell.cloud") emailImapUser String @default("echo-ocr@stillwell.cloud")
@ -96,3 +111,31 @@ model AppSettings {
emailProcessed String @default("mark_read") emailProcessed String @default("mark_read")
emailProcessedFolder String @default("Processed") 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])
}

View file

@ -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 }
);
}
}

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { fireIntegrationEvent } from "@/lib/integrations";
export async function POST( export async function POST(
_request: NextRequest, _request: NextRequest,
@ -20,6 +21,10 @@ export async function POST(
data: { reviewStatus: "exported" }, data: { reviewStatus: "exported" },
}); });
fireIntegrationEvent("card_exported", id, {
oldCard: card as unknown as Record<string, unknown>,
}).catch(() => {});
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (error) { } catch (error) {
console.error("[cards/[id]/export POST]", error); console.error("[cards/[id]/export POST]", error);

View file

@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getPresignedUrl, deleteObject } from "@/lib/minio"; import { getPresignedUrl, deleteObject } from "@/lib/minio";
import { fireIntegrationEvent } from "@/lib/integrations";
import { logActivity, diffCardFields } from "@/lib/activity-log";
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
@ -90,11 +92,29 @@ export async function PUT(
if (body.howHeard != null) data.howHeard = body.howHeard; if (body.howHeard != null) data.howHeard = body.howHeard;
if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse; if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse;
const oldCard = card as unknown as Record<string, unknown>;
const updated = await prisma.responseCard.update({ const updated = await prisma.responseCard.update({
where: { id }, where: { id },
data: data as Parameters<typeof prisma.responseCard.update>[0]["data"], data: data as Parameters<typeof prisma.responseCard.update>[0]["data"],
}); });
const newCard = updated as unknown as Record<string, unknown>;
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); return NextResponse.json(updated);
} catch (error) { } catch (error) {
console.error("[cards/[id] PUT]", error); console.error("[cards/[id] PUT]", error);
@ -119,6 +139,8 @@ export async function DELETE(
return NextResponse.json({ error: "Card not found" }, { status: 404 }); return NextResponse.json({ error: "Card not found" }, { status: 404 });
} }
fireIntegrationEvent("card_deleted", id).catch(() => {});
const deletePromises: Promise<void>[] = []; const deletePromises: Promise<void>[] = [];
if (card.frontImagePath) deletePromises.push(deleteObject(card.frontImagePath)); if (card.frontImagePath) deletePromises.push(deleteObject(card.frontImagePath));
if (card.backImagePath) deletePromises.push(deleteObject(card.backImagePath)); if (card.backImagePath) deletePromises.push(deleteObject(card.backImagePath));

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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<string, string>) ?? {};
const cardFields = mapItemToCardFields(mondayItem.columnValues, columnMap);
if (Object.keys(cardFields).length === 0) {
return NextResponse.json({ ok: true });
}
const oldCard = card as unknown as Record<string, unknown>;
const data: Record<string, unknown> = {};
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<typeof prisma.responseCard.update>[0]["data"],
});
const newCard = updated as unknown as Record<string, unknown>;
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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 }
);
}
}

View file

@ -38,6 +38,17 @@ export async function PUT(request: NextRequest) {
if (body.aiProvider != null) data.aiProvider = String(body.aiProvider); if (body.aiProvider != null) data.aiProvider = String(body.aiProvider);
if (body.aiModel != null) data.aiModel = String(body.aiModel); 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.emailImapHost != null) data.emailImapHost = String(body.emailImapHost);
if (body.emailImapPort != null) data.emailImapPort = Math.max(1, parseInt(String(body.emailImapPort)) || 993); if (body.emailImapPort != null) data.emailImapPort = Math.max(1, parseInt(String(body.emailImapPort)) || 993);
if (body.emailImapUser != null) data.emailImapUser = String(body.emailImapUser); if (body.emailImapUser != null) data.emailImapUser = String(body.emailImapUser);

View file

@ -19,6 +19,10 @@ import {
RefreshCw, RefreshCw,
Loader2, Loader2,
AlertCircle, AlertCircle,
Activity,
Clock,
Monitor,
LayoutGrid,
} from "lucide-react"; } from "lucide-react";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
@ -76,6 +80,15 @@ type CardData = {
backImageUrl: string | null; 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() { export default function CardDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
@ -87,6 +100,10 @@ export default function CardDetailPage() {
const [reprocessing, setReprocessing] = React.useState(false); const [reprocessing, setReprocessing] = React.useState(false);
const [edits, setEdits] = React.useState<Record<string, string>>({}); const [edits, setEdits] = React.useState<Record<string, string>>({});
const [showRawOcr, setShowRawOcr] = React.useState(false); const [showRawOcr, setShowRawOcr] = React.useState(false);
const [showActivity, setShowActivity] = React.useState(false);
const [activityLog, setActivityLog] = React.useState<ActivityEntry[]>([]);
const [activityLoading, setActivityLoading] = React.useState(false);
const [expandedEntry, setExpandedEntry] = React.useState<string | null>(null);
const fetchCard = React.useCallback(async () => { const fetchCard = React.useCallback(async () => {
setLoading(true); 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) { if (loading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -429,6 +459,88 @@ export default function CardDetailPage() {
)} )}
</div> </div>
<Card variant="glass">
<CardHeader>
<button
className="flex w-full items-center justify-between text-left"
onClick={() => setShowActivity(!showActivity)}
>
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="size-4 text-primary" />
Activity Log
</CardTitle>
{showActivity ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
</button>
</CardHeader>
{showActivity && (
<CardContent>
{activityLoading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : activityLog.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">No activity recorded yet</p>
) : (
<div className="space-y-0">
{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" ? <LayoutGrid className="size-3" /> :
entry.source === "user" ? <User className="size-3" /> :
<Monitor className="size-3" />;
return (
<div key={entry.id} className="relative flex gap-3 py-2.5">
<div className="flex flex-col items-center">
<div className={cn("flex size-6 shrink-0 items-center justify-center rounded-full", sourceColor)}>
{sourceIcon}
</div>
<div className="w-px flex-1 bg-border/50" />
</div>
<div className="min-w-0 flex-1 pb-2">
<div className="flex items-center gap-2 mb-0.5">
<Badge variant="secondary" className={cn("text-[10px] px-1.5 py-0 capitalize", sourceColor)}>
{entry.source}
</Badge>
<span className="text-[10px] text-muted-foreground flex items-center gap-1">
<Clock className="size-2.5" />
{formatTimeAgo(entry.createdAt)}
</span>
</div>
<button
className={cn("text-sm text-left", hasChanges && "hover:underline cursor-pointer")}
onClick={() => hasChanges && setExpandedEntry(isExpanded ? null : entry.id)}
disabled={!hasChanges}
>
{entry.summary}
</button>
{isExpanded && entry.changes && (
<div className="mt-2 space-y-1 rounded-lg border border-border/50 bg-muted/20 p-2.5">
{entry.changes.map((ch, i) => (
<div key={i} className="text-xs">
<span className="font-medium capitalize text-foreground">{ch.field}: </span>
{ch.from && <span className="text-muted-foreground line-through mr-1">{ch.from}</span>}
{ch.from && ch.to && <span className="text-muted-foreground mr-1">&rarr;</span>}
{ch.to && <span className="text-emerald-600 dark:text-emerald-400">{ch.to}</span>}
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
)}
</Card>
<div className="flex flex-col gap-3 border-t border-border/50 pt-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 border-t border-border/50 pt-4 sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" size="sm" className="w-fit rounded-xl" onClick={() => router.push("/")}> <Button variant="outline" size="sm" className="w-fit rounded-xl" onClick={() => router.push("/")}>
<ArrowLeft className="mr-1 size-4" /> All Cards <ArrowLeft className="mr-1 size-4" /> All Cards
@ -472,6 +584,18 @@ function Field({ label, value, onChange }: { label: string; value: string; onCha
); );
} }
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();
}
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) { function SelectField({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) {
return ( return (
<div> <div>

View file

@ -12,13 +12,15 @@ import {
Brain, Brain,
FolderSearch, FolderSearch,
HardDrive, HardDrive,
Plug,
Settings, Settings,
Sun, Sun,
Moon, Moon,
Monitor, Monitor,
Bell, Bell,
Mail, Mail,
LayoutGrid,
Globe,
Check,
} from "lucide-react"; } from "lucide-react";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
@ -52,6 +54,10 @@ const AI_PROVIDERS = [
}, },
] as const; ] as const;
import { Checkbox } from "@/components/ui/checkbox";
type MondayColumn = { id: string; title: string; type: string };
type SettingsData = { type SettingsData = {
ollamaUrl: string; ollamaUrl: string;
model: string; model: string;
@ -70,6 +76,16 @@ type SettingsData = {
emailWatching: boolean; emailWatching: boolean;
emailProcessed: string; emailProcessed: string;
emailProcessedFolder: string; emailProcessedFolder: string;
mondayApiToken: string;
mondayBoardId: string;
mondayEnabled: boolean;
mondayColumnMap: Record<string, string> | null;
mondayWebhookId: string;
mondayWebhookUrl: string;
webhookUrl: string;
webhookSecret: string;
webhookEnabled: boolean;
webhookEvents: string[] | null;
}; };
const NOTIFICATION_STORAGE_KEY = "echo-ocr-notification-prefs"; const NOTIFICATION_STORAGE_KEY = "echo-ocr-notification-prefs";
@ -118,6 +134,16 @@ export default function SettingsPage() {
emailWatching: false, emailWatching: false,
emailProcessed: "mark_read", emailProcessed: "mark_read",
emailProcessedFolder: "Processed", emailProcessedFolder: "Processed",
mondayApiToken: "",
mondayBoardId: "",
mondayEnabled: false,
mondayColumnMap: null,
mondayWebhookId: "",
mondayWebhookUrl: "",
webhookUrl: "",
webhookSecret: "",
webhookEnabled: false,
webhookEvents: null,
}); });
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
@ -125,6 +151,10 @@ export default function SettingsPage() {
const [cleanupStatus, setCleanupStatus] = React.useState<{ sourcesEligible: number; imagesEligible: number } | null>(null); const [cleanupStatus, setCleanupStatus] = React.useState<{ sourcesEligible: number; imagesEligible: number } | null>(null);
const [cleaning, setCleaning] = React.useState(false); const [cleaning, setCleaning] = React.useState(false);
const [emailTestStatus, setEmailTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle"); const [emailTestStatus, setEmailTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle");
const [mondayColumns, setMondayColumns] = React.useState<MondayColumn[]>([]);
const [fetchingColumns, setFetchingColumns] = React.useState(false);
const [subscribing, setSubscribing] = React.useState(false);
const [webhookTestStatus, setWebhookTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle");
const [notifPrefs, setNotifPrefs] = React.useState<NotificationPrefs>(DEFAULT_NOTIFICATION_PREFS); const [notifPrefs, setNotifPrefs] = React.useState<NotificationPrefs>(DEFAULT_NOTIFICATION_PREFS);
React.useEffect(() => { React.useEffect(() => {
@ -281,6 +311,92 @@ export default function SettingsPage() {
} }
}; };
const fetchMondayColumns = async () => {
setFetchingColumns(true);
try {
const res = await fetch("/api/integrations/monday/columns", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: settings.mondayApiToken, boardId: settings.mondayBoardId }),
});
const data = await res.json();
if (res.ok && data.columns) {
setMondayColumns(data.columns);
toast.success(`Found ${data.columns.length} columns`);
} else {
toast.error(data.error || "Failed to fetch columns");
}
} catch {
toast.error("Failed to connect to Monday.com");
} finally {
setFetchingColumns(false);
}
};
const toggleMondaySubscription = async () => {
setSubscribing(true);
try {
const isSubscribed = !!settings.mondayWebhookId;
const callbackUrl = settings.mondayWebhookUrl || `${window.location.origin}/api/integrations/monday/webhook`;
const res = await fetch("/api/integrations/monday/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: isSubscribed ? "unsubscribe" : "subscribe",
callbackUrl,
}),
});
const data = await res.json();
if (res.ok) {
setSettings((s) => ({
...s,
mondayWebhookId: data.webhookId || "",
mondayWebhookUrl: isSubscribed ? "" : callbackUrl,
}));
toast.success(isSubscribed ? "Unsubscribed from Monday.com changes" : "Subscribed to Monday.com changes");
} else {
toast.error(data.error || "Failed");
}
} catch {
toast.error("Subscription change failed");
} finally {
setSubscribing(false);
}
};
const testWebhook = async () => {
setWebhookTestStatus("testing");
try {
const res = await fetch("/api/integrations/webhook/test", { method: "POST" });
const data = await res.json();
if (data.ok) {
setWebhookTestStatus("success");
toast.success("Test webhook sent");
} else {
setWebhookTestStatus("error");
toast.error(data.error || "Webhook test failed");
}
} catch {
setWebhookTestStatus("error");
toast.error("Webhook test failed");
}
};
const setColumnMapping = (cardField: string, colId: string) => {
setSettings((s) => ({
...s,
mondayColumnMap: { ...(s.mondayColumnMap || {}), [cardField]: colId },
}));
};
const toggleWebhookEvent = (event: string) => {
setSettings((s) => {
const current = s.webhookEvents ?? [];
const next = current.includes(event) ? current.filter((e) => e !== event) : [...current, event];
return { ...s, webhookEvents: next };
});
};
const runCleanup = async () => { const runCleanup = async () => {
setCleaning(true); setCleaning(true);
try { try {
@ -689,22 +805,205 @@ export default function SettingsPage() {
<Card variant="glass"> <Card variant="glass">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Plug className="size-4 text-primary" /> <div>
Monday.com Integration <CardTitle className="flex items-center gap-2 text-base">
</CardTitle> <LayoutGrid className="size-4 text-primary" />
<CardDescription>Push scanned card data to Monday.com boards (coming soon)</CardDescription> Monday.com Integration
</CardHeader> </CardTitle>
<CardContent> <CardDescription>Bidirectional sync with Monday.com boards</CardDescription>
<div className="rounded-2xl border-2 border-dashed border-muted-foreground/15 p-6 sm:p-8 text-center"> </div>
<p className="text-sm text-muted-foreground"> {settings.mondayEnabled && (
API endpoints are ready. Configure Monday.com connection in a future update. <Badge className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-300">
</p> <Check className="mr-1 size-3" /> Enabled
<p className="mt-2 text-xs text-muted-foreground"> </Badge>
Use the REST API at <code className="rounded-md bg-muted px-1.5 py-0.5 text-foreground/80">/api/cards</code> to )}
integrate with n8n, Zapier, or Monday.com directly.
</p>
</div> </div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">API Token</Label>
<Input
type="password"
value={settings.mondayApiToken}
onChange={(e) => setSettings((s) => ({ ...s, mondayApiToken: e.target.value }))}
placeholder="••••••••"
/>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Board ID</Label>
<Input
value={settings.mondayBoardId}
onChange={(e) => setSettings((s) => ({ ...s, mondayBoardId: e.target.value }))}
placeholder="1234567890"
/>
</div>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">App URL (for webhook callback)</Label>
<Input
value={settings.mondayWebhookUrl || (typeof window !== "undefined" ? `${window.location.origin}/api/integrations/monday/webhook` : "")}
onChange={(e) => setSettings((s) => ({ ...s, mondayWebhookUrl: e.target.value }))}
placeholder="https://your-app.com/api/integrations/monday/webhook"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={fetchMondayColumns}
disabled={fetchingColumns || !settings.mondayApiToken || !settings.mondayBoardId}
>
{fetchingColumns ? <Loader2 className="mr-2 size-3 animate-spin" /> : <LayoutGrid className="mr-2 size-3" />}
Fetch Columns
</Button>
<Button
variant={settings.mondayWebhookId ? "destructive" : "outline"}
size="sm"
className="rounded-xl"
onClick={toggleMondaySubscription}
disabled={subscribing || !settings.mondayApiToken || !settings.mondayBoardId}
>
{subscribing && <Loader2 className="mr-2 size-3 animate-spin" />}
{settings.mondayWebhookId ? "Unsubscribe" : "Subscribe to Changes"}
</Button>
{settings.mondayWebhookId && (
<Badge className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-300">Subscribed</Badge>
)}
</div>
{mondayColumns.length > 0 && (
<div className="rounded-xl border border-border/50 bg-muted/20 p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground mb-2">Column Mapping</p>
<div className="grid gap-2 sm:grid-cols-2">
{["name", "email", "cellPhone", "gender", "visitType", "city", "state", "reviewStatus"].map((field) => (
<div key={field} className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-24 shrink-0 capitalize">{field}</span>
<Select
value={(settings.mondayColumnMap || {})[field] || "__none__"}
onValueChange={(v) => setColumnMapping(field, !v || v === "__none__" ? "" : v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{mondayColumns.map((col) => (
<SelectItem key={col.id} value={col.id}>{col.title} ({col.type})</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-24 shrink-0">Files (images)</span>
<Select
value={(settings.mondayColumnMap || {})._files || "__none__"}
onValueChange={(v) => setColumnMapping("_files", !v || v === "__none__" ? "" : v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{mondayColumns.filter((c) => c.type === "file").map((col) => (
<SelectItem key={col.id} value={col.id}>{col.title}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
<div className="flex items-center gap-3">
<Label className="text-xs font-medium text-muted-foreground">Enable Integration</Label>
<Switch
checked={settings.mondayEnabled}
onCheckedChange={(val: boolean) => setSettings((s) => ({ ...s, mondayEnabled: val }))}
/>
</div>
</CardContent>
</Card>
<Card variant="glass">
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-base">
<Globe className="size-4 text-primary" />
Webhook Integration
</CardTitle>
<CardDescription>POST card data to any external URL on events</CardDescription>
</div>
{settings.webhookEnabled && (
<Badge className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-300">
<Check className="mr-1 size-3" /> Enabled
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Webhook URL</Label>
<Input
value={settings.webhookUrl}
onChange={(e) => setSettings((s) => ({ ...s, webhookUrl: e.target.value }))}
placeholder="https://example.com/webhook"
/>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Secret (optional, for HMAC signing)</Label>
<Input
type="password"
value={settings.webhookSecret}
onChange={(e) => setSettings((s) => ({ ...s, webhookSecret: e.target.value }))}
placeholder="••••••••"
/>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">Events</Label>
<div className="grid gap-2 sm:grid-cols-2">
{[
{ 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) => (
<label key={evt.value} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={(settings.webhookEvents ?? []).includes(evt.value)}
onCheckedChange={() => toggleWebhookEvent(evt.value)}
/>
{evt.label}
</label>
))}
</div>
</div>
<div className="flex items-center gap-3">
<Label className="text-xs font-medium text-muted-foreground">Enable Webhook</Label>
<Switch
checked={settings.webhookEnabled}
onCheckedChange={(val: boolean) => setSettings((s) => ({ ...s, webhookEnabled: val }))}
/>
</div>
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={testWebhook}
disabled={webhookTestStatus === "testing" || !settings.webhookUrl}
>
{webhookTestStatus === "testing" ? (
<Loader2 className="mr-2 size-3 animate-spin" />
) : (
<Wifi className="mr-2 size-3" />
)}
Send Test
</Button>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View file

@ -4,7 +4,6 @@ import Link from "next/link";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { import {
Upload, Upload,
Bell,
Moon, Moon,
Sun, Sun,
Settings, Settings,
@ -32,6 +31,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useUserProfile } from "@/lib/user-profile"; import { useUserProfile } from "@/lib/user-profile";
import { NotificationCenter } from "@/components/notifications/notification-center";
export function TopBar() { export function TopBar() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@ -93,21 +93,7 @@ export function TopBar() {
<TooltipContent>Upload documents</TooltipContent> <TooltipContent>Upload documents</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <NotificationCenter />
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-9 rounded-xl"
/>
}
>
<Bell className="size-4" />
<span className="sr-only">Notifications</span>
</TooltipTrigger>
<TooltipContent>Notifications</TooltipContent>
</Tooltip>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger

View file

@ -0,0 +1,202 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
Bell,
CheckCircle2,
AlertCircle,
RefreshCw,
Mail,
Monitor,
LayoutGrid,
Globe,
Clock,
CheckCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
type Notification = {
id: string;
createdAt: string;
read: boolean;
type: string;
title: string;
message: string;
cardId: string | null;
actionUrl: string | null;
};
export function NotificationCenter() {
const [open, setOpen] = React.useState(false);
const [notifications, setNotifications] = React.useState<Notification[]>([]);
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button variant="ghost" size="icon" className="relative size-9 rounded-xl" />
}
>
<Bell className="size-4" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex size-4.5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-80 p-0">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2.5">
<span className="text-sm font-medium">Notifications</span>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" className="h-7 text-xs px-2" onClick={markAllRead}>
<CheckCheck className="mr-1 size-3" /> Mark all read
</Button>
)}
</div>
<div className="max-h-80 overflow-y-auto">
{notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<Bell className="size-8 mb-2 opacity-20" />
<p className="text-sm">No notifications</p>
</div>
) : (
notifications.map((n) => (
<NotificationItem key={n.id} notification={n} onRead={markRead} onClose={() => setOpen(false)} />
))
)}
</div>
</PopoverContent>
</Popover>
);
}
function NotificationItem({
notification: n,
onRead,
onClose,
}: {
notification: Notification;
onRead: (id: string) => void;
onClose: () => void;
}) {
const iconMap: Record<string, { icon: React.ReactNode; color: string }> = {
ocr_complete: { icon: <CheckCircle2 className="size-3.5" />, color: "text-emerald-600 bg-emerald-500/10" },
ocr_error: { icon: <AlertCircle className="size-3.5" />, color: "text-red-600 bg-red-500/10" },
card_needs_review: { icon: <RefreshCw className="size-3.5" />, color: "text-amber-600 bg-amber-500/10" },
monday_sync: { icon: <LayoutGrid className="size-3.5" />, color: "text-blue-600 bg-blue-500/10" },
monday_error: { icon: <LayoutGrid className="size-3.5" />, color: "text-red-600 bg-red-500/10" },
webhook_error: { icon: <Globe className="size-3.5" />, color: "text-red-600 bg-red-500/10" },
email_watcher: { icon: <Mail className="size-3.5" />, color: "text-purple-600 bg-purple-500/10" },
system: { icon: <Monitor className="size-3.5" />, 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 = (
<div
className={cn(
"flex gap-3 px-3 py-2.5 border-b border-border/30 transition-colors hover:bg-muted/50 cursor-pointer",
!n.read && "bg-primary/[0.03]"
)}
onClick={handleClick}
>
<div className={cn("flex size-7 shrink-0 items-center justify-center rounded-full mt-0.5", color)}>
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className={cn("text-xs font-medium truncate", !n.read && "text-foreground")}>{n.title}</span>
{!n.read && <span className="size-1.5 shrink-0 rounded-full bg-primary" />}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{n.message}</p>
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1 mt-1">
<Clock className="size-2.5" />
{formatTimeAgo(n.createdAt)}
</span>
</div>
</div>
);
if (n.actionUrl) {
return <Link href={n.actionUrl}>{content}</Link>;
}
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();
}

62
src/lib/activity-log.ts Normal file
View file

@ -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<string, unknown>,
newCard: Record<string, unknown>
): 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" },
});
}

208
src/lib/integrations.ts Normal file
View file

@ -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<string, unknown> }
) {
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<string, unknown>;
// --- 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<string, unknown>, 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<string, unknown>,
oldCard?: Record<string, unknown>
) {
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<string, unknown>
) {
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<string, unknown>,
cardId: string
) {
const token = settings.mondayApiToken;
const boardId = settings.mondayBoardId;
const columnMap = (settings.mondayColumnMap as Record<string, string>) ?? {};
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);
}
}
}

189
src/lib/monday.ts Normal file
View file

@ -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<string, unknown>) {
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<MondayColumn[]> {
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<string, unknown>
): Promise<string> {
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<string, unknown>
): Promise<void> {
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<void> {
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<string, unknown>,
columnMap: Record<string, string>
): Record<string, unknown> {
const values: Record<string, unknown> = {};
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<string, string>
): Record<string, string> {
const reverseMap: Record<string, string> = {};
for (const [cardField, colId] of Object.entries(columnMap)) {
reverseMap[colId] = cardField;
}
const cardData: Record<string, string> = {};
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<string> {
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<void> {
await gql(token, `
mutation ($webhookId: ID!) {
delete_webhook(id: $webhookId) { id }
}
`, { webhookId });
}

70
src/lib/notifications.ts Normal file
View file

@ -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<string, unknown> = { 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 },
});
}

View file

@ -3,6 +3,7 @@ import { Prisma } from "@/generated/prisma/client";
import { uploadBuffer, getBuffer, deleteObject } from "./minio"; import { uploadBuffer, getBuffer, deleteObject } from "./minio";
import { ocrImage } from "./ai-ocr"; import { ocrImage } from "./ai-ocr";
import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf"; import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf";
import { fireIntegrationEvent } from "./integrations";
export async function processFile( export async function processFile(
jobId: string, jobId: string,
@ -134,12 +135,16 @@ export async function processFile(
rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })), rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })),
}, },
}); });
fireIntegrationEvent("ocr_complete", card.id).catch(() => {});
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Unknown OCR error"; const message = err instanceof Error ? err.message : "Unknown OCR error";
await prisma.responseCard.update({ await prisma.responseCard.update({
where: { id: card.id }, where: { id: card.id },
data: { ocrStatus: "error", ocrError: message }, data: { ocrStatus: "error", ocrError: message },
}); });
fireIntegrationEvent("ocr_error", card.id).catch(() => {});
} }
await prisma.processingJob.update({ await prisma.processingJob.update({

39
src/lib/webhook.ts Normal file
View file

@ -0,0 +1,39 @@
import { createHmac } from "crypto";
export async function sendWebhook(
url: string,
secret: string,
event: string,
card: Record<string, unknown>
): Promise<{ ok: boolean; status?: number; error?: string }> {
const payload = JSON.stringify({
event,
timestamp: new Date().toISOString(),
card,
});
const headers: Record<string, string> = {
"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 };
}
}