Add manual push to Monday.com (single card + bulk sync)
- Extract reusable pushCardToMonday() that creates or updates Monday items - New /api/integrations/monday/push/[id] endpoint for single-card push - New /api/integrations/monday/sync-all endpoint for bulk push of all unsynced cards (ocrStatus=complete, no mondayItemId) - Add "Push to Monday.com" button on card detail page header (shows "Update Monday" when card already has a Monday item) - Add "Push All to Monday.com" button in Monday.com settings card Made-with: Cursor
This commit is contained in:
parent
2fcdd687f7
commit
7aefe7c4a1
5 changed files with 207 additions and 34 deletions
21
src/app/api/integrations/monday/push/[id]/route.ts
Normal file
21
src/app/api/integrations/monday/push/[id]/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { pushCardToMonday } from "@/lib/integrations";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const result = await pushCardToMonday(id);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
action: result.action,
|
||||||
|
mondayItemId: result.mondayItemId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[monday/push POST]", error);
|
||||||
|
const message = error instanceof Error ? error.message : "Push failed";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/app/api/integrations/monday/sync-all/route.ts
Normal file
58
src/app/api/integrations/monday/sync-all/route.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { pushCardToMonday } from "@/lib/integrations";
|
||||||
|
import { createNotification } from "@/lib/notifications";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const settings = await prisma.appSettings.findUnique({
|
||||||
|
where: { id: "singleton" },
|
||||||
|
});
|
||||||
|
if (!settings?.mondayEnabled || !settings?.mondayApiToken || !settings?.mondayBoardId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Monday.com integration is not configured or enabled" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = await prisma.responseCard.findMany({
|
||||||
|
where: {
|
||||||
|
ocrStatus: "complete",
|
||||||
|
mondayItemId: null,
|
||||||
|
},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cards.length === 0) {
|
||||||
|
return NextResponse.json({ ok: true, synced: 0, failed: 0, message: "All cards are already synced" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const card of cards) {
|
||||||
|
try {
|
||||||
|
await pushCardToMonday(card.id, settings);
|
||||||
|
synced++;
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
errors.push(`${card.name || card.id}: ${msg}`);
|
||||||
|
console.error(`[sync-all] Failed to push card ${card.id}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await createNotification({
|
||||||
|
type: failed > 0 ? "monday_error" : "monday_sync",
|
||||||
|
title: "Monday.com Bulk Sync Complete",
|
||||||
|
message: `${synced} card(s) pushed${failed > 0 ? `, ${failed} failed` : ""}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, synced, failed, total: cards.length, errors: errors.slice(0, 10) });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[monday/sync-all POST]", error);
|
||||||
|
const message = error instanceof Error ? error.message : "Sync failed";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,7 @@ type CardData = {
|
||||||
ocrConfidence: number | null;
|
ocrConfidence: number | null;
|
||||||
ocrError: string | null;
|
ocrError: string | null;
|
||||||
rawOcrResponse: Record<string, unknown> | null;
|
rawOcrResponse: Record<string, unknown> | null;
|
||||||
|
mondayItemId: string | null;
|
||||||
frontImageUrl: string | null;
|
frontImageUrl: string | null;
|
||||||
backImageUrl: string | null;
|
backImageUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -111,6 +112,7 @@ export default function CardDetailPage() {
|
||||||
const [activityLog, setActivityLog] = React.useState<ActivityEntry[]>([]);
|
const [activityLog, setActivityLog] = React.useState<ActivityEntry[]>([]);
|
||||||
const [activityLoading, setActivityLoading] = React.useState(false);
|
const [activityLoading, setActivityLoading] = React.useState(false);
|
||||||
const [expandedEntry, setExpandedEntry] = React.useState<string | null>(null);
|
const [expandedEntry, setExpandedEntry] = React.useState<string | null>(null);
|
||||||
|
const [pushingToMonday, setPushingToMonday] = React.useState(false);
|
||||||
|
|
||||||
const fetchCard = React.useCallback(async () => {
|
const fetchCard = React.useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -211,6 +213,24 @@ export default function CardDetailPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePushToMonday = async () => {
|
||||||
|
setPushingToMonday(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/integrations/monday/push/${id}`, { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success(data.action === "created" ? "Pushed to Monday.com" : "Updated in Monday.com");
|
||||||
|
fetchCard();
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || "Push to Monday.com failed");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to push to Monday.com");
|
||||||
|
} finally {
|
||||||
|
setPushingToMonday(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchActivity = React.useCallback(async () => {
|
const fetchActivity = React.useCallback(async () => {
|
||||||
setActivityLoading(true);
|
setActivityLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -304,6 +324,19 @@ export default function CardDetailPage() {
|
||||||
<Loader2 className="size-3.5 animate-spin" /> Processing...
|
<Loader2 className="size-3.5 animate-spin" /> Processing...
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={handlePushToMonday}
|
||||||
|
disabled={pushingToMonday || ocrStatus !== "complete"}
|
||||||
|
>
|
||||||
|
{pushingToMonday ? (
|
||||||
|
<><Loader2 className="mr-1 size-4 animate-spin" /> Pushing...</>
|
||||||
|
) : (
|
||||||
|
<><LayoutGrid className="mr-1 size-4" /> {card.mondayItemId ? "Update Monday" : "Push to Monday"}</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
{reviewStatus !== "reviewed" && (
|
{reviewStatus !== "reviewed" && (
|
||||||
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleMarkReviewed}>
|
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleMarkReviewed}>
|
||||||
<Check className="mr-1 size-4" /> Mark Reviewed
|
<Check className="mr-1 size-4" /> Mark Reviewed
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,7 @@ export default function SettingsPage() {
|
||||||
const [mondayColumns, setMondayColumns] = React.useState<MondayColumn[]>([]);
|
const [mondayColumns, setMondayColumns] = React.useState<MondayColumn[]>([]);
|
||||||
const [fetchingColumns, setFetchingColumns] = React.useState(false);
|
const [fetchingColumns, setFetchingColumns] = React.useState(false);
|
||||||
const [subscribing, setSubscribing] = React.useState(false);
|
const [subscribing, setSubscribing] = React.useState(false);
|
||||||
|
const [pushingAll, setPushingAll] = React.useState(false);
|
||||||
const [webhookTestStatus, setWebhookTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle");
|
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);
|
||||||
|
|
||||||
|
|
@ -399,6 +400,27 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pushAllToMonday = async () => {
|
||||||
|
setPushingAll(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/integrations/monday/sync-all", { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
if (data.synced === 0 && data.failed === 0) {
|
||||||
|
toast.success("All cards are already synced to Monday.com");
|
||||||
|
} else {
|
||||||
|
toast.success(`Pushed ${data.synced} card(s) to Monday.com${data.failed > 0 ? ` (${data.failed} failed)` : ""}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || "Sync failed");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to sync cards to Monday.com");
|
||||||
|
} finally {
|
||||||
|
setPushingAll(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const testWebhook = async () => {
|
const testWebhook = async () => {
|
||||||
setWebhookTestStatus("testing");
|
setWebhookTestStatus("testing");
|
||||||
try {
|
try {
|
||||||
|
|
@ -954,6 +976,7 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Label className="text-xs font-medium text-muted-foreground">Enable Integration</Label>
|
<Label className="text-xs font-medium text-muted-foreground">Enable Integration</Label>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -961,6 +984,17 @@ export default function SettingsPage() {
|
||||||
onCheckedChange={(val: boolean) => setSettings((s) => ({ ...s, mondayEnabled: val }))}
|
onCheckedChange={(val: boolean) => setSettings((s) => ({ ...s, mondayEnabled: val }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={pushAllToMonday}
|
||||||
|
disabled={pushingAll || !settings.mondayEnabled || !settings.mondayApiToken || !settings.mondayBoardId}
|
||||||
|
>
|
||||||
|
{pushingAll ? <Loader2 className="mr-2 size-3 animate-spin" /> : <LayoutGrid className="mr-2 size-3" />}
|
||||||
|
Push All to Monday.com
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,24 +169,54 @@ async function handleMonday(
|
||||||
card: Record<string, unknown>,
|
card: Record<string, unknown>,
|
||||||
cardId: string
|
cardId: string
|
||||||
) {
|
) {
|
||||||
|
if (event === "ocr_complete") {
|
||||||
|
await pushCardToMonday(cardId, settings);
|
||||||
|
} else if (event === "card_reviewed" || event === "card_exported") {
|
||||||
|
const token = settings.mondayApiToken;
|
||||||
|
const boardId = settings.mondayBoardId;
|
||||||
|
const columnMap = (settings.mondayColumnMap as Record<string, string>) ?? {};
|
||||||
|
const mondayItemId = card.mondayItemId as string | null;
|
||||||
|
if (mondayItemId) {
|
||||||
|
const columnValues = mapCardToColumnValues(card, columnMap);
|
||||||
|
await updateItem(token, boardId, mondayItemId, columnValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushCardToMonday(
|
||||||
|
cardId: string,
|
||||||
|
settingsOverride?: { mondayApiToken: string; mondayBoardId: string; mondayColumnMap: unknown } | null
|
||||||
|
) {
|
||||||
|
const settings = settingsOverride ?? await prisma.appSettings.findUnique({ where: { id: "singleton" } });
|
||||||
|
if (!settings?.mondayApiToken || !settings?.mondayBoardId) {
|
||||||
|
throw new Error("Monday.com is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = await prisma.responseCard.findUnique({ where: { id: cardId } });
|
||||||
|
if (!card) throw new Error("Card not found");
|
||||||
|
|
||||||
|
const cardData = card as unknown as Record<string, unknown>;
|
||||||
const token = settings.mondayApiToken;
|
const token = settings.mondayApiToken;
|
||||||
const boardId = settings.mondayBoardId;
|
const boardId = settings.mondayBoardId;
|
||||||
const columnMap = (settings.mondayColumnMap as Record<string, string>) ?? {};
|
const columnMap = (settings.mondayColumnMap as Record<string, string>) ?? {};
|
||||||
|
|
||||||
if (event === "ocr_complete") {
|
const columnValues = mapCardToColumnValues(cardData, columnMap);
|
||||||
const columnValues = mapCardToColumnValues(card, columnMap);
|
|
||||||
const itemName = (card.name as string) || "Unnamed Card";
|
const itemName = (card.name as string) || "Unnamed Card";
|
||||||
|
|
||||||
|
if (card.mondayItemId) {
|
||||||
|
await updateItem(token, boardId, card.mondayItemId, columnValues);
|
||||||
|
return { action: "updated" as const, mondayItemId: card.mondayItemId };
|
||||||
|
}
|
||||||
|
|
||||||
const itemId = await createItem(token, boardId, itemName, columnValues);
|
const itemId = await createItem(token, boardId, itemName, columnValues);
|
||||||
await prisma.responseCard.update({
|
await prisma.responseCard.update({
|
||||||
where: { id: cardId },
|
where: { id: cardId },
|
||||||
data: { mondayItemId: itemId },
|
data: { mondayItemId: itemId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload image attachments if a files column is mapped
|
|
||||||
const filesColId = columnMap._files;
|
const filesColId = columnMap._files;
|
||||||
if (filesColId) {
|
if (filesColId) {
|
||||||
for (const imgPath of [card.backImagePath, card.frontImagePath]) {
|
for (const imgPath of [cardData.backImagePath, cardData.frontImagePath]) {
|
||||||
if (typeof imgPath === "string" && imgPath) {
|
if (typeof imgPath === "string" && imgPath) {
|
||||||
try {
|
try {
|
||||||
const buffer = await getBuffer(imgPath);
|
const buffer = await getBuffer(imgPath);
|
||||||
|
|
@ -198,11 +228,8 @@ async function handleMonday(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event === "card_reviewed" || event === "card_exported") {
|
|
||||||
const mondayItemId = card.mondayItemId as string | null;
|
await logActivity(cardId, "monday_sync", "system", "Card pushed to Monday.com");
|
||||||
if (mondayItemId) {
|
|
||||||
const columnValues = mapCardToColumnValues(card, columnMap);
|
return { action: "created" as const, mondayItemId: itemId };
|
||||||
await updateItem(token, boardId, mondayItemId, columnValues);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue