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 { prisma } from "@/lib/db";
import { pushCardToMonday } from "@/lib/integrations"; import { pushCardToMonday } from "@/lib/integrations";
import { createNotification } from "@/lib/notifications"; import { createNotification } from "@/lib/notifications";
export async function POST() { export async function POST(req: NextRequest) {
try { try {
const settings = await prisma.appSettings.findUnique({ const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" }, 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({ const cards = await prisma.responseCard.findMany({
where: { where,
ocrStatus: "complete", select: { id: true, name: true, mondayItemId: true },
mondayItemId: null,
},
select: { id: true, name: true },
}); });
if (cards.length === 0) { 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; let failed = 0;
const errors: string[] = []; const errors: string[] = [];
@ -43,12 +51,13 @@ export async function POST() {
for (let j = 0; j < results.length; j++) { for (let j = 0; j < results.length; j++) {
const result = results[j]; const result = results[j];
if (result.status === "fulfilled") { if (result.status === "fulfilled") {
synced++; if (result.value.action === "created") created++;
else updated++;
} else { } else {
failed++; failed++;
const msg = result.reason instanceof Error ? result.reason.message : "Unknown error"; const msg = result.reason instanceof Error ? result.reason.message : "Unknown error";
errors.push(`${batch[j].name || batch[j].id}: ${msg}`); 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({ await createNotification({
type: failed > 0 ? "monday_error" : "monday_sync", type: failed > 0 ? "monday_error" : "monday_sync",
title: "Monday.com Bulk Sync Complete", title: "Monday.com Sync Complete",
message: `${synced} card(s) pushed${failed > 0 ? `, ${failed} failed` : ""}`, 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) { } catch (error) {
console.error("[monday/sync-all POST]", error); console.error("[monday/sync-all POST]", error);
const message = error instanceof Error ? error.message : "Sync failed"; 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); setPushingAll(true);
try { 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(); const data = await res.json();
if (res.ok) { if (res.ok) {
if (data.synced === 0 && data.failed === 0) { if (data.synced === 0 && data.failed === 0) {
toast.success("All cards are already synced to Monday.com"); toast.success(data.message || "All cards are already synced");
} 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`);
} else { } 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 { } else {
toast.error(data.error || "Sync failed"); toast.error(data.error || "Sync failed");
@ -1050,11 +1060,11 @@ export default function SettingsPage() {
variant="outline" variant="outline"
size="sm" size="sm"
className="rounded-xl" className="rounded-xl"
onClick={pushAllToMonday} onClick={syncAllToMonday}
disabled={pushingAll || !settings.mondayEnabled || !settings.mondayApiToken || !settings.mondayBoardId} disabled={pushingAll || !settings.mondayEnabled || !settings.mondayApiToken || !settings.mondayBoardId}
> >
{pushingAll ? <Loader2 className="mr-2 size-3 animate-spin" /> : <LayoutGrid className="mr-2 size-3" />} {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> </Button>
</div> </div>
</CardContent> </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 = () => { const handleExportCsv = () => {
if (data.length === 0) { if (data.length === 0) {
toast.error("No data to export"); toast.error("No data to export");
@ -419,6 +453,7 @@ export function DashboardContent() {
onMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")} onMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
onMarkExported={(ids) => handleBulkAction(ids, "exported")} onMarkExported={(ids) => handleBulkAction(ids, "exported")}
onReprocess={handleBulkReprocess} onReprocess={handleBulkReprocess}
onSyncMonday={handleBulkSyncMonday}
onDelete={(ids) => handleBulkAction(ids, "delete")} onDelete={(ids) => handleBulkAction(ids, "delete")}
onClear={() => setSelectedIds([])} onClear={() => setSelectedIds([])}
/> />

View file

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