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 job2: Job = { ...jobFixture, id: "job-2", status: "discovered" };
|
||||||
|
const processingJob: Job = { ...jobFixture, id: "job-3", status: "processing" };
|
||||||
|
|
||||||
const createMatchMedia = (matches: boolean) =>
|
const createMatchMedia = (matches: boolean) =>
|
||||||
vi.fn().mockImplementation((query: string) => ({
|
vi.fn().mockImplementation((query: string) => ({
|
||||||
@ -100,10 +101,10 @@ const createMatchMedia = (matches: boolean) =>
|
|||||||
|
|
||||||
vi.mock("./orchestrator/useOrchestratorData", () => ({
|
vi.mock("./orchestrator/useOrchestratorData", () => ({
|
||||||
useOrchestratorData: () => ({
|
useOrchestratorData: () => ({
|
||||||
jobs: [jobFixture, job2],
|
jobs: [jobFixture, job2, processingJob],
|
||||||
stats: {
|
stats: {
|
||||||
discovered: 1,
|
discovered: 1,
|
||||||
processing: 0,
|
processing: 1,
|
||||||
ready: 1,
|
ready: 1,
|
||||||
applied: 0,
|
applied: 0,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
@ -190,13 +191,45 @@ vi.mock("./orchestrator/JobDetailPanel", () => ({
|
|||||||
vi.mock("./orchestrator/JobListPanel", () => ({
|
vi.mock("./orchestrator/JobListPanel", () => ({
|
||||||
JobListPanel: ({
|
JobListPanel: ({
|
||||||
onSelectJob,
|
onSelectJob,
|
||||||
|
onToggleSelectJob,
|
||||||
|
onToggleSelectAll,
|
||||||
selectedJobId,
|
selectedJobId,
|
||||||
}: {
|
}: {
|
||||||
onSelectJob: (id: string) => void;
|
onSelectJob: (id: string) => void;
|
||||||
|
onToggleSelectJob: (id: string) => void;
|
||||||
|
onToggleSelectAll: (checked: boolean) => void;
|
||||||
selectedJobId: string | null;
|
selectedJobId: string | null;
|
||||||
}) => (
|
}) => (
|
||||||
<div>
|
<div>
|
||||||
<div data-testid="selected-job">{selectedJobId ?? "none"}</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
|
<button
|
||||||
data-testid="select-job-1"
|
data-testid="select-job-1"
|
||||||
type="button"
|
type="button"
|
||||||
@ -211,6 +244,13 @@ vi.mock("./orchestrator/JobListPanel", () => ({
|
|||||||
>
|
>
|
||||||
Select job 2
|
Select job 2
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid="select-job-3"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectJob("job-3")}
|
||||||
|
>
|
||||||
|
Select job 3
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@ -461,4 +501,36 @@ describe("OrchestratorPage", () => {
|
|||||||
|
|
||||||
setIntervalSpy.mockRestore();
|
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,
|
selectedJobIds,
|
||||||
canSkipSelected,
|
canSkipSelected,
|
||||||
canMoveSelected,
|
canMoveSelected,
|
||||||
|
canRescoreSelected,
|
||||||
bulkActionInFlight,
|
bulkActionInFlight,
|
||||||
toggleSelectJob,
|
toggleSelectJob,
|
||||||
toggleSelectAll,
|
toggleSelectAll,
|
||||||
@ -442,9 +443,11 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
selectedCount={selectedJobIds.size}
|
selectedCount={selectedJobIds.size}
|
||||||
canMoveSelected={canMoveSelected}
|
canMoveSelected={canMoveSelected}
|
||||||
canSkipSelected={canSkipSelected}
|
canSkipSelected={canSkipSelected}
|
||||||
|
canRescoreSelected={canRescoreSelected}
|
||||||
bulkActionInFlight={bulkActionInFlight !== null}
|
bulkActionInFlight={bulkActionInFlight !== null}
|
||||||
onMoveToReady={() => void runBulkAction("move_to_ready")}
|
onMoveToReady={() => void runBulkAction("move_to_ready")}
|
||||||
onSkipSelected={() => void runBulkAction("skip")}
|
onSkipSelected={() => void runBulkAction("skip")}
|
||||||
|
onRescoreSelected={() => void runBulkAction("rescore")}
|
||||||
onClear={clearSelection}
|
onClear={clearSelection}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,11 @@ interface FloatingBulkActionsBarProps {
|
|||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
canMoveSelected: boolean;
|
canMoveSelected: boolean;
|
||||||
canSkipSelected: boolean;
|
canSkipSelected: boolean;
|
||||||
|
canRescoreSelected: boolean;
|
||||||
bulkActionInFlight: boolean;
|
bulkActionInFlight: boolean;
|
||||||
onMoveToReady: () => void;
|
onMoveToReady: () => void;
|
||||||
onSkipSelected: () => void;
|
onSkipSelected: () => void;
|
||||||
|
onRescoreSelected: () => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,9 +19,11 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
|
|||||||
selectedCount,
|
selectedCount,
|
||||||
canMoveSelected,
|
canMoveSelected,
|
||||||
canSkipSelected,
|
canSkipSelected,
|
||||||
|
canRescoreSelected,
|
||||||
bulkActionInFlight,
|
bulkActionInFlight,
|
||||||
onMoveToReady,
|
onMoveToReady,
|
||||||
onSkipSelected,
|
onSkipSelected,
|
||||||
|
onRescoreSelected,
|
||||||
onClear,
|
onClear,
|
||||||
}) => {
|
}) => {
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
@ -73,6 +77,17 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
|
|||||||
Skip selected
|
Skip selected
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{canRescoreSelected && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={bulkActionInFlight}
|
||||||
|
onClick={onRescoreSelected}
|
||||||
|
>
|
||||||
|
Recalculate match
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
canBulkMoveToReady,
|
canBulkMoveToReady,
|
||||||
|
canBulkRescore,
|
||||||
canBulkSkip,
|
canBulkSkip,
|
||||||
getFailedJobIds,
|
getFailedJobIds,
|
||||||
} from "./bulkActions";
|
} from "./bulkActions";
|
||||||
@ -71,7 +72,7 @@ function createJob(id: string, status: JobStatus): Job {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("bulkActions", () => {
|
describe("bulkActions", () => {
|
||||||
it("computes eligibility for skip and move-to-ready", () => {
|
it("computes eligibility for skip, move-to-ready, and rescore", () => {
|
||||||
expect(
|
expect(
|
||||||
canBulkSkip([createJob("1", "discovered"), createJob("2", "ready")]),
|
canBulkSkip([createJob("1", "discovered"), createJob("2", "ready")]),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@ -84,6 +85,19 @@ describe("bulkActions", () => {
|
|||||||
]),
|
]),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(canBulkMoveToReady([createJob("1", "ready")])).toBe(false);
|
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", () => {
|
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");
|
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> {
|
export function getFailedJobIds(response: BulkJobActionResponse): Set<string> {
|
||||||
const failedIds = response.results
|
const failedIds = response.results
|
||||||
.filter((result) => !result.ok)
|
.filter((result) => !result.ok)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js";
|
import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js";
|
||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import { useBulkJobSelection } from "./useBulkJobSelection";
|
import { useBulkJobSelection } from "./useBulkJobSelection";
|
||||||
@ -198,4 +199,45 @@ describe("useBulkJobSelection", () => {
|
|||||||
expect(Array.from(result.current.selectedJobIds)).toEqual(["job-3"]);
|
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 * as api from "../../api";
|
||||||
import {
|
import {
|
||||||
canBulkMoveToReady,
|
canBulkMoveToReady,
|
||||||
|
canBulkRescore,
|
||||||
canBulkSkip,
|
canBulkSkip,
|
||||||
getFailedJobIds,
|
getFailedJobIds,
|
||||||
} from "./bulkActions";
|
} from "./bulkActions";
|
||||||
@ -42,6 +43,10 @@ export function useBulkJobSelection({
|
|||||||
() => canBulkMoveToReady(selectedJobs),
|
() => canBulkMoveToReady(selectedJobs),
|
||||||
[selectedJobs],
|
[selectedJobs],
|
||||||
);
|
);
|
||||||
|
const canRescoreSelected = useMemo(
|
||||||
|
() => canBulkRescore(selectedJobs),
|
||||||
|
[selectedJobs],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previousActiveTabRef.current === activeTab) return;
|
if (previousActiveTabRef.current === activeTab) return;
|
||||||
@ -114,7 +119,11 @@ export function useBulkJobSelection({
|
|||||||
|
|
||||||
const failedIds = getFailedJobIds(result);
|
const failedIds = getFailedJobIds(result);
|
||||||
const successLabel =
|
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) {
|
if (result.failed === 0) {
|
||||||
toast.success(`${result.succeeded} ${successLabel}`);
|
toast.success(`${result.succeeded} ${successLabel}`);
|
||||||
@ -154,6 +163,7 @@ export function useBulkJobSelection({
|
|||||||
selectedJobIds,
|
selectedJobIds,
|
||||||
canSkipSelected,
|
canSkipSelected,
|
||||||
canMoveSelected,
|
canMoveSelected,
|
||||||
|
canRescoreSelected,
|
||||||
bulkActionInFlight,
|
bulkActionInFlight,
|
||||||
toggleSelectJob,
|
toggleSelectJob,
|
||||||
toggleSelectAll,
|
toggleSelectAll,
|
||||||
|
|||||||
@ -161,6 +161,73 @@ describe.sequential("Jobs API routes", () => {
|
|||||||
).toBe("INVALID_REQUEST");
|
).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 () => {
|
it("validates bulk action payloads", async () => {
|
||||||
const tooManyIds = Array.from(
|
const tooManyIds = Array.from(
|
||||||
{ length: 101 },
|
{ length: 101 },
|
||||||
|
|||||||
@ -141,7 +141,7 @@ const updateOutcomeSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const bulkActionRequestSchema = 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),
|
jobIds: z.array(z.string().min(1)).min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -211,32 +211,75 @@ async function executeBulkActionForJob(
|
|||||||
return { jobId, ok: true, job: updated };
|
return { jobId, ok: true, job: updated };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (job.status !== "discovered") {
|
if (action === "move_to_ready") {
|
||||||
throw badRequest(
|
if (job.status !== "discovered") {
|
||||||
`Job is not movable to Ready from status "${job.status}"`,
|
throw badRequest(
|
||||||
{
|
`Job is not movable to Ready from status "${job.status}"`,
|
||||||
jobId,
|
{
|
||||||
status: job.status,
|
jobId,
|
||||||
requiredStatus: "discovered",
|
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 (job.status === "processing") {
|
||||||
if (!processed.success) {
|
throw badRequest(`Job is not rescorable from status "${job.status}"`, {
|
||||||
throw new AppError({
|
jobId,
|
||||||
status: 500,
|
status: job.status,
|
||||||
code: "INTERNAL_ERROR",
|
disallowedStatus: "processing",
|
||||||
message: processed.error || "Failed to process job",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (!updated) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
status: 404,
|
status: 404,
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Job not found after processing",
|
message: "Job not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -339,7 +339,7 @@ export interface JobsListResponse {
|
|||||||
byStatus: Record<JobStatus, number>;
|
byStatus: Record<JobStatus, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BulkJobAction = "skip" | "move_to_ready";
|
export type BulkJobAction = "skip" | "move_to_ready" | "rescore";
|
||||||
|
|
||||||
export interface BulkJobActionRequest {
|
export interface BulkJobActionRequest {
|
||||||
action: BulkJobAction;
|
action: BulkJobAction;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user