Recalculate match in batch actions (#102)

* rescore in batch

* more tests!

* formatting!
This commit is contained in:
Shaheer Sarfaraz 2026-02-07 22:51:42 +00:00 committed by GitHub
parent a409aa5ee0
commit 2fe0dc2c2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 293 additions and 23 deletions

View File

@ -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();
});
});
});

View File

@ -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}
/>

View File

@ -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"

View File

@ -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", () => {

View File

@ -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)

View File

@ -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");
});
});

View File

@ -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,

View File

@ -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 },

View File

@ -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",
});
}

View File

@ -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;