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:
parent
6fdf68bc89
commit
72375d2fae
20 changed files with 1592 additions and 32 deletions
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
19
src/app/api/cards/[id]/activity/route.ts
Normal file
19
src/app/api/cards/[id]/activity/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown>,
|
||||
}).catch(() => {});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error("[cards/[id]/export POST]", error);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
|
||||
const updated = await prisma.responseCard.update({
|
||||
where: { 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) {
|
||||
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<void>[] = [];
|
||||
if (card.frontImagePath) deletePromises.push(deleteObject(card.frontImagePath));
|
||||
if (card.backImagePath) deletePromises.push(deleteObject(card.backImagePath));
|
||||
|
|
|
|||
24
src/app/api/integrations/monday/columns/route.ts
Normal file
24
src/app/api/integrations/monday/columns/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
68
src/app/api/integrations/monday/subscribe/route.ts
Normal file
68
src/app/api/integrations/monday/subscribe/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
82
src/app/api/integrations/monday/webhook/route.ts
Normal file
82
src/app/api/integrations/monday/webhook/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
47
src/app/api/integrations/webhook/test/route.ts
Normal file
47
src/app/api/integrations/webhook/test/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
55
src/app/api/notifications/route.ts
Normal file
55
src/app/api/notifications/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string>>({});
|
||||
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 () => {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -429,6 +459,88 @@ export default function CardDetailPage() {
|
|||
)}
|
||||
</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">→</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">
|
||||
<Button variant="outline" size="sm" className="w-fit rounded-xl" onClick={() => router.push("/")}>
|
||||
<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 }) {
|
||||
return (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -12,13 +12,15 @@ import {
|
|||
Brain,
|
||||
FolderSearch,
|
||||
HardDrive,
|
||||
Plug,
|
||||
Settings,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Bell,
|
||||
Mail,
|
||||
LayoutGrid,
|
||||
Globe,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Header } from "@/components/layout/header";
|
||||
|
|
@ -52,6 +54,10 @@ const AI_PROVIDERS = [
|
|||
},
|
||||
] as const;
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
type MondayColumn = { id: string; title: string; type: string };
|
||||
|
||||
type SettingsData = {
|
||||
ollamaUrl: string;
|
||||
model: string;
|
||||
|
|
@ -70,6 +76,16 @@ type SettingsData = {
|
|||
emailWatching: boolean;
|
||||
emailProcessed: 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";
|
||||
|
|
@ -118,6 +134,16 @@ export default function SettingsPage() {
|
|||
emailWatching: false,
|
||||
emailProcessed: "mark_read",
|
||||
emailProcessedFolder: "Processed",
|
||||
mondayApiToken: "",
|
||||
mondayBoardId: "",
|
||||
mondayEnabled: false,
|
||||
mondayColumnMap: null,
|
||||
mondayWebhookId: "",
|
||||
mondayWebhookUrl: "",
|
||||
webhookUrl: "",
|
||||
webhookSecret: "",
|
||||
webhookEnabled: false,
|
||||
webhookEvents: null,
|
||||
});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
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 [cleaning, setCleaning] = React.useState(false);
|
||||
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);
|
||||
|
||||
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 () => {
|
||||
setCleaning(true);
|
||||
try {
|
||||
|
|
@ -689,22 +805,205 @@ export default function SettingsPage() {
|
|||
|
||||
<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">
|
||||
<Plug className="size-4 text-primary" />
|
||||
<LayoutGrid className="size-4 text-primary" />
|
||||
Monday.com Integration
|
||||
</CardTitle>
|
||||
<CardDescription>Push scanned card data to Monday.com boards (coming soon)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-2xl border-2 border-dashed border-muted-foreground/15 p-6 sm:p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
API endpoints are ready. Configure Monday.com connection in a future update.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
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>
|
||||
<CardDescription>Bidirectional sync with Monday.com boards</CardDescription>
|
||||
</div>
|
||||
{settings.mondayEnabled && (
|
||||
<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 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>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<TooltipContent>Upload documents</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<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>
|
||||
<NotificationCenter />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
|
|
|
|||
202
src/components/notifications/notification-center.tsx
Normal file
202
src/components/notifications/notification-center.tsx
Normal 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
62
src/lib/activity-log.ts
Normal 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
208
src/lib/integrations.ts
Normal 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
189
src/lib/monday.ts
Normal 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
70
src/lib/notifications.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
39
src/lib/webhook.ts
Normal file
39
src/lib/webhook.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue