Select multiple jobs for bulk actions (#94)
* Initial implementation * UI * UI * floating bar animations * UI and split up orchestrator page * remove bulk action hint * UI * UI * formatting * fix lint * enforce max actions
This commit is contained in:
parent
855ac0c5a5
commit
cfabee5f45
@ -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<Job> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function bulkJobAction(
|
||||
input: BulkJobActionRequest,
|
||||
): Promise<BulkJobActionResponse> {
|
||||
return fetchApi<BulkJobActionResponse>("/jobs/bulk-actions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getJobStageEvents(id: string): Promise<StageEvent[]> {
|
||||
return fetchApi<StageEvent[]>(`/jobs/${id}/events?t=${Date.now()}`);
|
||||
}
|
||||
|
||||
@ -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<ReadyPanelProps> = ({
|
||||
employer: string;
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
} | null>(null);
|
||||
const previousJobIdRef = useRef<string | null>(null);
|
||||
|
||||
const { personName } = useProfile();
|
||||
|
||||
@ -85,6 +86,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
|
||||
// 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]);
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -42,28 +42,39 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
}) => {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
|
||||
const [jobDescription, setJobDescription] = useState(
|
||||
job.jobDescription || "",
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
|
||||
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<Set<string>>(() =>
|
||||
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<TailoringEditorProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
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<TailoringEditorProps> = ({
|
||||
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<TailoringEditorProps> = ({
|
||||
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..."
|
||||
/>
|
||||
|
||||
@ -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<DiscoveredPanelProps> = ({
|
||||
const [mode, setMode] = useState<PanelMode>("decide");
|
||||
const [isSkipping, setIsSkipping] = useState(false);
|
||||
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||
const previousJobIdRef = useRef<string | null>(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);
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -40,26 +40,30 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
}) => {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
|
||||
const [jobDescription, setJobDescription] = useState(
|
||||
job.jobDescription || "",
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
|
||||
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<Set<string>>(() =>
|
||||
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<TailorModeProps> = ({
|
||||
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<TailorModeProps> = ({
|
||||
}
|
||||
}, [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<TailorModeProps> = ({
|
||||
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<TailorModeProps> = ({
|
||||
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}
|
||||
|
||||
@ -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 = () => {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<FloatingBulkActionsBar
|
||||
selectedCount={selectedJobIds.size}
|
||||
canMoveSelected={canMoveSelected}
|
||||
canSkipSelected={canSkipSelected}
|
||||
bulkActionInFlight={bulkActionInFlight !== null}
|
||||
onMoveToReady={() => void runBulkAction("move_to_ready")}
|
||||
onSkipSelected={() => void runBulkAction("skip")}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
|
||||
<ManualImportSheet
|
||||
open={isManualImportOpen}
|
||||
onOpenChange={setIsManualImportOpen}
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FloatingBulkActionsBarProps {
|
||||
selectedCount: number;
|
||||
canMoveSelected: boolean;
|
||||
canSkipSelected: boolean;
|
||||
bulkActionInFlight: boolean;
|
||||
onMoveToReady: () => void;
|
||||
onSkipSelected: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
|
||||
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 (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto flex flex-wrap items-center gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85",
|
||||
"transition-all duration-200 ease-out",
|
||||
isVisible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{selectedCount} selected
|
||||
</div>
|
||||
{canMoveSelected && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={bulkActionInFlight}
|
||||
onClick={onMoveToReady}
|
||||
>
|
||||
Move to Ready
|
||||
</Button>
|
||||
)}
|
||||
{canSkipSelected && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={bulkActionInFlight}
|
||||
onClick={onSkipSelected}
|
||||
>
|
||||
Skip selected
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onClear}
|
||||
disabled={bulkActionInFlight}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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: "<p>Hello <strong>world</strong></p>",
|
||||
}),
|
||||
onSelectJobId: vi.fn(),
|
||||
onJobUpdated: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
);
|
||||
await renderJobDetailPanel({
|
||||
activeTab: "all",
|
||||
activeJobs: [],
|
||||
selectedJob: createJob({
|
||||
jobDescription: "<p>Hello <strong>world</strong></p>",
|
||||
}),
|
||||
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"));
|
||||
|
||||
@ -70,6 +70,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
const [hasUnsavedTailoring, setHasUnsavedTailoring] = useState(false);
|
||||
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
|
||||
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
|
||||
const previousSelectedJobIdRef = useRef<string | null>(null);
|
||||
|
||||
const { personName } = useProfile();
|
||||
|
||||
@ -82,6 +83,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentJobId = selectedJob?.id ?? null;
|
||||
if (previousSelectedJobIdRef.current === currentJobId) return;
|
||||
previousSelectedJobIdRef.current = currentJobId;
|
||||
setHasUnsavedTailoring(false);
|
||||
saveTailoringRef.current = null;
|
||||
onPauseRefreshChange?.(false);
|
||||
|
||||
@ -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(
|
||||
<JobListPanel
|
||||
isLoading={false}
|
||||
jobs={jobs}
|
||||
activeJobs={jobs}
|
||||
selectedJobId="job-1"
|
||||
selectedJobIds={new Set(["job-1"])}
|
||||
activeTab="ready"
|
||||
searchQuery=""
|
||||
onSelectJob={vi.fn()}
|
||||
onToggleSelectJob={onToggleSelectJob}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Select Backend Engineer"));
|
||||
expect(onToggleSelectJob).toHaveBeenCalledWith("job-1");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Select all filtered jobs"));
|
||||
expect(onToggleSelectAll).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string>;
|
||||
activeTab: FilterTab;
|
||||
searchQuery: string;
|
||||
onSelectJob: (jobId: string) => void;
|
||||
onToggleSelectJob: (jobId: string) => void;
|
||||
onToggleSelectAll: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export const JobListPanel: React.FC<JobListPanelProps> = ({
|
||||
@ -20,9 +24,12 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
|
||||
jobs,
|
||||
activeJobs,
|
||||
selectedJobId,
|
||||
selectedJobIds,
|
||||
activeTab,
|
||||
searchQuery,
|
||||
onSelectJob,
|
||||
onToggleSelectJob,
|
||||
onToggleSelectAll,
|
||||
}) => (
|
||||
<div className="min-w-0 rounded-xl border border-border bg-card shadow-sm">
|
||||
{isLoading && jobs.length === 0 ? (
|
||||
@ -41,24 +48,64 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2 opacity-50 hover:opacity-100 transition-opacity">
|
||||
<label
|
||||
htmlFor="job-list-select-all"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<Checkbox
|
||||
id="job-list-select-all"
|
||||
checked={
|
||||
activeJobs.length > 0 &&
|
||||
activeJobs.every((job) => selectedJobIds.has(job.id))
|
||||
}
|
||||
onCheckedChange={() => {
|
||||
const allSelected =
|
||||
activeJobs.length > 0 &&
|
||||
activeJobs.every((job) => selectedJobIds.has(job.id));
|
||||
onToggleSelectAll(!allSelected);
|
||||
}}
|
||||
aria-label="Select all filtered jobs"
|
||||
/>
|
||||
Select all filtered
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{selectedJobIds.size} selected
|
||||
</span>
|
||||
</div>
|
||||
{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 (
|
||||
<button
|
||||
<div
|
||||
key={job.id}
|
||||
type="button"
|
||||
onClick={() => onSelectJob(job.id)}
|
||||
data-testid={`select-${job.id}`}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
|
||||
"group flex items-center gap-3 px-4 py-3 transition-colors cursor-pointer border-l-2 border-b",
|
||||
isChecked
|
||||
? "!border-l !border-l-primary !bg-muted/40"
|
||||
: "border-l border-l-border/40",
|
||||
isSelected
|
||||
? "bg-primary/5 border-l-2 border-l-primary"
|
||||
: "hover:bg-muted/20 border-l-2 border-l-transparent",
|
||||
? "bg-primary/5"
|
||||
: "border-b-border/40 hover:bg-muted/20",
|
||||
isChecked && isSelected && "outline-2 outline-primary/30",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => onToggleSelectJob(job.id)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label={`Select ${job.title}`}
|
||||
className={cn(
|
||||
"border-border/80 cursor-pointer text-muted-foreground/70 transition-opacity",
|
||||
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/20 data-[state=checked]:text-primary",
|
||||
"data-[state=checked]:shadow-[0_0_0_1px_hsl(var(--primary)/0.35)]",
|
||||
isChecked || isSelected
|
||||
? "opacity-100"
|
||||
: "opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100",
|
||||
)}
|
||||
/>
|
||||
{/* Single status indicator: subtle dot */}
|
||||
<span
|
||||
className={cn(
|
||||
@ -69,49 +116,57 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
|
||||
title={statusToken.label}
|
||||
/>
|
||||
|
||||
{/* Primary content: title strongest, company secondary */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-sm leading-tight",
|
||||
isSelected ? "font-semibold" : "font-medium",
|
||||
)}
|
||||
>
|
||||
{job.title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{job.employer}
|
||||
{job.location && (
|
||||
<span className="before:content-['_in_']">
|
||||
{job.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{job.salary?.trim() && (
|
||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{job.salary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single triage cue: score only (status shown via dot) */}
|
||||
{hasScore && (
|
||||
<div className="shrink-0 text-right">
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectJob(job.id)}
|
||||
data-testid={`select-${job.id}`}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left"
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{/* Primary content: title strongest, company secondary */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
(job.suitabilityScore ?? 0) >= 70
|
||||
? "text-emerald-400/90"
|
||||
: (job.suitabilityScore ?? 0) >= 50
|
||||
? "text-foreground/60"
|
||||
: "text-muted-foreground/60",
|
||||
"truncate text-sm leading-tight",
|
||||
isSelected ? "font-semibold" : "font-medium",
|
||||
)}
|
||||
>
|
||||
{job.suitabilityScore}
|
||||
</span>
|
||||
{job.title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{job.employer}
|
||||
{job.location && (
|
||||
<span className="before:content-['_in_']">
|
||||
{job.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{job.salary?.trim() && (
|
||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{job.salary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Single triage cue: score only (status shown via dot) */}
|
||||
{hasScore && (
|
||||
<div className="shrink-0 text-right">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
(job.suitabilityScore ?? 0) >= 70
|
||||
? "text-emerald-400/90"
|
||||
: (job.suitabilityScore ?? 0) >= 50
|
||||
? "text-foreground/60"
|
||||
: "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{job.suitabilityScore}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
112
orchestrator/src/client/pages/orchestrator/bulkActions.test.ts
Normal file
112
orchestrator/src/client/pages/orchestrator/bulkActions.test.ts
Normal file
@ -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"]);
|
||||
});
|
||||
});
|
||||
20
orchestrator/src/client/pages/orchestrator/bulkActions.ts
Normal file
20
orchestrator/src/client/pages/orchestrator/bulkActions.ts
Normal file
@ -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<string> {
|
||||
const failedIds = response.results
|
||||
.filter((result) => !result.ok)
|
||||
.map((result) => result.jobId);
|
||||
return new Set(failedIds);
|
||||
}
|
||||
@ -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<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
};
|
||||
|
||||
const deferred = <T>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<BulkJobActionResponse>();
|
||||
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<void>;
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<void>;
|
||||
}
|
||||
|
||||
export function useBulkJobSelection({
|
||||
activeJobs,
|
||||
activeTab,
|
||||
loadJobs,
|
||||
}: UseBulkJobSelectionArgs) {
|
||||
const [selectedJobIds, setSelectedJobIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [bulkActionInFlight, setBulkActionInFlight] =
|
||||
useState<null | BulkJobAction>(null);
|
||||
const previousActiveTabRef = useRef<FilterTab>(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,
|
||||
};
|
||||
}
|
||||
@ -32,7 +32,7 @@ type Deferred<T> = {
|
||||
resolve: (value: T) => void;
|
||||
};
|
||||
|
||||
const deferred = <T,>(): Deferred<T> => {
|
||||
const deferred = <T>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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 () => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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<JobStatus> = 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<BulkJobActionResult> {
|
||||
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
|
||||
*/
|
||||
|
||||
@ -339,6 +339,36 @@ export interface JobsListResponse {
|
||||
byStatus: Record<JobStatus, number>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user