Recalculate match in batch actions (#102)
* rescore in batch * more tests! * formatting!
This commit is contained in:
parent
a409aa5ee0
commit
2fe0dc2c2f
@ -86,6 +86,7 @@ const jobFixture: Job = {
|
||||
};
|
||||
|
||||
const job2: Job = { ...jobFixture, id: "job-2", status: "discovered" };
|
||||
const processingJob: Job = { ...jobFixture, id: "job-3", status: "processing" };
|
||||
|
||||
const createMatchMedia = (matches: boolean) =>
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
@ -100,10 +101,10 @@ const createMatchMedia = (matches: boolean) =>
|
||||
|
||||
vi.mock("./orchestrator/useOrchestratorData", () => ({
|
||||
useOrchestratorData: () => ({
|
||||
jobs: [jobFixture, job2],
|
||||
jobs: [jobFixture, job2, processingJob],
|
||||
stats: {
|
||||
discovered: 1,
|
||||
processing: 0,
|
||||
processing: 1,
|
||||
ready: 1,
|
||||
applied: 0,
|
||||
skipped: 0,
|
||||
@ -190,13 +191,45 @@ vi.mock("./orchestrator/JobDetailPanel", () => ({
|
||||
vi.mock("./orchestrator/JobListPanel", () => ({
|
||||
JobListPanel: ({
|
||||
onSelectJob,
|
||||
onToggleSelectJob,
|
||||
onToggleSelectAll,
|
||||
selectedJobId,
|
||||
}: {
|
||||
onSelectJob: (id: string) => void;
|
||||
onToggleSelectJob: (id: string) => void;
|
||||
onToggleSelectAll: (checked: boolean) => void;
|
||||
selectedJobId: string | null;
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="selected-job">{selectedJobId ?? "none"}</div>
|
||||
<button
|
||||
data-testid="toggle-select-all-on"
|
||||
type="button"
|
||||
onClick={() => onToggleSelectAll(true)}
|
||||
>
|
||||
Toggle all on
|
||||
</button>
|
||||
<button
|
||||
data-testid="toggle-select-all-off"
|
||||
type="button"
|
||||
onClick={() => onToggleSelectAll(false)}
|
||||
>
|
||||
Toggle all off
|
||||
</button>
|
||||
<button
|
||||
data-testid="toggle-select-job-1"
|
||||
type="button"
|
||||
onClick={() => onToggleSelectJob("job-1")}
|
||||
>
|
||||
Toggle job 1
|
||||
</button>
|
||||
<button
|
||||
data-testid="toggle-select-job-3"
|
||||
type="button"
|
||||
onClick={() => onToggleSelectJob("job-3")}
|
||||
>
|
||||
Toggle job 3
|
||||
</button>
|
||||
<button
|
||||
data-testid="select-job-1"
|
||||
type="button"
|
||||
@ -211,6 +244,13 @@ vi.mock("./orchestrator/JobListPanel", () => ({
|
||||
>
|
||||
Select job 2
|
||||
</button>
|
||||
<button
|
||||
data-testid="select-job-3"
|
||||
type="button"
|
||||
onClick={() => onSelectJob("job-3")}
|
||||
>
|
||||
Select job 3
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@ -461,4 +501,36 @@ describe("OrchestratorPage", () => {
|
||||
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shows and hides bulk Recalculate match based on selected statuses", async () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/all"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("toggle-select-all-on"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Recalculate match" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("toggle-select-all-off"));
|
||||
fireEvent.click(screen.getByTestId("toggle-select-job-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Recalculate match" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -194,6 +194,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
selectedJobIds,
|
||||
canSkipSelected,
|
||||
canMoveSelected,
|
||||
canRescoreSelected,
|
||||
bulkActionInFlight,
|
||||
toggleSelectJob,
|
||||
toggleSelectAll,
|
||||
@ -442,9 +443,11 @@ export const OrchestratorPage: React.FC = () => {
|
||||
selectedCount={selectedJobIds.size}
|
||||
canMoveSelected={canMoveSelected}
|
||||
canSkipSelected={canSkipSelected}
|
||||
canRescoreSelected={canRescoreSelected}
|
||||
bulkActionInFlight={bulkActionInFlight !== null}
|
||||
onMoveToReady={() => void runBulkAction("move_to_ready")}
|
||||
onSkipSelected={() => void runBulkAction("skip")}
|
||||
onRescoreSelected={() => void runBulkAction("rescore")}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
|
||||
|
||||
@ -7,9 +7,11 @@ interface FloatingBulkActionsBarProps {
|
||||
selectedCount: number;
|
||||
canMoveSelected: boolean;
|
||||
canSkipSelected: boolean;
|
||||
canRescoreSelected: boolean;
|
||||
bulkActionInFlight: boolean;
|
||||
onMoveToReady: () => void;
|
||||
onSkipSelected: () => void;
|
||||
onRescoreSelected: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
@ -17,9 +19,11 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
|
||||
selectedCount,
|
||||
canMoveSelected,
|
||||
canSkipSelected,
|
||||
canRescoreSelected,
|
||||
bulkActionInFlight,
|
||||
onMoveToReady,
|
||||
onSkipSelected,
|
||||
onRescoreSelected,
|
||||
onClear,
|
||||
}) => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
@ -73,6 +77,17 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
|
||||
Skip selected
|
||||
</Button>
|
||||
)}
|
||||
{canRescoreSelected && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={bulkActionInFlight}
|
||||
onClick={onRescoreSelected}
|
||||
>
|
||||
Recalculate match
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@ -2,6 +2,7 @@ import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canBulkMoveToReady,
|
||||
canBulkRescore,
|
||||
canBulkSkip,
|
||||
getFailedJobIds,
|
||||
} from "./bulkActions";
|
||||
@ -71,7 +72,7 @@ function createJob(id: string, status: JobStatus): Job {
|
||||
}
|
||||
|
||||
describe("bulkActions", () => {
|
||||
it("computes eligibility for skip and move-to-ready", () => {
|
||||
it("computes eligibility for skip, move-to-ready, and rescore", () => {
|
||||
expect(
|
||||
canBulkSkip([createJob("1", "discovered"), createJob("2", "ready")]),
|
||||
).toBe(true);
|
||||
@ -84,6 +85,19 @@ describe("bulkActions", () => {
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(canBulkMoveToReady([createJob("1", "ready")])).toBe(false);
|
||||
|
||||
expect(
|
||||
canBulkRescore([
|
||||
createJob("1", "discovered"),
|
||||
createJob("2", "ready"),
|
||||
createJob("3", "applied"),
|
||||
createJob("4", "skipped"),
|
||||
createJob("5", "expired"),
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canBulkRescore([createJob("1", "ready"), createJob("2", "processing")]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts failed job ids from a bulk response", () => {
|
||||
|
||||
@ -12,6 +12,10 @@ export function canBulkMoveToReady(jobs: Job[]): boolean {
|
||||
return jobs.length > 0 && jobs.every((job) => job.status === "discovered");
|
||||
}
|
||||
|
||||
export function canBulkRescore(jobs: Job[]): boolean {
|
||||
return jobs.length > 0 && jobs.every((job) => job.status !== "processing");
|
||||
}
|
||||
|
||||
export function getFailedJobIds(response: BulkJobActionResponse): Set<string> {
|
||||
const failedIds = response.results
|
||||
.filter((result) => !result.ok)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { toast } from "sonner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../../api";
|
||||
import { useBulkJobSelection } from "./useBulkJobSelection";
|
||||
@ -198,4 +199,45 @@ describe("useBulkJobSelection", () => {
|
||||
expect(Array.from(result.current.selectedJobIds)).toEqual(["job-3"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("runs bulk rescore and reports success copy", async () => {
|
||||
const activeJobs = [
|
||||
createJob("job-1", "ready"),
|
||||
createJob("job-2", "ready"),
|
||||
];
|
||||
const loadJobs = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(api.bulkJobAction).mockResolvedValue({
|
||||
action: "rescore",
|
||||
requested: 2,
|
||||
succeeded: 2,
|
||||
failed: 0,
|
||||
results: [
|
||||
{ jobId: "job-1", ok: true, job: createJob("job-1", "ready") },
|
||||
{ jobId: "job-2", ok: true, job: createJob("job-2", "ready") },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBulkJobSelection({
|
||||
activeJobs,
|
||||
activeTab: "ready",
|
||||
loadJobs,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSelectJob("job-1");
|
||||
result.current.toggleSelectJob("job-2");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runBulkAction("rescore");
|
||||
});
|
||||
|
||||
expect(api.bulkJobAction).toHaveBeenCalledWith({
|
||||
action: "rescore",
|
||||
jobIds: ["job-1", "job-2"],
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith("2 matches recalculated");
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ import { toast } from "sonner";
|
||||
import * as api from "../../api";
|
||||
import {
|
||||
canBulkMoveToReady,
|
||||
canBulkRescore,
|
||||
canBulkSkip,
|
||||
getFailedJobIds,
|
||||
} from "./bulkActions";
|
||||
@ -42,6 +43,10 @@ export function useBulkJobSelection({
|
||||
() => canBulkMoveToReady(selectedJobs),
|
||||
[selectedJobs],
|
||||
);
|
||||
const canRescoreSelected = useMemo(
|
||||
() => canBulkRescore(selectedJobs),
|
||||
[selectedJobs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousActiveTabRef.current === activeTab) return;
|
||||
@ -114,7 +119,11 @@ export function useBulkJobSelection({
|
||||
|
||||
const failedIds = getFailedJobIds(result);
|
||||
const successLabel =
|
||||
action === "skip" ? "jobs skipped" : "jobs moved to Ready";
|
||||
action === "skip"
|
||||
? "jobs skipped"
|
||||
: action === "move_to_ready"
|
||||
? "jobs moved to Ready"
|
||||
: "matches recalculated";
|
||||
|
||||
if (result.failed === 0) {
|
||||
toast.success(`${result.succeeded} ${successLabel}`);
|
||||
@ -154,6 +163,7 @@ export function useBulkJobSelection({
|
||||
selectedJobIds,
|
||||
canSkipSelected,
|
||||
canMoveSelected,
|
||||
canRescoreSelected,
|
||||
bulkActionInFlight,
|
||||
toggleSelectJob,
|
||||
toggleSelectAll,
|
||||
|
||||
@ -161,6 +161,73 @@ describe.sequential("Jobs API routes", () => {
|
||||
).toBe("INVALID_REQUEST");
|
||||
});
|
||||
|
||||
it("runs bulk rescore with partial failures", async () => {
|
||||
const { createJob, updateJob } = await import("../../repositories/jobs");
|
||||
const { scoreJobSuitability } = await import("../../services/scorer");
|
||||
const { getProfile } = await import("../../services/profile");
|
||||
|
||||
vi.mocked(getProfile).mockResolvedValue({});
|
||||
vi.mocked(scoreJobSuitability).mockResolvedValue({
|
||||
score: 81,
|
||||
reason: "Updated fit from bulk rescore",
|
||||
});
|
||||
|
||||
const discovered = await createJob({
|
||||
source: "manual",
|
||||
title: "Discovered Role",
|
||||
employer: "Acme",
|
||||
jobUrl: "https://example.com/job/bulk-rescore-1",
|
||||
jobDescription: "Test description",
|
||||
});
|
||||
const ready = await createJob({
|
||||
source: "manual",
|
||||
title: "Ready Role",
|
||||
employer: "Beta",
|
||||
jobUrl: "https://example.com/job/bulk-rescore-2",
|
||||
jobDescription: "Test description",
|
||||
});
|
||||
const processing = await createJob({
|
||||
source: "manual",
|
||||
title: "Processing Role",
|
||||
employer: "Gamma",
|
||||
jobUrl: "https://example.com/job/bulk-rescore-3",
|
||||
jobDescription: "Test description",
|
||||
});
|
||||
await updateJob(ready.id, { status: "ready" });
|
||||
await updateJob(processing.id, { status: "processing" });
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/jobs/bulk-actions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "rescore",
|
||||
jobIds: [discovered.id, ready.id, processing.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);
|
||||
expect(
|
||||
body.data.results.find((r: any) => r.jobId === discovered.id).job
|
||||
.suitabilityScore,
|
||||
).toBe(81);
|
||||
expect(
|
||||
body.data.results.find((r: any) => r.jobId === ready.id).job
|
||||
.suitabilityScore,
|
||||
).toBe(81);
|
||||
expect(
|
||||
body.data.results.find((r: any) => r.jobId === processing.id).error.code,
|
||||
).toBe("INVALID_REQUEST");
|
||||
expect(
|
||||
body.data.results.find((r: any) => r.jobId === "missing-id").error.code,
|
||||
).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("validates bulk action payloads", async () => {
|
||||
const tooManyIds = Array.from(
|
||||
{ length: 101 },
|
||||
|
||||
@ -141,7 +141,7 @@ const updateOutcomeSchema = z.object({
|
||||
});
|
||||
|
||||
const bulkActionRequestSchema = z.object({
|
||||
action: z.enum(["skip", "move_to_ready"]),
|
||||
action: z.enum(["skip", "move_to_ready", "rescore"]),
|
||||
jobIds: z.array(z.string().min(1)).min(1).max(100),
|
||||
});
|
||||
|
||||
@ -211,32 +211,75 @@ async function executeBulkActionForJob(
|
||||
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",
|
||||
},
|
||||
);
|
||||
if (action === "move_to_ready") {
|
||||
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 };
|
||||
}
|
||||
|
||||
const processed = await processJob(jobId);
|
||||
if (!processed.success) {
|
||||
throw new AppError({
|
||||
status: 500,
|
||||
code: "INTERNAL_ERROR",
|
||||
message: processed.error || "Failed to process job",
|
||||
if (job.status === "processing") {
|
||||
throw badRequest(`Job is not rescorable from status "${job.status}"`, {
|
||||
jobId,
|
||||
status: job.status,
|
||||
disallowedStatus: "processing",
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await jobsRepo.getJobById(jobId);
|
||||
if (isDemoMode()) {
|
||||
const simulated = await simulateRescoreJob(job.id);
|
||||
return { jobId, ok: true, job: simulated };
|
||||
}
|
||||
|
||||
const rawProfile = await getProfile();
|
||||
if (
|
||||
!rawProfile ||
|
||||
typeof rawProfile !== "object" ||
|
||||
Array.isArray(rawProfile)
|
||||
) {
|
||||
throw badRequest("Invalid resume profile format");
|
||||
}
|
||||
|
||||
const { score, reason } = await scoreJobSuitability(
|
||||
job,
|
||||
rawProfile as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const updated = await jobsRepo.updateJob(job.id, {
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new AppError({
|
||||
status: 404,
|
||||
code: "NOT_FOUND",
|
||||
message: "Job not found after processing",
|
||||
message: "Job not found",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -339,7 +339,7 @@ export interface JobsListResponse {
|
||||
byStatus: Record<JobStatus, number>;
|
||||
}
|
||||
|
||||
export type BulkJobAction = "skip" | "move_to_ready";
|
||||
export type BulkJobAction = "skip" | "move_to_ready" | "rescore";
|
||||
|
||||
export interface BulkJobActionRequest {
|
||||
action: BulkJobAction;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user