diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 6273aa8..62b9fc4 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -8,6 +8,8 @@ import type { ApplicationTask, AppSettings, BackupInfo, + BulkJobActionRequest, + BulkJobActionResponse, CreateJobInput, DemoInfoResponse, Job, @@ -227,6 +229,15 @@ export async function skipJob(id: string): Promise { }); } +export async function bulkJobAction( + input: BulkJobActionRequest, +): Promise { + return fetchApi("/jobs/bulk-actions", { + method: "POST", + body: JSON.stringify(input), + }); +} + export async function getJobStageEvents(id: string): Promise { return fetchApi(`/jobs/${id}/events?t=${Date.now()}`); } diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index ab33563..8353fe0 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -23,7 +23,7 @@ import { XCircle, } from "lucide-react"; import type React from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Accordion, @@ -75,6 +75,7 @@ export const ReadyPanel: React.FC = ({ employer: string; timeoutId: ReturnType; } | null>(null); + const previousJobIdRef = useRef(null); const { personName } = useProfile(); @@ -85,6 +86,9 @@ export const ReadyPanel: React.FC = ({ // Reset mode when job changes useEffect(() => { + const currentJobId = job?.id ?? null; + if (previousJobIdRef.current === currentJobId) return; + previousJobIdRef.current = currentJobId; setMode("ready"); onTailoringDirtyChange?.(false); }, [job?.id, onTailoringDirtyChange]); diff --git a/orchestrator/src/client/components/TailoringEditor.test.tsx b/orchestrator/src/client/components/TailoringEditor.test.tsx index d8ba7b1..245d548 100644 --- a/orchestrator/src/client/components/TailoringEditor.test.tsx +++ b/orchestrator/src/client/components/TailoringEditor.test.tsx @@ -51,7 +51,9 @@ describe("TailoringEditor", () => { />, ); - expect(screen.getByLabelText("Tailored Summary")).toHaveValue("Local draft"); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Local draft", + ); }); it("resets local state when job id changes", async () => { @@ -78,7 +80,9 @@ describe("TailoringEditor", () => { />, ); - expect(screen.getByLabelText("Tailored Summary")).toHaveValue("New job summary"); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "New job summary", + ); }); it("emits dirty state changes", async () => { diff --git a/orchestrator/src/client/components/TailoringEditor.tsx b/orchestrator/src/client/components/TailoringEditor.tsx index da42e6d..544c5eb 100644 --- a/orchestrator/src/client/components/TailoringEditor.tsx +++ b/orchestrator/src/client/components/TailoringEditor.tsx @@ -42,28 +42,39 @@ export const TailoringEditor: React.FC = ({ }) => { const [catalog, setCatalog] = useState([]); const [summary, setSummary] = useState(job.tailoredSummary || ""); - const [jobDescription, setJobDescription] = useState(job.jobDescription || ""); + const [jobDescription, setJobDescription] = useState( + job.jobDescription || "", + ); const [selectedIds, setSelectedIds] = useState>(() => parseSelectedIds(job.selectedProjectIds), ); const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || ""); - const [savedDescription, setSavedDescription] = useState(job.jobDescription || ""); + const [savedDescription, setSavedDescription] = useState( + job.jobDescription || "", + ); const [savedSelectedIds, setSavedSelectedIds] = useState>(() => parseSelectedIds(job.selectedProjectIds), ); const [isSummarizing, setIsSummarizing] = useState(false); const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [activeField, setActiveField] = useState<"summary" | "description" | null>( - null, - ); + const [activeField, setActiveField] = useState< + "summary" | "description" | null + >(null); const lastJobIdRef = useRef(job.id); const isDirty = useMemo(() => { if (summary !== savedSummary) return true; if (jobDescription !== savedDescription) return true; return hasSelectionDiff(selectedIds, savedSelectedIds); - }, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedSelectedIds]); + }, [ + summary, + savedSummary, + jobDescription, + savedDescription, + selectedIds, + savedSelectedIds, + ]); useEffect(() => { onDirtyChange?.(isDirty); @@ -123,7 +134,10 @@ export const TailoringEditor: React.FC = ({ [], ); - const selectedIdsCsv = useMemo(() => Array.from(selectedIds).join(","), [selectedIds]); + const selectedIdsCsv = useMemo( + () => Array.from(selectedIds).join(","), + [selectedIds], + ); const saveChanges = useCallback( async ({ showToast = true }: { showToast?: boolean } = {}) => { @@ -144,7 +158,15 @@ export const TailoringEditor: React.FC = ({ setIsSaving(false); } }, - [job.id, onUpdate, selectedIdsCsv, selectedIds, summary, jobDescription, syncSavedSnapshot], + [ + job.id, + onUpdate, + selectedIdsCsv, + selectedIds, + summary, + jobDescription, + syncSavedSnapshot, + ], ); useEffect(() => { @@ -259,9 +281,7 @@ export const TailoringEditor: React.FC = ({ onChange={(e) => setJobDescription(e.target.value)} onFocus={() => setActiveField("description")} onBlur={() => - setActiveField((prev) => - prev === "description" ? null : prev, - ) + setActiveField((prev) => (prev === "description" ? null : prev)) } placeholder="The raw job description..." /> diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx index 33baf6e..caeadc1 100644 --- a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx @@ -1,6 +1,6 @@ import type { Job } from "@shared/types.js"; import type React from "react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import * as api from "../../api"; import { useRescoreJob } from "../../hooks/useRescoreJob"; @@ -27,9 +27,13 @@ export const DiscoveredPanel: React.FC = ({ const [mode, setMode] = useState("decide"); const [isSkipping, setIsSkipping] = useState(false); const [isFinalizing, setIsFinalizing] = useState(false); + const previousJobIdRef = useRef(null); const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated); useEffect(() => { + const currentJobId = job?.id ?? null; + if (previousJobIdRef.current === currentJobId) return; + previousJobIdRef.current = currentJobId; setMode("decide"); setIsSkipping(false); setIsFinalizing(false); diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx index 950768d..6180547 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx @@ -57,7 +57,9 @@ describe("TailorMode", () => { />, ); - expect(screen.getByLabelText("Tailored Summary")).toHaveValue("Local draft"); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Local draft", + ); }); it("resets local state when job id changes", async () => { @@ -91,7 +93,9 @@ describe("TailorMode", () => { />, ); - expect(screen.getByLabelText("Tailored Summary")).toHaveValue("New job summary"); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "New job summary", + ); }); it("does not sync same-job props while summary field is focused", async () => { diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.tsx index e5d3ead..73fc90a 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.tsx @@ -40,26 +40,30 @@ export const TailorMode: React.FC = ({ }) => { const [catalog, setCatalog] = useState([]); const [summary, setSummary] = useState(job.tailoredSummary || ""); - const [jobDescription, setJobDescription] = useState(job.jobDescription || ""); + const [jobDescription, setJobDescription] = useState( + job.jobDescription || "", + ); const [selectedIds, setSelectedIds] = useState>(() => parseSelectedIds(job.selectedProjectIds), ); const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || ""); - const [savedDescription, setSavedDescription] = useState(job.jobDescription || ""); + const [savedDescription, setSavedDescription] = useState( + job.jobDescription || "", + ); const [savedSelectedIds, setSavedSelectedIds] = useState>(() => parseSelectedIds(job.selectedProjectIds), ); const [isGenerating, setIsGenerating] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [draftStatus, setDraftStatus] = useState<"unsaved" | "saving" | "saved">( - "saved", - ); + const [draftStatus, setDraftStatus] = useState< + "unsaved" | "saving" | "saved" + >("saved"); const [showDescription, setShowDescription] = useState(false); - const [activeField, setActiveField] = useState<"summary" | "description" | null>( - null, - ); + const [activeField, setActiveField] = useState< + "summary" | "description" | null + >(null); const lastJobIdRef = useRef(job.id); useEffect(() => { @@ -70,7 +74,14 @@ export const TailorMode: React.FC = ({ if (summary !== savedSummary) return true; if (jobDescription !== savedDescription) return true; return hasSelectionDiff(selectedIds, savedSelectedIds); - }, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedSelectedIds]); + }, [ + summary, + savedSummary, + jobDescription, + savedDescription, + selectedIds, + savedSelectedIds, + ]); useEffect(() => { onDirtyChange?.(isDirty); @@ -124,7 +135,10 @@ export const TailorMode: React.FC = ({ } }, [isDirty, draftStatus]); - const selectedIdsCsv = useMemo(() => Array.from(selectedIds).join(","), [selectedIds]); + const selectedIdsCsv = useMemo( + () => Array.from(selectedIds).join(","), + [selectedIds], + ); const syncSavedSnapshot = useCallback( ( @@ -147,7 +161,14 @@ export const TailorMode: React.FC = ({ selectedProjectIds: selectedIdsCsv, }); syncSavedSnapshot(summary, jobDescription, selectedIds); - }, [job.id, summary, jobDescription, selectedIdsCsv, selectedIds, syncSavedSnapshot]); + }, [ + job.id, + summary, + jobDescription, + selectedIdsCsv, + selectedIds, + syncSavedSnapshot, + ]); useEffect(() => { if (!isDirty || draftStatus !== "unsaved") return; @@ -314,9 +335,7 @@ export const TailorMode: React.FC = ({ onChange={(event) => setJobDescription(event.target.value)} onFocus={() => setActiveField("description")} onBlur={() => - setActiveField((prev) => - prev === "description" ? null : prev, - ) + setActiveField((prev) => (prev === "description" ? null : prev)) } placeholder="The raw job description..." disabled={disableInputs} diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 9e4448a..bea49d3 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -14,11 +14,13 @@ import * as api from "../api"; import { ManualImportSheet } from "../components"; import type { FilterTab, JobSort } from "./orchestrator/constants"; import { DEFAULT_SORT } from "./orchestrator/constants"; +import { FloatingBulkActionsBar } from "./orchestrator/FloatingBulkActionsBar"; import { JobDetailPanel } from "./orchestrator/JobDetailPanel"; import { JobListPanel } from "./orchestrator/JobListPanel"; import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters"; import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader"; import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary"; +import { useBulkJobSelection } from "./orchestrator/useBulkJobSelection"; import { useFilteredJobs } from "./orchestrator/useFilteredJobs"; import { useOrchestratorData } from "./orchestrator/useOrchestratorData"; import { usePipelineSources } from "./orchestrator/usePipelineSources"; @@ -184,6 +186,20 @@ export const OrchestratorPage: React.FC = () => { : null, [jobs, selectedJobId], ); + const { + selectedJobIds, + canSkipSelected, + canMoveSelected, + bulkActionInFlight, + toggleSelectJob, + toggleSelectAll, + clearSelection, + runBulkAction, + } = useBulkJobSelection({ + activeJobs, + activeTab, + loadJobs, + }); useEffect(() => { if (isLoading || sourceFilter === "all") return; @@ -335,9 +351,12 @@ export const OrchestratorPage: React.FC = () => { jobs={jobs} activeJobs={activeJobs} selectedJobId={selectedJobId} + selectedJobIds={selectedJobIds} activeTab={activeTab} searchQuery={searchQuery} onSelectJob={handleSelectJob} + onToggleSelectJob={toggleSelectJob} + onToggleSelectAll={toggleSelectAll} /> {/* Inspector panel: visually subordinate to list */} @@ -357,6 +376,16 @@ export const OrchestratorPage: React.FC = () => { + void runBulkAction("move_to_ready")} + onSkipSelected={() => void runBulkAction("skip")} + onClear={clearSelection} + /> + void; + onSkipSelected: () => void; + onClear: () => void; +} + +export const FloatingBulkActionsBar: React.FC = ({ + selectedCount, + canMoveSelected, + canSkipSelected, + bulkActionInFlight, + onMoveToReady, + onSkipSelected, + onClear, +}) => { + const [isMounted, setIsMounted] = useState(false); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + if (selectedCount > 0) { + setIsMounted(true); + const enterTimer = window.setTimeout(() => setIsVisible(true), 10); + return () => window.clearTimeout(enterTimer); + } + + setIsVisible(false); + const exitTimer = window.setTimeout(() => setIsMounted(false), 180); + return () => window.clearTimeout(exitTimer); + }, [selectedCount]); + + if (!isMounted) return null; + + return ( +
+
+
+ {selectedCount} selected +
+ {canMoveSelected && ( + + )} + {canSkipSelected && ( + + )} + +
+
+ ); +}; diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx index 2efad58..78f395d 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx @@ -1,5 +1,11 @@ import type { Job } from "@shared/types.js"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import type React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../../api"; @@ -181,45 +187,39 @@ describe("JobDetailPanel", () => { it("renders the discovered panel when active tab is discovered", async () => { const job = createJob({ id: "job-99", status: "discovered" }); - await renderJobDetailPanel( - { - activeTab: "discovered", - activeJobs: [job], - selectedJob: job, - onSelectJobId: vi.fn(), - onJobUpdated: vi.fn().mockResolvedValue(undefined), - }, - ); + await renderJobDetailPanel({ + activeTab: "discovered", + activeJobs: [job], + selectedJob: job, + onSelectJobId: vi.fn(), + onJobUpdated: vi.fn().mockResolvedValue(undefined), + }); expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99"); }); it("shows an empty state when no job is selected", async () => { - await renderJobDetailPanel( - { - activeTab: "all", - activeJobs: [], - selectedJob: null, - onSelectJobId: vi.fn(), - onJobUpdated: vi.fn().mockResolvedValue(undefined), - }, - ); + await renderJobDetailPanel({ + activeTab: "all", + activeJobs: [], + selectedJob: null, + onSelectJobId: vi.fn(), + onJobUpdated: vi.fn().mockResolvedValue(undefined), + }); expect(screen.getByText("No job selected")).toBeInTheDocument(); }); it("renders a stripped description preview for html content", async () => { - await renderJobDetailPanel( - { - activeTab: "all", - activeJobs: [], - selectedJob: createJob({ - jobDescription: "

Hello world

", - }), - onSelectJobId: vi.fn(), - onJobUpdated: vi.fn().mockResolvedValue(undefined), - }, - ); + await renderJobDetailPanel({ + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ + jobDescription: "

Hello world

", + }), + onSelectJobId: vi.fn(), + onJobUpdated: vi.fn().mockResolvedValue(undefined), + }); expect(screen.getByText("Hello world")).toBeInTheDocument(); }); @@ -228,15 +228,13 @@ describe("JobDetailPanel", () => { const onJobUpdated = vi.fn().mockResolvedValue(undefined); vi.mocked(api.updateJob).mockResolvedValue(undefined as any); - await renderJobDetailPanel( - { - activeTab: "all", - activeJobs: [], - selectedJob: createJob({ jobDescription: "Original" }), - onSelectJobId: vi.fn(), - onJobUpdated, - }, - ); + await renderJobDetailPanel({ + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ jobDescription: "Original" }), + onSelectJobId: vi.fn(), + onJobUpdated, + }); fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i })); fireEvent.click(await screen.findByRole("button", { name: /^edit$/i })); @@ -259,15 +257,13 @@ describe("JobDetailPanel", () => { const onJobUpdated = vi.fn().mockResolvedValue(undefined); vi.mocked(api.markAsApplied).mockResolvedValue(undefined as any); - await renderJobDetailPanel( - { - activeTab: "all", - activeJobs: [], - selectedJob: createJob({ status: "ready" }), - onSelectJobId: vi.fn(), - onJobUpdated, - }, - ); + await renderJobDetailPanel({ + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ status: "ready" }), + onSelectJobId: vi.fn(), + onJobUpdated, + }); fireEvent.click(screen.getByRole("button", { name: /applied/i })); @@ -281,15 +277,13 @@ describe("JobDetailPanel", () => { const onJobUpdated = vi.fn().mockResolvedValue(undefined); vi.mocked(api.skipJob).mockResolvedValue(undefined as any); - await renderJobDetailPanel( - { - activeTab: "all", - activeJobs: [], - selectedJob: createJob({ status: "ready" }), - onSelectJobId: vi.fn(), - onJobUpdated, - }, - ); + await renderJobDetailPanel({ + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ status: "ready" }), + onSelectJobId: vi.fn(), + onJobUpdated, + }); fireEvent.pointerDown( screen.getByRole("button", { name: /more actions/i }), @@ -304,16 +298,14 @@ describe("JobDetailPanel", () => { it("forwards tailoring dirty state to refresh pause callback", async () => { const onPauseRefreshChange = vi.fn(); - await renderJobDetailPanel( - { - activeTab: "all", - activeJobs: [], - selectedJob: createJob({ status: "ready" }), - onSelectJobId: vi.fn(), - onJobUpdated: vi.fn().mockResolvedValue(undefined), - onPauseRefreshChange, - }, - ); + await renderJobDetailPanel({ + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ status: "ready" }), + onSelectJobId: vi.fn(), + onJobUpdated: vi.fn().mockResolvedValue(undefined), + onPauseRefreshChange, + }); fireEvent.mouseDown(screen.getByRole("tab", { name: /tailoring/i })); fireEvent.click(await screen.findByText("Mark tailoring dirty")); diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index 1cb71a8..e5ad6ad 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -70,6 +70,7 @@ export const JobDetailPanel: React.FC = ({ const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false); const [processingJobId, setProcessingJobId] = useState(null); const saveTailoringRef = useRef Promise)>(null); + const previousSelectedJobIdRef = useRef(null); const { personName } = useProfile(); @@ -82,6 +83,9 @@ export const JobDetailPanel: React.FC = ({ ); useEffect(() => { + const currentJobId = selectedJob?.id ?? null; + if (previousSelectedJobIdRef.current === currentJobId) return; + previousSelectedJobIdRef.current = currentJobId; setHasUnsavedTailoring(false); saveTailoringRef.current = null; onPauseRefreshChange?.(false); diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx index 9db215f..9eebb14 100644 --- a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx @@ -74,9 +74,12 @@ describe("JobListPanel", () => { jobs={[]} activeJobs={[]} selectedJobId={null} + selectedJobIds={new Set()} activeTab="ready" searchQuery="" onSelectJob={vi.fn()} + onToggleSelectJob={vi.fn()} + onToggleSelectAll={vi.fn()} />, ); @@ -90,9 +93,12 @@ describe("JobListPanel", () => { jobs={[]} activeJobs={[]} selectedJobId={null} + selectedJobIds={new Set()} activeTab="ready" searchQuery="" onSelectJob={vi.fn()} + onToggleSelectJob={vi.fn()} + onToggleSelectAll={vi.fn()} />, ); @@ -109,9 +115,12 @@ describe("JobListPanel", () => { jobs={[]} activeJobs={[]} selectedJobId={null} + selectedJobIds={new Set()} activeTab="ready" searchQuery="iOS" onSelectJob={vi.fn()} + onToggleSelectJob={vi.fn()} + onToggleSelectAll={vi.fn()} />, ); @@ -120,6 +129,8 @@ describe("JobListPanel", () => { it("renders jobs and notifies when a job is selected", () => { const onSelectJob = vi.fn(); + const onToggleSelectJob = vi.fn(); + const onToggleSelectAll = vi.fn(); const jobs = [ createJob({ id: "job-1", title: "Backend Engineer" }), createJob({ @@ -135,9 +146,12 @@ describe("JobListPanel", () => { jobs={jobs} activeJobs={jobs} selectedJobId="job-1" + selectedJobIds={new Set()} activeTab="ready" searchQuery="" onSelectJob={onSelectJob} + onToggleSelectJob={onToggleSelectJob} + onToggleSelectAll={onToggleSelectAll} />, ); @@ -148,4 +162,34 @@ describe("JobListPanel", () => { fireEvent.click(screen.getByRole("button", { name: /Frontend Engineer/i })); expect(onSelectJob).toHaveBeenCalledWith("job-2"); }); + + it("toggles row selection and select-all", () => { + const onToggleSelectJob = vi.fn(); + const onToggleSelectAll = vi.fn(); + const jobs = [ + createJob({ id: "job-1", title: "Backend Engineer" }), + createJob({ id: "job-2", title: "Frontend Engineer" }), + ]; + + render( + , + ); + + fireEvent.click(screen.getByLabelText("Select Backend Engineer")); + expect(onToggleSelectJob).toHaveBeenCalledWith("job-1"); + + fireEvent.click(screen.getByLabelText("Select all filtered jobs")); + expect(onToggleSelectAll).toHaveBeenCalledWith(true); + }); }); diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx index b0e1a1f..1ff71b1 100644 --- a/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx @@ -1,6 +1,7 @@ import type { Job } from "@shared/types.js"; import { Loader2 } from "lucide-react"; import type React from "react"; +import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import type { FilterTab } from "./constants"; import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants"; @@ -10,9 +11,12 @@ interface JobListPanelProps { jobs: Job[]; activeJobs: Job[]; selectedJobId: string | null; + selectedJobIds: Set; activeTab: FilterTab; searchQuery: string; onSelectJob: (jobId: string) => void; + onToggleSelectJob: (jobId: string) => void; + onToggleSelectAll: (checked: boolean) => void; } export const JobListPanel: React.FC = ({ @@ -20,9 +24,12 @@ export const JobListPanel: React.FC = ({ jobs, activeJobs, selectedJobId, + selectedJobIds, activeTab, searchQuery, onSelectJob, + onToggleSelectJob, + onToggleSelectAll, }) => (
{isLoading && jobs.length === 0 ? ( @@ -41,24 +48,64 @@ export const JobListPanel: React.FC = ({
) : (
+
+ + + {selectedJobIds.size} selected + +
{activeJobs.map((job) => { const isSelected = job.id === selectedJobId; + const isChecked = selectedJobIds.has(job.id); const hasScore = job.suitabilityScore != null; const statusToken = statusTokens[job.status] ?? defaultStatusToken; return ( - + + {/* Single triage cue: score only (status shown via dot) */} + {hasScore && ( +
+ = 70 + ? "text-emerald-400/90" + : (job.suitabilityScore ?? 0) >= 50 + ? "text-foreground/60" + : "text-muted-foreground/60", + )} + > + {job.suitabilityScore} + +
+ )} + +
); })} diff --git a/orchestrator/src/client/pages/orchestrator/bulkActions.test.ts b/orchestrator/src/client/pages/orchestrator/bulkActions.test.ts new file mode 100644 index 0000000..b4bc33a --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/bulkActions.test.ts @@ -0,0 +1,112 @@ +import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js"; +import { describe, expect, it } from "vitest"; +import { + canBulkMoveToReady, + canBulkSkip, + getFailedJobIds, +} from "./bulkActions"; + +function createJob(id: string, status: JobStatus): Job { + return { + id, + source: "linkedin", + sourceJobId: null, + jobUrlDirect: null, + datePosted: null, + title: "Role", + employer: "Acme", + employerUrl: null, + jobUrl: `https://example.com/${id}`, + applicationLink: null, + disciplines: null, + deadline: null, + salary: null, + location: null, + degreeRequired: null, + starting: null, + jobDescription: null, + status, + outcome: null, + closedAt: null, + suitabilityScore: null, + suitabilityReason: null, + tailoredSummary: null, + tailoredHeadline: null, + tailoredSkills: null, + selectedProjectIds: null, + pdfPath: null, + notionPageId: null, + sponsorMatchScore: null, + sponsorMatchNames: null, + jobType: null, + salarySource: null, + salaryInterval: null, + salaryMinAmount: null, + salaryMaxAmount: null, + salaryCurrency: null, + isRemote: null, + jobLevel: null, + jobFunction: null, + listingType: null, + emails: null, + companyIndustry: null, + companyLogo: null, + companyUrlDirect: null, + companyAddresses: null, + companyNumEmployees: null, + companyRevenue: null, + companyDescription: null, + skills: null, + experienceRange: null, + companyRating: null, + companyReviewsCount: null, + vacancyCount: null, + workFromHomeType: null, + discoveredAt: "2025-01-01T00:00:00Z", + processedAt: null, + appliedAt: null, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", + }; +} + +describe("bulkActions", () => { + it("computes eligibility for skip and move-to-ready", () => { + expect( + canBulkSkip([createJob("1", "discovered"), createJob("2", "ready")]), + ).toBe(true); + expect(canBulkSkip([createJob("1", "applied")])).toBe(false); + + expect( + canBulkMoveToReady([ + createJob("1", "discovered"), + createJob("2", "discovered"), + ]), + ).toBe(true); + expect(canBulkMoveToReady([createJob("1", "ready")])).toBe(false); + }); + + it("extracts failed job ids from a bulk response", () => { + const response: BulkJobActionResponse = { + action: "skip", + requested: 3, + succeeded: 1, + failed: 2, + results: [ + { jobId: "job-1", ok: true, job: createJob("job-1", "skipped") }, + { + jobId: "job-2", + ok: false, + error: { code: "INVALID_REQUEST", message: "bad status" }, + }, + { + jobId: "job-3", + ok: false, + error: { code: "NOT_FOUND", message: "missing" }, + }, + ], + }; + + expect(Array.from(getFailedJobIds(response))).toEqual(["job-2", "job-3"]); + }); +}); diff --git a/orchestrator/src/client/pages/orchestrator/bulkActions.ts b/orchestrator/src/client/pages/orchestrator/bulkActions.ts new file mode 100644 index 0000000..c63498d --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/bulkActions.ts @@ -0,0 +1,20 @@ +import type { BulkJobActionResponse, Job } from "@shared/types"; + +const SKIPPABLE_STATUSES = new Set(["discovered", "ready"]); + +export function canBulkSkip(jobs: Job[]): boolean { + return ( + jobs.length > 0 && jobs.every((job) => SKIPPABLE_STATUSES.has(job.status)) + ); +} + +export function canBulkMoveToReady(jobs: Job[]): boolean { + return jobs.length > 0 && jobs.every((job) => job.status === "discovered"); +} + +export function getFailedJobIds(response: BulkJobActionResponse): Set { + const failedIds = response.results + .filter((result) => !result.ok) + .map((result) => result.jobId); + return new Set(failedIds); +} diff --git a/orchestrator/src/client/pages/orchestrator/useBulkJobSelection.test.ts b/orchestrator/src/client/pages/orchestrator/useBulkJobSelection.test.ts new file mode 100644 index 0000000..908bd43 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/useBulkJobSelection.test.ts @@ -0,0 +1,201 @@ +import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "../../api"; +import { useBulkJobSelection } from "./useBulkJobSelection"; + +vi.mock("../../api", () => ({ + bulkJobAction: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +function createJob(id: string, status: JobStatus): Job { + return { + id, + source: "linkedin", + sourceJobId: null, + jobUrlDirect: null, + datePosted: null, + title: `Role ${id}`, + employer: "Acme", + employerUrl: null, + jobUrl: `https://example.com/${id}`, + applicationLink: null, + disciplines: null, + deadline: null, + salary: null, + location: null, + degreeRequired: null, + starting: null, + jobDescription: null, + status, + outcome: null, + closedAt: null, + suitabilityScore: null, + suitabilityReason: null, + tailoredSummary: null, + tailoredHeadline: null, + tailoredSkills: null, + selectedProjectIds: null, + pdfPath: null, + notionPageId: null, + sponsorMatchScore: null, + sponsorMatchNames: null, + jobType: null, + salarySource: null, + salaryInterval: null, + salaryMinAmount: null, + salaryMaxAmount: null, + salaryCurrency: null, + isRemote: null, + jobLevel: null, + jobFunction: null, + listingType: null, + emails: null, + companyIndustry: null, + companyLogo: null, + companyUrlDirect: null, + companyAddresses: null, + companyNumEmployees: null, + companyRevenue: null, + companyDescription: null, + skills: null, + experienceRange: null, + companyRating: null, + companyReviewsCount: null, + vacancyCount: null, + workFromHomeType: null, + discoveredAt: "2025-01-01T00:00:00Z", + processedAt: null, + appliedAt: null, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", + }; +} + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +const deferred = (): Deferred => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +describe("useBulkJobSelection", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("caps select-all to the API max", () => { + const activeJobs = Array.from({ length: 101 }, (_, index) => + createJob(`job-${index + 1}`, "discovered"), + ); + const loadJobs = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => + useBulkJobSelection({ + activeJobs, + activeTab: "discovered", + loadJobs, + }), + ); + + act(() => { + result.current.toggleSelectAll(true); + }); + + expect(result.current.selectedJobIds.size).toBe(100); + }); + + it("does not send bulk requests above the max selection size", async () => { + const activeJobs = Array.from({ length: 101 }, (_, index) => + createJob(`job-${index + 1}`, "discovered"), + ); + const loadJobs = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => + useBulkJobSelection({ + activeJobs, + activeTab: "discovered", + loadJobs, + }), + ); + + act(() => { + for (const job of activeJobs) { + result.current.toggleSelectJob(job.id); + } + }); + + await act(async () => { + await result.current.runBulkAction("skip"); + }); + + expect(api.bulkJobAction).not.toHaveBeenCalled(); + }); + + it("reconciles failures with selection changes made during in-flight action", async () => { + const activeJobs = [ + createJob("job-1", "discovered"), + createJob("job-2", "discovered"), + createJob("job-3", "discovered"), + ]; + const loadJobs = vi.fn().mockResolvedValue(undefined); + const pending = deferred(); + vi.mocked(api.bulkJobAction).mockImplementation(() => pending.promise); + + const { result } = renderHook(() => + useBulkJobSelection({ + activeJobs, + activeTab: "discovered", + loadJobs, + }), + ); + + act(() => { + result.current.toggleSelectJob("job-1"); + result.current.toggleSelectJob("job-2"); + }); + + let runPromise: Promise; + await act(async () => { + runPromise = result.current.runBulkAction("skip"); + }); + + act(() => { + result.current.toggleSelectJob("job-2"); + result.current.toggleSelectJob("job-3"); + }); + + await act(async () => { + pending.resolve({ + action: "skip", + requested: 2, + succeeded: 1, + failed: 1, + results: [ + { jobId: "job-1", ok: true, job: createJob("job-1", "skipped") }, + { + jobId: "job-2", + ok: false, + error: { code: "INVALID_REQUEST", message: "bad status" }, + }, + ], + }); + await runPromise; + }); + + await waitFor(() => { + expect(Array.from(result.current.selectedJobIds)).toEqual(["job-3"]); + }); + }); +}); diff --git a/orchestrator/src/client/pages/orchestrator/useBulkJobSelection.ts b/orchestrator/src/client/pages/orchestrator/useBulkJobSelection.ts new file mode 100644 index 0000000..8b10868 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/useBulkJobSelection.ts @@ -0,0 +1,163 @@ +import type { BulkJobAction, Job } from "@shared/types.js"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import * as api from "../../api"; +import { + canBulkMoveToReady, + canBulkSkip, + getFailedJobIds, +} from "./bulkActions"; +import type { FilterTab } from "./constants"; + +const MAX_BULK_ACTION_JOB_IDS = 100; + +interface UseBulkJobSelectionArgs { + activeJobs: Job[]; + activeTab: FilterTab; + loadJobs: () => Promise; +} + +export function useBulkJobSelection({ + activeJobs, + activeTab, + loadJobs, +}: UseBulkJobSelectionArgs) { + const [selectedJobIds, setSelectedJobIds] = useState>( + () => new Set(), + ); + const [bulkActionInFlight, setBulkActionInFlight] = + useState(null); + const previousActiveTabRef = useRef(activeTab); + + const selectedJobs = useMemo( + () => activeJobs.filter((job) => selectedJobIds.has(job.id)), + [activeJobs, selectedJobIds], + ); + + const canSkipSelected = useMemo( + () => canBulkSkip(selectedJobs), + [selectedJobs], + ); + const canMoveSelected = useMemo( + () => canBulkMoveToReady(selectedJobs), + [selectedJobs], + ); + + useEffect(() => { + if (previousActiveTabRef.current === activeTab) return; + previousActiveTabRef.current = activeTab; + setSelectedJobIds(new Set()); + }, [activeTab]); + + useEffect(() => { + const activeJobIdSet = new Set(activeJobs.map((job) => job.id)); + setSelectedJobIds((previous) => { + if (previous.size === 0) return previous; + const next = new Set( + Array.from(previous).filter((jobId) => activeJobIdSet.has(jobId)), + ); + return next.size === previous.size ? previous : next; + }); + }, [activeJobs]); + + const toggleSelectJob = useCallback((jobId: string) => { + setSelectedJobIds((previous) => { + const next = new Set(previous); + if (next.has(jobId)) { + next.delete(jobId); + } else { + next.add(jobId); + } + return next; + }); + }, []); + + const toggleSelectAll = useCallback( + (checked: boolean) => { + setSelectedJobIds(() => { + if (!checked) return new Set(); + const allIds = activeJobs.map((job) => job.id); + if (allIds.length <= MAX_BULK_ACTION_JOB_IDS) { + return new Set(allIds); + } + toast.error( + `Select all is limited to ${MAX_BULK_ACTION_JOB_IDS} jobs per action.`, + ); + return new Set(allIds.slice(0, MAX_BULK_ACTION_JOB_IDS)); + }); + }, + [activeJobs], + ); + + const clearSelection = useCallback(() => { + setSelectedJobIds(new Set()); + }, []); + + const runBulkAction = useCallback( + async (action: BulkJobAction) => { + const selectedAtStart = Array.from(selectedJobIds); + if (selectedAtStart.length === 0) return; + if (selectedAtStart.length > MAX_BULK_ACTION_JOB_IDS) { + toast.error( + `You can run bulk actions on up to ${MAX_BULK_ACTION_JOB_IDS} jobs at a time.`, + ); + return; + } + + const selectedAtStartSet = new Set(selectedAtStart); + try { + setBulkActionInFlight(action); + const result = await api.bulkJobAction({ + action, + jobIds: selectedAtStart, + }); + + const failedIds = getFailedJobIds(result); + const successLabel = + action === "skip" ? "jobs skipped" : "jobs moved to Ready"; + + if (result.failed === 0) { + toast.success(`${result.succeeded} ${successLabel}`); + } else { + toast.error( + `${result.succeeded} succeeded, ${result.failed} failed.`, + ); + } + + await loadJobs(); + setSelectedJobIds((current) => { + const addedDuringRequest = Array.from(current).filter( + (jobId) => !selectedAtStartSet.has(jobId), + ); + const removedDuringRequest = Array.from(selectedAtStartSet).filter( + (jobId) => !current.has(jobId), + ); + const next = new Set([ + ...Array.from(failedIds), + ...addedDuringRequest, + ]); + for (const jobId of removedDuringRequest) next.delete(jobId); + return next; + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to run bulk action"; + toast.error(message); + } finally { + setBulkActionInFlight(null); + } + }, + [selectedJobIds, loadJobs], + ); + + return { + selectedJobIds, + canSkipSelected, + canMoveSelected, + bulkActionInFlight, + toggleSelectJob, + toggleSelectAll, + clearSelection, + runBulkAction, + }; +} diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.test.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.test.ts index 02f3594..0c25c9f 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.test.ts +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.test.ts @@ -32,7 +32,7 @@ type Deferred = { resolve: (value: T) => void; }; -const deferred = (): Deferred => { +const deferred = (): Deferred => { let resolve!: (value: T) => void; const promise = new Promise((res) => { resolve = res; @@ -45,7 +45,9 @@ describe("useOrchestratorData", () => { vi.clearAllMocks(); vi.useRealTimers(); vi.mocked(api.getJobs).mockResolvedValue(makeResponse("initial") as any); - vi.mocked(api.getPipelineStatus).mockResolvedValue({ isRunning: false } as any); + vi.mocked(api.getPipelineStatus).mockResolvedValue({ + isRunning: false, + } as any); }); it("applies newest loadJobs response when requests resolve out of order", async () => { diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts index e78298a..964501f 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts @@ -38,7 +38,10 @@ export const useOrchestratorData = () => { error instanceof Error ? error.message : "Failed to load jobs"; toast.error(message); } finally { - pendingLoadCountRef.current = Math.max(0, pendingLoadCountRef.current - 1); + pendingLoadCountRef.current = Math.max( + 0, + pendingLoadCountRef.current - 1, + ); if (pendingLoadCountRef.current === 0) { setIsLoading(false); } diff --git a/orchestrator/src/server/api/routes/jobs.test.ts b/orchestrator/src/server/api/routes/jobs.test.ts index 733e13c..3c4b0cf 100644 --- a/orchestrator/src/server/api/routes/jobs.test.ts +++ b/orchestrator/src/server/api/routes/jobs.test.ts @@ -72,6 +72,115 @@ describe.sequential("Jobs API routes", () => { expect(deleteBody.data.count).toBe(1); }); + it("runs bulk skip with partial failures", async () => { + const { createJob } = await import("../../repositories/jobs"); + const discovered = await createJob({ + source: "manual", + title: "Discovered Role", + employer: "Acme", + jobUrl: "https://example.com/job/bulk-discovered", + jobDescription: "Test description", + }); + const ready = await createJob({ + source: "manual", + title: "Ready Role", + employer: "Beta", + jobUrl: "https://example.com/job/bulk-ready", + jobDescription: "Test description", + }); + const applied = await createJob({ + source: "manual", + title: "Applied Role", + employer: "Gamma", + jobUrl: "https://example.com/job/bulk-applied", + jobDescription: "Test description", + }); + const { updateJob } = await import("../../repositories/jobs"); + await updateJob(ready.id, { status: "ready" }); + await updateJob(applied.id, { status: "applied" }); + + const res = await fetch(`${baseUrl}/api/jobs/bulk-actions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "skip", + jobIds: [discovered.id, ready.id, applied.id, "missing-id"], + }), + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.meta.requestId).toBeTruthy(); + expect(body.data.requested).toBe(4); + expect(body.data.succeeded).toBe(2); + expect(body.data.failed).toBe(2); + const failures = body.data.results.filter((r: any) => !r.ok); + expect(failures).toHaveLength(2); + expect(failures.map((r: any) => r.error.code).sort()).toEqual([ + "INVALID_REQUEST", + "NOT_FOUND", + ]); + }); + + it("runs bulk move_to_ready and rejects ineligible statuses", async () => { + const { createJob, updateJob } = await import("../../repositories/jobs"); + const discovered = await createJob({ + source: "manual", + title: "New Role", + employer: "Acme", + jobUrl: "https://example.com/job/bulk-ready-1", + jobDescription: "Test description", + }); + const ready = await createJob({ + source: "manual", + title: "Already Ready", + employer: "Acme", + jobUrl: "https://example.com/job/bulk-ready-2", + jobDescription: "Test description", + }); + await updateJob(ready.id, { status: "ready" }); + const { processJob } = await import("../../pipeline/index"); + + const res = await fetch(`${baseUrl}/api/jobs/bulk-actions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "move_to_ready", + jobIds: [discovered.id, ready.id], + }), + }); + const body = await res.json(); + + expect(body.ok).toBe(true); + expect(body.data.succeeded).toBe(1); + expect(body.data.failed).toBe(1); + expect(vi.mocked(processJob)).toHaveBeenCalledWith(discovered.id); + expect( + body.data.results.find((r: any) => r.jobId === ready.id).error.code, + ).toBe("INVALID_REQUEST"); + }); + + it("validates bulk action payloads", async () => { + const tooManyIds = Array.from( + { length: 101 }, + (_, index) => `job-${index}`, + ); + const res = await fetch(`${baseUrl}/api/jobs/bulk-actions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "skip", + jobIds: tooManyIds, + }), + }); + const body = await res.json(); + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("INVALID_REQUEST"); + expect(body.meta.requestId).toBeTruthy(); + }); + it("applies a job and syncs to Notion", async () => { const { createNotionEntry } = await import("../../services/notion"); vi.mocked(createNotionEntry).mockResolvedValue({ diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts index f33e5bb..61be704 100644 --- a/orchestrator/src/server/api/routes/jobs.ts +++ b/orchestrator/src/server/api/routes/jobs.ts @@ -1,10 +1,13 @@ -import { okWithMeta } from "@infra/http"; +import { fail, ok, okWithMeta } from "@infra/http"; import { logger } from "@infra/logger"; import { sanitizeWebhookPayload } from "@infra/sanitize"; import { APPLICATION_OUTCOMES, APPLICATION_STAGES, type ApiResponse, + type BulkJobAction, + type BulkJobActionResponse, + type BulkJobActionResult, type Job, type JobStatus, type JobsListResponse, @@ -12,6 +15,7 @@ import { import { type Request, type Response, Router } from "express"; import { z } from "zod"; import { isDemoMode, sendDemoBlocked } from "../../config/demo"; +import { AppError, badRequest } from "../../infra/errors"; import { generateFinalPdf, processJob, @@ -136,6 +140,120 @@ const updateOutcomeSchema = z.object({ closedAt: z.number().int().nullable().optional(), }); +const bulkActionRequestSchema = z.object({ + action: z.enum(["skip", "move_to_ready"]), + jobIds: z.array(z.string().min(1)).min(1).max(100), +}); + +const SKIPPABLE_STATUSES: ReadonlySet = new Set([ + "discovered", + "ready", +]); + +function mapErrorForResult(error: unknown): { + code: string; + message: string; + details?: unknown; +} { + if (error instanceof AppError) { + return { + code: error.code, + message: error.message, + ...(error.details !== undefined ? { details: error.details } : {}), + }; + } + + if (error instanceof Error) { + return { + code: "INTERNAL_ERROR", + message: error.message || "Unknown error", + }; + } + + return { + code: "INTERNAL_ERROR", + message: "Unknown error", + }; +} + +async function executeBulkActionForJob( + action: BulkJobAction, + jobId: string, +): Promise { + try { + const job = await jobsRepo.getJobById(jobId); + if (!job) { + throw new AppError({ + status: 404, + code: "NOT_FOUND", + message: "Job not found", + }); + } + + if (action === "skip") { + if (!SKIPPABLE_STATUSES.has(job.status)) { + throw badRequest(`Job is not skippable from status "${job.status}"`, { + jobId, + status: job.status, + allowedStatuses: ["discovered", "ready"], + }); + } + + const updated = await jobsRepo.updateJob(jobId, { status: "skipped" }); + if (!updated) { + throw new AppError({ + status: 404, + code: "NOT_FOUND", + message: "Job not found", + }); + } + + return { jobId, ok: true, job: updated }; + } + + if (job.status !== "discovered") { + throw badRequest( + `Job is not movable to Ready from status "${job.status}"`, + { + jobId, + status: job.status, + requiredStatus: "discovered", + }, + ); + } + + const processed = await processJob(jobId); + if (!processed.success) { + throw new AppError({ + status: 500, + code: "INTERNAL_ERROR", + message: processed.error || "Failed to process job", + }); + } + + const updated = await jobsRepo.getJobById(jobId); + if (!updated) { + throw new AppError({ + status: 404, + code: "NOT_FOUND", + message: "Job not found after processing", + }); + } + + return { jobId, ok: true, job: updated }; + } catch (error) { + const mapped = mapErrorForResult(error); + return { + jobId, + ok: false, + error: { + code: mapped.code, + message: mapped.message, + }, + }; + } +} + /** * GET /api/jobs - List all jobs * Query params: status (comma-separated list of statuses to filter) @@ -166,6 +284,62 @@ jobsRouter.get("/", async (req: Request, res: Response) => { } }); +/** + * POST /api/jobs/bulk-actions - Run a bulk action across selected jobs + */ +jobsRouter.post("/bulk-actions", async (req: Request, res: Response) => { + try { + const parsed = bulkActionRequestSchema.parse(req.body); + const dedupedJobIds = Array.from(new Set(parsed.jobIds)); + + const results: BulkJobActionResult[] = []; + for (const jobId of dedupedJobIds) { + const result = await executeBulkActionForJob(parsed.action, jobId); + results.push(result); + } + + const succeeded = results.filter((result) => result.ok).length; + const failed = results.length - succeeded; + const payload: BulkJobActionResponse = { + action: parsed.action, + requested: dedupedJobIds.length, + succeeded, + failed, + results, + }; + + logger.info("Bulk job action completed", { + route: "POST /api/jobs/bulk-actions", + action: parsed.action, + requested: dedupedJobIds.length, + succeeded, + failed, + }); + + ok(res, payload); + } catch (error) { + const err = + error instanceof z.ZodError + ? badRequest("Invalid bulk action request", error.flatten()) + : error instanceof AppError + ? error + : new AppError({ + status: 500, + code: "INTERNAL_ERROR", + message: error instanceof Error ? error.message : "Unknown error", + }); + + logger.error("Bulk job action failed", { + route: "POST /api/jobs/bulk-actions", + status: err.status, + code: err.code, + details: err.details, + }); + + fail(res, err); + } +}); + /** * GET /api/jobs/:id - Get a single job */ diff --git a/shared/src/types.ts b/shared/src/types.ts index a798744..d283ff8 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -339,6 +339,36 @@ export interface JobsListResponse { byStatus: Record; } +export type BulkJobAction = "skip" | "move_to_ready"; + +export interface BulkJobActionRequest { + action: BulkJobAction; + jobIds: string[]; +} + +export type BulkJobActionResult = + | { + jobId: string; + ok: true; + job: Job; + } + | { + jobId: string; + ok: false; + error: { + code: string; + message: string; + }; + }; + +export interface BulkJobActionResponse { + action: BulkJobAction; + requested: number; + succeeded: number; + failed: number; + results: BulkJobActionResult[]; +} + export interface UkVisaJobsSearchResponse { jobs: CreateJobInput[]; totalJobs: number;