Add Monday.com sync-all with push+update support and batch toolbar button

- Sync-all endpoint now supports mode param: "push" (new only), "update"
  (existing only), or "all" (both); also accepts cardIds for batch ops
- Response includes separate created/updated/failed counts
- Settings button renamed to "Sync All to Monday.com" and shows breakdown
- Add "Monday.com" button to the floating selection toolbar for syncing
  selected cards in batch

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-09 15:14:48 -05:00
parent 05a40cca51
commit d6d209cf9d
4 changed files with 99 additions and 25 deletions

View file

@ -1,9 +1,9 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { pushCardToMonday } from "@/lib/integrations";
import { createNotification } from "@/lib/notifications";
export async function POST() {
export async function POST(req: NextRequest) {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
@ -15,19 +15,27 @@ export async function POST() {
);
}
const body = await req.json().catch(() => ({}));
const mode: "push" | "update" | "all" = body.mode || "all";
const cardIds: string[] | undefined = body.cardIds;
const where: Record<string, unknown> = { ocrStatus: "complete" };
if (mode === "push") where.mondayItemId = null;
else if (mode === "update") where.mondayItemId = { not: null };
if (cardIds?.length) where.id = { in: cardIds };
const cards = await prisma.responseCard.findMany({
where: {
ocrStatus: "complete",
mondayItemId: null,
},
select: { id: true, name: true },
where,
select: { id: true, name: true, mondayItemId: true },
});
if (cards.length === 0) {
return NextResponse.json({ ok: true, synced: 0, failed: 0, message: "All cards are already synced" });
const label = mode === "push" ? "push" : mode === "update" ? "update" : "sync";
return NextResponse.json({ ok: true, synced: 0, failed: 0, created: 0, updated: 0, message: `No cards to ${label}` });
}
let synced = 0;
let created = 0;
let updated = 0;
let failed = 0;
const errors: string[] = [];
@ -43,12 +51,13 @@ export async function POST() {
for (let j = 0; j < results.length; j++) {
const result = results[j];
if (result.status === "fulfilled") {
synced++;
if (result.value.action === "created") created++;
else updated++;
} else {
failed++;
const msg = result.reason instanceof Error ? result.reason.message : "Unknown error";
errors.push(`${batch[j].name || batch[j].id}: ${msg}`);
console.error(`[sync-all] Failed to push card ${batch[j].id}:`, result.reason);
console.error(`[sync-all] Failed to sync card ${batch[j].id}:`, result.reason);
}
}
@ -57,13 +66,19 @@ export async function POST() {
}
}
const synced = created + updated;
const parts: string[] = [];
if (created > 0) parts.push(`${created} pushed`);
if (updated > 0) parts.push(`${updated} updated`);
if (failed > 0) parts.push(`${failed} failed`);
await createNotification({
type: failed > 0 ? "monday_error" : "monday_sync",
title: "Monday.com Bulk Sync Complete",
message: `${synced} card(s) pushed${failed > 0 ? `, ${failed} failed` : ""}`,
title: "Monday.com Sync Complete",
message: parts.join(", "),
});
return NextResponse.json({ ok: true, synced, failed, total: cards.length, errors: errors.slice(0, 10) });
return NextResponse.json({ ok: true, synced, created, updated, 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";

View file

@ -415,21 +415,31 @@ export default function SettingsPage() {
}
};
const pushAllToMonday = async () => {
const syncAllToMonday = async () => {
setPushingAll(true);
try {
const res = await fetch("/api/integrations/monday/sync-all", { method: "POST" });
const res = await fetch("/api/integrations/monday/sync-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "all" }),
});
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 if (data.failed > 0 && data.synced === 0) {
const errDetail = data.errors?.[0] ? `\n${data.errors[0]}` : "";
toast.error(`All ${data.failed} card(s) failed to push to Monday.com${errDetail}`);
} else if (data.failed > 0) {
toast.warning(`Pushed ${data.synced} card(s), but ${data.failed} failed`);
toast.success(data.message || "All cards are already synced");
} else {
toast.success(`Pushed ${data.synced} card(s) to Monday.com`);
const parts: string[] = [];
if (data.created > 0) parts.push(`${data.created} pushed`);
if (data.updated > 0) parts.push(`${data.updated} updated`);
if (data.failed > 0) parts.push(`${data.failed} failed`);
const msg = parts.join(", ");
if (data.failed > 0 && data.synced === 0) {
toast.error(`Monday.com: ${msg}`, { description: data.errors?.[0] });
} else if (data.failed > 0) {
toast.warning(`Monday.com: ${msg}`);
} else {
toast.success(`Monday.com: ${msg}`);
}
}
} else {
toast.error(data.error || "Sync failed");
@ -1050,11 +1060,11 @@ export default function SettingsPage() {
variant="outline"
size="sm"
className="rounded-xl"
onClick={pushAllToMonday}
onClick={syncAllToMonday}
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
Sync All to Monday.com
</Button>
</div>
</CardContent>

View file

@ -167,6 +167,40 @@ export function DashboardContent() {
}
};
const handleBulkSyncMonday = async (ids: string[]) => {
try {
toast.info(`Syncing ${ids.length} card(s) to Monday.com…`);
const res = await fetch("/api/integrations/monday/sync-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cardIds: ids, mode: "all" }),
});
const result = await res.json();
if (!res.ok) {
toast.error(result.error || "Monday.com sync failed");
return;
}
const parts: string[] = [];
if (result.created > 0) parts.push(`${result.created} pushed`);
if (result.updated > 0) parts.push(`${result.updated} updated`);
if (result.failed > 0) parts.push(`${result.failed} failed`);
if (result.failed > 0 && result.synced === 0) {
toast.error(`Monday.com: ${parts.join(", ")}`, {
description: result.errors?.[0],
});
} else if (result.failed > 0) {
toast.warning(`Monday.com: ${parts.join(", ")}`);
} else if (result.synced === 0) {
toast.info("No eligible cards to sync");
} else {
toast.success(`Monday.com: ${parts.join(", ")}`);
}
fetchCards();
} catch {
toast.error("Failed to sync to Monday.com");
}
};
const handleExportCsv = () => {
if (data.length === 0) {
toast.error("No data to export");
@ -419,6 +453,7 @@ export function DashboardContent() {
onMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
onMarkExported={(ids) => handleBulkAction(ids, "exported")}
onReprocess={handleBulkReprocess}
onSyncMonday={handleBulkSyncMonday}
onDelete={(ids) => handleBulkAction(ids, "delete")}
onClear={() => setSelectedIds([])}
/>

View file

@ -8,6 +8,7 @@ import {
RefreshCw,
Trash2,
X,
LayoutGrid,
} from "lucide-react";
import { toast } from "sonner";
@ -27,6 +28,7 @@ interface SelectionToolbarProps {
onMarkReviewed?: (ids: string[]) => void;
onMarkExported?: (ids: string[]) => void;
onReprocess?: (ids: string[]) => void;
onSyncMonday?: (ids: string[]) => void;
onDelete?: (ids: string[]) => void;
onClear: () => void;
}
@ -54,6 +56,7 @@ export function SelectionToolbar({
onMarkReviewed,
onMarkExported,
onReprocess,
onSyncMonday,
onDelete,
onClear,
}: SelectionToolbarProps) {
@ -154,6 +157,17 @@ export function SelectionToolbar({
<span className="hidden sm:inline ml-1">Reprocess</span>
</Button>
)}
{onSyncMonday && (
<Button
variant="ghost"
size="sm"
className="rounded-xl"
onClick={() => onSyncMonday(selectedIds)}
>
<LayoutGrid className="size-4" />
<span className="hidden sm:inline ml-1">Monday.com</span>
</Button>
)}
<Popover open={copyOpen} onOpenChange={setCopyOpen}>
<PopoverTrigger