diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index e570fb2..b6223ef 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -85,6 +85,12 @@ export async function processJob(id: string, options?: { force?: boolean }): Pro }); } +export async function rescoreJob(id: string): Promise { + return fetchApi(`/jobs/${id}/rescore`, { + method: 'POST', + }); +} + export async function summarizeJob(id: string, options?: { force?: boolean }): Promise { const query = options?.force ? '?force=1' : ''; return fetchApi(`/jobs/${id}/summarize${query}`, { diff --git a/orchestrator/src/client/components/ReadyPanel.test.tsx b/orchestrator/src/client/components/ReadyPanel.test.tsx new file mode 100644 index 0000000..89c634b --- /dev/null +++ b/orchestrator/src/client/components/ReadyPanel.test.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { ReadyPanel } from "./ReadyPanel"; +import type { Job } from "../../shared/types"; +import * as api from "../api"; +import { toast } from "sonner"; + +vi.mock("@/components/ui/dropdown-menu", () => { + return { + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + onSelect, + ...props + }: { + children: React.ReactNode; + onSelect?: () => void; + }) => ( + + ), + DropdownMenuSeparator: () =>
, + }; +}); + +vi.mock("../hooks/useProfile", () => ({ + useProfile: () => ({ personName: "Test" }), +})); + +vi.mock("../hooks/useSettings", () => ({ + useSettings: () => ({ showSponsorInfo: false }), +})); + +vi.mock("../api", () => ({ + rescoreJob: vi.fn(), + getResumeProjectsCatalog: vi.fn().mockResolvedValue([]), + markAsApplied: vi.fn(), + generateJobPdf: vi.fn(), + checkSponsor: vi.fn(), + skipJob: vi.fn(), + updateJob: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + message: vi.fn(), + }, +})); + +const createJob = (overrides: Partial = {}): Job => ({ + id: "job-1", + source: "linkedin", + sourceJobId: null, + jobUrlDirect: null, + datePosted: null, + title: "Backend Engineer", + employer: "Acme", + employerUrl: null, + jobUrl: "https://example.com/job", + applicationLink: "https://example.com/apply", + disciplines: null, + deadline: "2025-02-01", + salary: "GBP 50k", + location: "London", + degreeRequired: null, + starting: null, + jobDescription: "Build APIs", + status: "ready", + suitabilityScore: 82, + suitabilityReason: "Strong fit", + 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-02T00:00:00Z", + ...overrides, +}); + +describe("ReadyPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("re-runs the fit assessment from the menu", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + const job = createJob(); + vi.mocked(api.rescoreJob).mockResolvedValue(job as Job); + + render( + + ); + + fireEvent.click(screen.getByRole("menuitem", { name: /recalculate match/i })); + + await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1")); + expect(onJobUpdated).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith("Match recalculated"); + }); +}); diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index 644c986..10ccc40 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -46,6 +46,7 @@ import * as api from "../api"; import { FitAssessment, JobHeader, TailoredSummary } from "."; import { TailorMode } from "./discovered-panel/TailorMode"; import { useProfile } from "../hooks/useProfile"; +import { useRescoreJob } from "../hooks/useRescoreJob"; import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; type PanelMode = "ready" | "tailor"; @@ -67,6 +68,7 @@ export const ReadyPanel: React.FC = ({ const [mode, setMode] = useState("ready"); const [isMarkingApplied, setIsMarkingApplied] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); + const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated); const [catalog, setCatalog] = useState([]); const [recentlyApplied, setRecentlyApplied] = useState<{ jobId: string; @@ -181,6 +183,8 @@ export const ReadyPanel: React.FC = ({ } }, [job, onJobUpdated]); + const handleRescore = useCallback(() => rescoreJob(job?.id), [job?.id, rescoreJob]); + const handleSkip = useCallback(async () => { if (!job) return; @@ -385,6 +389,14 @@ export const ReadyPanel: React.FC = ({ {isRegenerating ? "Regenerating..." : "Regenerate PDF"} + + + {isRescoring ? "Recalculating..." : "Recalculate match"} + + {/* Utility actions */} diff --git a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx index 777ea89..bb8bacd 100644 --- a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx @@ -1,7 +1,13 @@ import React, { useMemo, useState } from "react"; -import { ExternalLink, Loader2, Sparkles, XCircle } from "lucide-react"; +import { ChevronUp, ExternalLink, Loader2, RefreshCcw, Sparkles, XCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; import { FitAssessment, JobHeader, TailoredSummary } from ".."; @@ -14,6 +20,8 @@ interface DecideModeProps { onTailor: () => void; onSkip: () => void; isSkipping: boolean; + onRescore: () => void; + isRescoring: boolean; onCheckSponsor?: () => Promise; } @@ -22,6 +30,8 @@ export const DecideMode: React.FC = ({ onTailor, onSkip, isSkipping, + onRescore, + isRescoring, onCheckSponsor, }) => { const [showDescription, setShowDescription] = useState(false); @@ -87,7 +97,26 @@ export const DecideMode: React.FC = ({ -
+
+ + + + + + + + {isRescoring ? "Recalculating..." : "Recalculate match"} + + + + {jobLink ? ( ); }; - diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx new file mode 100644 index 0000000..ed85336 --- /dev/null +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx @@ -0,0 +1,135 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { DiscoveredPanel } from "./DiscoveredPanel"; +import type { Job } from "../../../shared/types"; +import * as api from "../../api"; +import { toast } from "sonner"; + +vi.mock("@/components/ui/dropdown-menu", () => { + return { + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + onSelect, + ...props + }: { + children: React.ReactNode; + onSelect?: () => void; + }) => ( + + ), + DropdownMenuSeparator: () =>
, + }; +}); + +vi.mock("../../hooks/useSettings", () => ({ + useSettings: () => ({ showSponsorInfo: false }), +})); + +vi.mock("../../api", () => ({ + rescoreJob: vi.fn(), + skipJob: vi.fn(), + processJob: vi.fn(), + checkSponsor: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + message: vi.fn(), + }, +})); + +const createJob = (overrides: Partial = {}): Job => ({ + id: "job-2", + source: "linkedin", + sourceJobId: null, + jobUrlDirect: null, + datePosted: null, + title: "Backend Engineer", + employer: "Acme", + employerUrl: null, + jobUrl: "https://example.com/job", + applicationLink: "https://example.com/apply", + disciplines: null, + deadline: null, + salary: null, + location: "London", + degreeRequired: null, + starting: null, + jobDescription: "Build APIs", + status: "discovered", + suitabilityScore: 55, + suitabilityReason: "Ok fit", + 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-02T00:00:00Z", + ...overrides, +}); + +describe("DiscoveredPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("re-runs the fit assessment from the menu", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + const job = createJob(); + vi.mocked(api.rescoreJob).mockResolvedValue(job as Job); + + render( + + ); + + fireEvent.click(screen.getByRole("menuitem", { name: /recalculate match/i })); + + await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-2")); + expect(onJobUpdated).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith("Match recalculated"); + }); +}); diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx index 3065cec..25e8d24 100644 --- a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx @@ -7,6 +7,7 @@ import { DecideMode } from "./DecideMode"; import { EmptyState } from "./EmptyState"; import { ProcessingState } from "./ProcessingState"; import { TailorMode } from "./TailorMode"; +import { useRescoreJob } from "../../hooks/useRescoreJob"; type PanelMode = "decide" | "tailor"; @@ -24,6 +25,7 @@ export const DiscoveredPanel: React.FC = ({ const [mode, setMode] = useState("decide"); const [isSkipping, setIsSkipping] = useState(false); const [isFinalizing, setIsFinalizing] = useState(false); + const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated); useEffect(() => { setMode("decide"); @@ -69,6 +71,8 @@ export const DiscoveredPanel: React.FC = ({ } }; + const handleRescore = () => rescoreJob(job?.id); + if (!job) { return ; } @@ -85,6 +89,8 @@ export const DiscoveredPanel: React.FC = ({ onTailor={() => setMode("tailor")} onSkip={handleSkip} isSkipping={isSkipping} + onRescore={handleRescore} + isRescoring={isRescoring} onCheckSponsor={async () => { await api.checkSponsor(job.id); await onJobUpdated(); diff --git a/orchestrator/src/client/hooks/useRescoreJob.test.ts b/orchestrator/src/client/hooks/useRescoreJob.test.ts new file mode 100644 index 0000000..d711ccc --- /dev/null +++ b/orchestrator/src/client/hooks/useRescoreJob.test.ts @@ -0,0 +1,38 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useRescoreJob } from "./useRescoreJob"; +import * as api from "../api"; +import { toast } from "sonner"; + +vi.mock("../api", () => ({ + rescoreJob: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe("useRescoreJob", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rescoring updates the job and shows a toast", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + vi.mocked(api.rescoreJob).mockResolvedValue({} as any); + + const { result } = renderHook(() => useRescoreJob(onJobUpdated)); + + await act(async () => { + await result.current.rescoreJob("job-1"); + }); + + expect(api.rescoreJob).toHaveBeenCalledWith("job-1"); + expect(onJobUpdated).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith("Match recalculated"); + }); +}); diff --git a/orchestrator/src/client/hooks/useRescoreJob.ts b/orchestrator/src/client/hooks/useRescoreJob.ts new file mode 100644 index 0000000..2ebb44d --- /dev/null +++ b/orchestrator/src/client/hooks/useRescoreJob.ts @@ -0,0 +1,29 @@ +import { useCallback, useState } from "react"; +import { toast } from "sonner"; + +import * as api from "../api"; + +export function useRescoreJob(onJobUpdated: () => void | Promise) { + const [isRescoring, setIsRescoring] = useState(false); + + const rescoreJob = useCallback( + async (jobId?: string | null) => { + if (!jobId) return; + + try { + setIsRescoring(true); + await api.rescoreJob(jobId); + toast.success("Match recalculated"); + await onJobUpdated(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to recalculate match"; + toast.error(message); + } finally { + setIsRescoring(false); + } + }, + [onJobUpdated], + ); + + return { isRescoring, rescoreJob }; +} diff --git a/orchestrator/src/server/api/routes/jobs.test.ts b/orchestrator/src/server/api/routes/jobs.test.ts index 7fba136..7ad5d6e 100644 --- a/orchestrator/src/server/api/routes/jobs.test.ts +++ b/orchestrator/src/server/api/routes/jobs.test.ts @@ -96,6 +96,32 @@ describe.sequential('Jobs API routes', () => { ); }); + it('rescoring a job updates the suitability fields', async () => { + const { createJob } = await import('../../repositories/jobs.js'); + const { scoreJobSuitability } = await import('../../services/scorer.js'); + const { getProfile } = await import('../../services/profile.js'); + + vi.mocked(getProfile).mockResolvedValue({}); + vi.mocked(scoreJobSuitability).mockResolvedValue({ score: 77, reason: 'Updated fit' }); + + const job = await createJob({ + source: 'manual', + title: 'Test Role', + employer: 'Acme', + jobUrl: 'https://example.com/job/5', + jobDescription: 'Test description', + suitabilityScore: 55, + suitabilityReason: 'Old fit', + }); + + const res = await fetch(`${baseUrl}/api/jobs/${job.id}/rescore`, { method: 'POST' }); + const body = await res.json(); + + expect(body.success).toBe(true); + expect(body.data.suitabilityScore).toBe(77); + expect(body.data.suitabilityReason).toBe('Updated fit'); + }); + it('checks visa sponsor status for a job', async () => { const { searchSponsors } = await import('../../services/visa-sponsors/index.js'); vi.mocked(searchSponsors).mockReturnValue([ diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts index 366cbcc..77b9003 100644 --- a/orchestrator/src/server/api/routes/jobs.ts +++ b/orchestrator/src/server/api/routes/jobs.ts @@ -4,6 +4,8 @@ import * as jobsRepo from '../../repositories/jobs.js'; import * as settingsRepo from '../../repositories/settings.js'; import { processJob, summarizeJob, generateFinalPdf } from '../../pipeline/index.js'; import { createNotionEntry } from '../../services/notion.js'; +import { scoreJobSuitability } from '../../services/scorer.js'; +import { getProfile } from '../../services/profile.js'; import * as visaSponsors from '../../services/visa-sponsors/index.js'; import type { Job, JobStatus, ApiResponse, JobsListResponse } from '../../../shared/types.js'; @@ -139,6 +141,40 @@ jobsRouter.post('/:id/summarize', async (req: Request, res: Response) => { } }); +/** + * POST /api/jobs/:id/rescore - Regenerate suitability score + reason + */ +jobsRouter.post('/:id/rescore', async (req: Request, res: Response) => { + try { + const job = await jobsRepo.getJobById(req.params.id); + + if (!job) { + return res.status(404).json({ success: false, error: 'Job not found' }); + } + + const rawProfile = await getProfile(); + if (!rawProfile || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) { + return res.status(400).json({ success: false, error: 'Invalid resume profile format' }); + } + + const { score, reason } = await scoreJobSuitability(job, rawProfile as Record); + + const updatedJob = await jobsRepo.updateJob(job.id, { + suitabilityScore: score, + suitabilityReason: reason, + }); + + if (!updatedJob) { + return res.status(404).json({ success: false, error: 'Job not found' }); + } + + res.json({ success: true, data: updatedJob }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); + } +}); + /** * POST /api/jobs/:id/check-sponsor - Check if employer is a visa sponsor */ diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts index 8787a9d..7750b68 100644 --- a/orchestrator/src/server/api/routes/test-utils.ts +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -45,6 +45,10 @@ vi.mock('../../services/scorer.js', () => ({ scoreJobSuitability: vi.fn(), })); +vi.mock('../../services/profile.js', () => ({ + getProfile: vi.fn().mockResolvedValue({}), +})); + vi.mock('../../services/ukvisajobs.js', () => ({ fetchUkVisaJobsPage: vi.fn(), }));