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?
|
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])
|
||||||
|
}
|
||||||
|
|
|
||||||
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 { 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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
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.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);
|
||||||
|
|
|
||||||
|
|
@ -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">→</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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 { 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
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