initial commit
This commit is contained in:
parent
e83cff7a12
commit
35365b5fca
@ -85,6 +85,12 @@ export async function processJob(id: string, options?: { force?: boolean }): Pro
|
||||
});
|
||||
}
|
||||
|
||||
export async function rescoreJob(id: string): Promise<Job> {
|
||||
return fetchApi<Job>(`/jobs/${id}/rescore`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function summarizeJob(id: string, options?: { force?: boolean }): Promise<Job> {
|
||||
const query = options?.force ? '?force=1' : '';
|
||||
return fetchApi<Job>(`/jobs/${id}/summarize${query}`, {
|
||||
|
||||
140
orchestrator/src/client/components/ReadyPanel.test.tsx
Normal file
140
orchestrator/src/client/components/ReadyPanel.test.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
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";
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
}) => (
|
||||
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <div role="separator" />,
|
||||
};
|
||||
});
|
||||
|
||||
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> = {}): 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(
|
||||
<ReadyPanel
|
||||
job={job}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /re-run fit assessment/i }));
|
||||
|
||||
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1"));
|
||||
expect(onJobUpdated).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -67,6 +67,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
const [mode, setMode] = useState<PanelMode>("ready");
|
||||
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [isRescoring, setIsRescoring] = useState(false);
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [recentlyApplied, setRecentlyApplied] = useState<{
|
||||
jobId: string;
|
||||
@ -181,6 +182,22 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
}
|
||||
}, [job, onJobUpdated]);
|
||||
|
||||
const handleRescore = useCallback(async () => {
|
||||
if (!job) return;
|
||||
|
||||
try {
|
||||
setIsRescoring(true);
|
||||
await api.rescoreJob(job.id);
|
||||
toast.success("Fit assessment updated");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to re-run fit assessment";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsRescoring(false);
|
||||
}
|
||||
}, [job, onJobUpdated]);
|
||||
|
||||
const handleSkip = useCallback(async () => {
|
||||
if (!job) return;
|
||||
|
||||
@ -385,6 +402,14 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
{isRegenerating ? "Regenerating..." : "Regenerate PDF"}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={handleRescore}
|
||||
disabled={isRescoring}
|
||||
>
|
||||
<RefreshCcw className={cn("mr-2 h-4 w-4", isRescoring && "animate-spin")} />
|
||||
{isRescoring ? "Re-scoring..." : "Re-run fit assessment"}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Utility actions */}
|
||||
|
||||
@ -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<void>;
|
||||
}
|
||||
|
||||
@ -22,6 +30,8 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
onTailor,
|
||||
onSkip,
|
||||
isSkipping,
|
||||
onRescore,
|
||||
isRescoring,
|
||||
onCheckSponsor,
|
||||
}) => {
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
@ -87,7 +97,26 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
|
||||
<Separator className='opacity-40' />
|
||||
|
||||
<div className='pt-6 pb-2'>
|
||||
<div className='pt-4 pb-2 space-y-4'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-8 gap-2 text-xs text-muted-foreground hover:text-foreground justify-center"
|
||||
>
|
||||
More actions
|
||||
<ChevronUp className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-56">
|
||||
<DropdownMenuItem onSelect={onRescore} disabled={isRescoring}>
|
||||
<RefreshCcw className={isRescoring ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"} />
|
||||
{isRescoring ? "Re-scoring..." : "Re-run fit assessment"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{jobLink ? (
|
||||
<div className='flex justify-center'>
|
||||
<a
|
||||
@ -105,4 +134,3 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,133 @@
|
||||
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";
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
}) => (
|
||||
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <div role="separator" />,
|
||||
};
|
||||
});
|
||||
|
||||
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> = {}): 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(
|
||||
<DiscoveredPanel
|
||||
job={job}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /re-run fit assessment/i }));
|
||||
|
||||
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-2"));
|
||||
expect(onJobUpdated).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -24,11 +24,13 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
const [mode, setMode] = useState<PanelMode>("decide");
|
||||
const [isSkipping, setIsSkipping] = useState(false);
|
||||
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||
const [isRescoring, setIsRescoring] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMode("decide");
|
||||
setIsSkipping(false);
|
||||
setIsFinalizing(false);
|
||||
setIsRescoring(false);
|
||||
}, [job?.id]);
|
||||
|
||||
const handleSkip = async () => {
|
||||
@ -69,6 +71,22 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRescore = async () => {
|
||||
if (!job) return;
|
||||
try {
|
||||
setIsRescoring(true);
|
||||
await api.rescoreJob(job.id);
|
||||
toast.success("Fit assessment updated");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to re-run fit assessment";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsRescoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!job) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
@ -85,6 +103,8 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
onTailor={() => setMode("tailor")}
|
||||
onSkip={handleSkip}
|
||||
isSkipping={isSkipping}
|
||||
onRescore={handleRescore}
|
||||
isRescoring={isRescoring}
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(job.id);
|
||||
await onJobUpdated();
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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<string, unknown>);
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
@ -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(),
|
||||
}));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user