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:
Randall Stillwell 2026-04-07 13:32:15 -05:00
parent 2fcdd687f7
commit 7aefe7c4a1
5 changed files with 207 additions and 34 deletions

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

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

View file

@ -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

View file

@ -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>

View file

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