Merge pull request #12 from DaKheera47/show-sponsor-information-in-ui
Show sponsor information in UI
This commit is contained in:
commit
5ac6ca3b24
76
orchestrator/package-lock.json
generated
76
orchestrator/package-lock.json
generated
@ -17,6 +17,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -2231,6 +2232,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
@ -2359,6 +2412,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@ -86,6 +86,12 @@ export async function generateJobPdf(id: string): Promise<Job> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkSponsor(id: string): Promise<Job> {
|
||||
return fetchApi<Job>(`/jobs/${id}/check-sponsor`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function markAsApplied(id: string): Promise<Job> {
|
||||
return fetchApi<Job>(`/jobs/${id}/apply`, {
|
||||
method: 'POST',
|
||||
@ -186,6 +192,7 @@ export async function updateSettings(update: {
|
||||
jobspyCountryIndeed?: string | null
|
||||
jobspySites?: string[] | null
|
||||
jobspyLinkedinFetchDescription?: boolean | null
|
||||
showSponsorInfo?: boolean | null
|
||||
}): Promise<AppSettings> {
|
||||
return fetchApi<AppSettings>('/settings', {
|
||||
method: 'PATCH',
|
||||
|
||||
104
orchestrator/src/client/components/JobHeader.test.tsx
Normal file
104
orchestrator/src/client/components/JobHeader.test.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { JobHeader } from "./JobHeader";
|
||||
import { useSettings } from "../hooks/useSettings";
|
||||
import type { Job } from "../../shared/types";
|
||||
|
||||
// Mock useSettings
|
||||
vi.mock("../hooks/useSettings", () => ({
|
||||
useSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock api
|
||||
vi.mock("../api", () => ({
|
||||
checkSponsor: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock Tooltip components to simplify testing
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-content">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockJob: Job = {
|
||||
id: "job-1",
|
||||
title: "Software Engineer",
|
||||
employer: "Tech Corp",
|
||||
location: "London",
|
||||
salary: "£60,000",
|
||||
deadline: "2025-12-31",
|
||||
status: "discovered",
|
||||
source: "linkedin",
|
||||
suitabilityScore: 85,
|
||||
suitabilityReason: "Strong match",
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
// Other fields...
|
||||
} as Job;
|
||||
|
||||
describe("JobHeader", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(useSettings as any).mockReturnValue({
|
||||
showSponsorInfo: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders basic job information", () => {
|
||||
render(<JobHeader job={mockJob} />);
|
||||
expect(screen.getByText("Software Engineer")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tech Corp")).toBeInTheDocument();
|
||||
expect(screen.getByText("London")).toBeInTheDocument();
|
||||
expect(screen.getByText("£60,000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => {
|
||||
const onCheckSponsor = vi.fn().mockResolvedValue(undefined);
|
||||
render(<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />);
|
||||
|
||||
const button = screen.getByText("Check Sponsorship Status");
|
||||
expect(button).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onCheckSponsor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows 'Confirmed Sponsor' when score >= 95", () => {
|
||||
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98, sponsorMatchNames: '["Tech Corp Ltd"]' };
|
||||
render(<JobHeader job={jobWithSponsor} />);
|
||||
|
||||
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Potential Sponsor' when score is between 80 and 94", () => {
|
||||
const jobWithPotential = { ...mockJob, sponsorMatchScore: 85, sponsorMatchNames: '["Techy Corp"]' };
|
||||
render(<JobHeader job={jobWithPotential} />);
|
||||
|
||||
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Sponsor Not Found' when score < 80", () => {
|
||||
const jobNoSponsor = { ...mockJob, sponsorMatchScore: 40, sponsorMatchNames: '["Other Corp"]' };
|
||||
render(<JobHeader job={jobNoSponsor} />);
|
||||
|
||||
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides sponsor info when showSponsorInfo is false", () => {
|
||||
(useSettings as any).mockReturnValue({
|
||||
showSponsorInfo: false,
|
||||
});
|
||||
|
||||
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
|
||||
render(<JobHeader job={jobWithSponsor} />);
|
||||
|
||||
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Check Sponsorship Status")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -1,13 +1,18 @@
|
||||
import React from "react";
|
||||
import { Calendar, DollarSign, MapPin } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn, formatDate, sourceLabel } from "@/lib/utils";
|
||||
import type { Job, JobStatus } from "../../shared/types";
|
||||
import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants";
|
||||
|
||||
import { useSettings } from "../hooks/useSettings";
|
||||
|
||||
interface JobHeaderProps {
|
||||
job: Job;
|
||||
className?: string;
|
||||
onCheckSponsor?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
|
||||
@ -42,7 +47,101 @@ const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
|
||||
interface SponsorPillProps {
|
||||
score: number | null;
|
||||
names: string | null;
|
||||
onCheck?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
const parsedNames = useMemo(() => {
|
||||
if (!names) return [];
|
||||
try {
|
||||
return JSON.parse(names) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [names]);
|
||||
|
||||
const handleCheck = async () => {
|
||||
if (!onCheck) return;
|
||||
setIsChecking(true);
|
||||
try {
|
||||
await onCheck();
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show "Check" button if no score and callback provided
|
||||
if (score == null && onCheck) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 px-1.5 text-xs font-medium text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
onClick={handleCheck}
|
||||
disabled={isChecking}
|
||||
>
|
||||
{isChecking ? (
|
||||
<Loader2 className="h-2 w-2 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-2 w-2" />
|
||||
)}
|
||||
<span>{isChecking ? "Checking..." : "Check Sponsorship Status"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">Check if employer is a visa sponsor</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (score == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getStatus = (s: number) => {
|
||||
if (s >= 95) return { label: "Confirmed Sponsor", dot: "bg-emerald-500", color: "text-emerald-400" };
|
||||
if (s >= 80) return { label: "Potential Sponsor", dot: "bg-amber-500", color: "text-amber-400" };
|
||||
return { label: "Sponsor Not Found", dot: "bg-slate-500", color: "text-slate-400" };
|
||||
};
|
||||
|
||||
const status = getStatus(score);
|
||||
const tooltipContent = `${score}% match`;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80 cursor-help">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", status.dot)} />
|
||||
{status.label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{parsedNames.length > 0 && (
|
||||
<p className="text-xs font-medium space-x-1">
|
||||
<span className="opacity-70">Matched</span>
|
||||
<span>{parsedNames.join(", ")}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="opacity-80 mt-1 text-[10px]">{tooltipContent}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className, onCheckSponsor }) => {
|
||||
const { showSponsorInfo } = useSettings();
|
||||
const deadline = formatDate(job.deadline);
|
||||
|
||||
return (
|
||||
@ -51,7 +150,9 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold text-foreground/90">{job.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{job.employer}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{job.employer}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
|
||||
{sourceLabel[job.source]}
|
||||
@ -82,7 +183,16 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
|
||||
|
||||
{/* Status and score: single line, subdued */}
|
||||
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
|
||||
<StatusPill status={job.status} />
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusPill status={job.status} />
|
||||
{showSponsorInfo && (
|
||||
<SponsorPill
|
||||
score={job.sponsorMatchScore}
|
||||
names={job.sponsorMatchNames}
|
||||
onCheck={onCheckSponsor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ScoreMeter score={job.suitabilityScore} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -141,7 +141,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
// Revert to ready status
|
||||
await api.updateJob(jobId, { status: "ready" });
|
||||
toast.success("Reverted to Ready");
|
||||
|
||||
|
||||
if (recentlyApplied?.timeoutId) {
|
||||
clearTimeout(recentlyApplied.timeoutId);
|
||||
}
|
||||
@ -215,7 +215,14 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<JobHeader job={job} className="pb-4 border-b border-border/40" />
|
||||
<JobHeader
|
||||
job={job}
|
||||
className="pb-4 border-b border-border/40"
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(job.id);
|
||||
await onJobUpdated();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ─────────────────────────────────────────────────────────────────────
|
||||
PRIMARY ACTION CLUSTER
|
||||
|
||||
@ -14,6 +14,7 @@ interface DecideModeProps {
|
||||
onTailor: () => void;
|
||||
onSkip: () => void;
|
||||
isSkipping: boolean;
|
||||
onCheckSponsor?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
@ -21,6 +22,7 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
onTailor,
|
||||
onSkip,
|
||||
isSkipping,
|
||||
onCheckSponsor,
|
||||
}) => {
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
const jobLink = job.applicationLink || job.jobUrl;
|
||||
@ -33,7 +35,10 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
return (
|
||||
<div className='flex flex-col h-full'>
|
||||
<div className='space-y-4 pb-4'>
|
||||
<JobHeader job={job} />
|
||||
<JobHeader
|
||||
job={job}
|
||||
onCheckSponsor={onCheckSponsor}
|
||||
/>
|
||||
|
||||
<div className='flex flex-col gap-2.5 pt-2 sm:flex-row'>
|
||||
<Button
|
||||
|
||||
@ -85,6 +85,10 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
onTailor={() => setMode("tailor")}
|
||||
onSkip={handleSkip}
|
||||
isSkipping={isSkipping}
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(job.id);
|
||||
await onJobUpdated();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TailorMode
|
||||
|
||||
80
orchestrator/src/client/hooks/useSettings.test.ts
Normal file
80
orchestrator/src/client/hooks/useSettings.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useSettings, _resetSettingsCache } from './useSettings';
|
||||
import * as api from '../api';
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
getSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_resetSettingsCache();
|
||||
});
|
||||
|
||||
it('fetches settings on mount if not already cached', async () => {
|
||||
const mockSettings = { showSponsorInfo: false };
|
||||
(api.getSettings as any).mockResolvedValue(mockSettings);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
// Should start in loading state
|
||||
expect(result.current.settings).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settings).toEqual(mockSettings);
|
||||
});
|
||||
|
||||
expect(result.current.showSponsorInfo).toBe(false);
|
||||
expect(api.getSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses default values when settings are null', async () => {
|
||||
(api.getSettings as any).mockResolvedValue(null);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
// settings is null, so showSponsorInfo should default to true
|
||||
expect(result.current.showSponsorInfo).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('provides a refresh function that updates settings', async () => {
|
||||
const initialSettings = { showSponsorInfo: true };
|
||||
const updatedSettings = { showSponsorInfo: false };
|
||||
|
||||
(api.getSettings as any).mockResolvedValueOnce(initialSettings);
|
||||
(api.getSettings as any).mockResolvedValueOnce(updatedSettings);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settings).toEqual(initialSettings);
|
||||
});
|
||||
|
||||
let refreshed;
|
||||
await waitFor(async () => {
|
||||
refreshed = await result.current.refreshSettings();
|
||||
});
|
||||
|
||||
expect(refreshed).toEqual(updatedSettings);
|
||||
expect(result.current.settings).toEqual(updatedSettings);
|
||||
expect(result.current.showSponsorInfo).toBe(false);
|
||||
});
|
||||
|
||||
it('handles errors when fetching settings', async () => {
|
||||
const mockError = new Error('Failed to fetch');
|
||||
(api.getSettings as any).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.settings).toBeNull();
|
||||
});
|
||||
});
|
||||
87
orchestrator/src/client/hooks/useSettings.ts
Normal file
87
orchestrator/src/client/hooks/useSettings.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AppSettings } from '../../shared/types';
|
||||
import * as api from '../api';
|
||||
|
||||
let settingsCache: AppSettings | null = null;
|
||||
let settingsError: Error | null = null;
|
||||
let subscribers: Set<(settings: AppSettings | null, error: Error | null) => void> = new Set();
|
||||
let isFetching = false;
|
||||
|
||||
export function useSettings() {
|
||||
const [settings, setSettings] = useState<AppSettings | null>(settingsCache);
|
||||
const [error, setError] = useState<Error | null>(settingsError);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsCache) {
|
||||
setSettings(settingsCache);
|
||||
}
|
||||
if (settingsError) {
|
||||
setError(settingsError);
|
||||
}
|
||||
|
||||
const handleUpdate = (newSettings: AppSettings | null, newError: Error | null) => {
|
||||
setSettings(newSettings);
|
||||
setError(newError);
|
||||
};
|
||||
|
||||
subscribers.add(handleUpdate);
|
||||
|
||||
if (!settingsCache && !isFetching) {
|
||||
isFetching = true;
|
||||
settingsError = null;
|
||||
api.getSettings()
|
||||
.then((data) => {
|
||||
settingsCache = data;
|
||||
settingsError = null;
|
||||
subscribers.forEach(sub => sub(data, null));
|
||||
})
|
||||
.catch((err) => {
|
||||
settingsError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach(sub => sub(settingsCache, settingsError));
|
||||
})
|
||||
.finally(() => {
|
||||
isFetching = false;
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscribers.delete(handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshSettings = async () => {
|
||||
isFetching = true;
|
||||
settingsError = null;
|
||||
subscribers.forEach(sub => sub(settingsCache, null));
|
||||
|
||||
try {
|
||||
const data = await api.getSettings();
|
||||
settingsCache = data;
|
||||
settingsError = null;
|
||||
subscribers.forEach(sub => sub(data, null));
|
||||
return data;
|
||||
} catch (err) {
|
||||
settingsError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach(sub => sub(settingsCache, settingsError));
|
||||
throw settingsError;
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
error,
|
||||
isLoading: !settings && isFetching && !error,
|
||||
showSponsorInfo: settings?.showSponsorInfo ?? true,
|
||||
refreshSettings,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetSettingsCache() {
|
||||
settingsCache = null;
|
||||
settingsError = null;
|
||||
isFetching = false;
|
||||
subscribers.clear();
|
||||
}
|
||||
@ -33,6 +33,8 @@ const jobFixture: Job = {
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
@ -198,7 +200,7 @@ describe("OrchestratorPage", () => {
|
||||
// Clicking job-2 should update URL
|
||||
const job2Button = screen.getByTestId("select-job-2");
|
||||
fireEvent.click(job2Button);
|
||||
|
||||
|
||||
// Wait for URL to update
|
||||
await waitFor(() => {
|
||||
expect(locationText()).toContain("/all/job-2");
|
||||
|
||||
@ -92,6 +92,9 @@ const baseSettings: AppSettings = {
|
||||
jobspyLinkedinFetchDescription: true,
|
||||
defaultJobspyLinkedinFetchDescription: true,
|
||||
overrideJobspyLinkedinFetchDescription: null,
|
||||
showSponsorInfo: true,
|
||||
defaultShowSponsorInfo: true,
|
||||
overrideShowSponsorInfo: null,
|
||||
}
|
||||
|
||||
const renderPage = () => {
|
||||
|
||||
@ -14,6 +14,7 @@ import * as api from "../api"
|
||||
import { arraysEqual } from "@/lib/utils"
|
||||
import { resumeProjectsEqual } from "./settings/utils"
|
||||
import { DangerZoneSection } from "./settings/components/DangerZoneSection"
|
||||
import { DisplaySettingsSection } from "./settings/components/DisplaySettingsSection"
|
||||
import { GradcrackerSection } from "./settings/components/GradcrackerSection"
|
||||
import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection"
|
||||
import { JobspySection } from "./settings/components/JobspySection"
|
||||
@ -41,6 +42,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
|
||||
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
|
||||
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
|
||||
const [showSponsorInfoDraft, setShowSponsorInfoDraft] = useState<boolean | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered'])
|
||||
@ -69,6 +71,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setJobspyCountryIndeedDraft(data.overrideJobspyCountryIndeed)
|
||||
setJobspySitesDraft(data.overrideJobspySites)
|
||||
setJobspyLinkedinFetchDescriptionDraft(data.overrideJobspyLinkedinFetchDescription)
|
||||
setShowSponsorInfoDraft(data.overrideShowSponsorInfo)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "Failed to load settings"
|
||||
@ -126,6 +129,9 @@ export const SettingsPage: React.FC = () => {
|
||||
const effectiveJobspyLinkedinFetchDescription = settings?.jobspyLinkedinFetchDescription ?? true
|
||||
const defaultJobspyLinkedinFetchDescription = settings?.defaultJobspyLinkedinFetchDescription ?? true
|
||||
const overrideJobspyLinkedinFetchDescription = settings?.overrideJobspyLinkedinFetchDescription
|
||||
const effectiveShowSponsorInfo = settings?.showSponsorInfo ?? true
|
||||
const defaultShowSponsorInfo = settings?.defaultShowSponsorInfo ?? true
|
||||
const overrideShowSponsorInfo = settings?.overrideShowSponsorInfo
|
||||
const profileProjects = settings?.profileProjects ?? []
|
||||
const maxProjectsTotal = profileProjects.length
|
||||
const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0
|
||||
@ -163,7 +169,8 @@ export const SettingsPage: React.FC = () => {
|
||||
jobspyHoursOldDraft !== (overrideJobspyHoursOld ?? null) ||
|
||||
jobspyCountryIndeedDraft !== (overrideJobspyCountryIndeed ?? null) ||
|
||||
JSON.stringify((jobspySitesDraft ?? []).slice().sort()) !== JSON.stringify((overrideJobspySites ?? []).slice().sort()) ||
|
||||
jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null)
|
||||
jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null) ||
|
||||
showSponsorInfoDraft !== (overrideShowSponsorInfo ?? null)
|
||||
)
|
||||
}, [
|
||||
settings,
|
||||
@ -192,12 +199,14 @@ export const SettingsPage: React.FC = () => {
|
||||
jobspyCountryIndeedDraft,
|
||||
jobspySitesDraft,
|
||||
jobspyLinkedinFetchDescriptionDraft,
|
||||
showSponsorInfoDraft,
|
||||
overrideJobspyLocation,
|
||||
overrideJobspyResultsWanted,
|
||||
overrideJobspyHoursOld,
|
||||
overrideJobspyCountryIndeed,
|
||||
overrideJobspySites,
|
||||
overrideJobspyLinkedinFetchDescription,
|
||||
overrideShowSponsorInfo,
|
||||
])
|
||||
|
||||
const handleSave = async () => {
|
||||
@ -222,6 +231,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const jobspyCountryIndeedOverride = jobspyCountryIndeedDraft === defaultJobspyCountryIndeed ? null : jobspyCountryIndeedDraft
|
||||
const jobspySitesOverride = arraysEqual((jobspySitesDraft ?? []).slice().sort(), (defaultJobspySites ?? []).slice().sort()) ? null : jobspySitesDraft
|
||||
const jobspyLinkedinFetchDescriptionOverride = jobspyLinkedinFetchDescriptionDraft === defaultJobspyLinkedinFetchDescription ? null : jobspyLinkedinFetchDescriptionDraft
|
||||
const showSponsorInfoOverride = showSponsorInfoDraft === defaultShowSponsorInfo ? null : showSponsorInfoDraft
|
||||
const updated = await api.updateSettings({
|
||||
model: trimmed.length > 0 ? trimmed : null,
|
||||
modelScorer: trimmedScorer.length > 0 ? trimmedScorer : null,
|
||||
@ -239,6 +249,7 @@ export const SettingsPage: React.FC = () => {
|
||||
jobspyCountryIndeed: jobspyCountryIndeedOverride,
|
||||
jobspySites: jobspySitesOverride,
|
||||
jobspyLinkedinFetchDescription: jobspyLinkedinFetchDescriptionOverride,
|
||||
showSponsorInfo: showSponsorInfoOverride,
|
||||
})
|
||||
setSettings(updated)
|
||||
setModelDraft(updated.overrideModel ?? "")
|
||||
@ -257,6 +268,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setJobspyCountryIndeedDraft(updated.overrideJobspyCountryIndeed)
|
||||
setJobspySitesDraft(updated.overrideJobspySites)
|
||||
setJobspyLinkedinFetchDescriptionDraft(updated.overrideJobspyLinkedinFetchDescription)
|
||||
setShowSponsorInfoDraft(updated.overrideShowSponsorInfo)
|
||||
toast.success("Settings saved")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to save settings"
|
||||
@ -340,6 +352,7 @@ export const SettingsPage: React.FC = () => {
|
||||
jobspyCountryIndeed: null,
|
||||
jobspySites: null,
|
||||
jobspyLinkedinFetchDescription: null,
|
||||
showSponsorInfo: null,
|
||||
})
|
||||
setSettings(updated)
|
||||
setModelDraft("")
|
||||
@ -358,6 +371,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setJobspyCountryIndeedDraft(null)
|
||||
setJobspySitesDraft(null)
|
||||
setJobspyLinkedinFetchDescriptionDraft(null)
|
||||
setShowSponsorInfoDraft(null)
|
||||
toast.success("Reset to default")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to reset settings"
|
||||
@ -471,6 +485,14 @@ export const SettingsPage: React.FC = () => {
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<DisplaySettingsSection
|
||||
showSponsorInfoDraft={showSponsorInfoDraft}
|
||||
setShowSponsorInfoDraft={setShowSponsorInfoDraft}
|
||||
defaultShowSponsorInfo={defaultShowSponsorInfo}
|
||||
effectiveShowSponsorInfo={effectiveShowSponsorInfo}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<DangerZoneSection
|
||||
statusesToClear={statusesToClear}
|
||||
toggleStatusToClear={toggleStatusToClear}
|
||||
|
||||
@ -31,6 +31,9 @@ vi.mock("../../components", () => ({
|
||||
DiscoveredPanel: ({ job }: { job: Job | null }) => (
|
||||
<div data-testid="discovered-panel">{job?.id ?? "no-job"}</div>
|
||||
),
|
||||
JobHeader: () => <div data-testid="job-header" />,
|
||||
FitAssessment: () => <div data-testid="fit-assessment" />,
|
||||
TailoredSummary: () => <div data-testid="tailored-summary" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ReadyPanel", () => ({
|
||||
@ -100,6 +103,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
|
||||
@ -269,7 +269,13 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<JobHeader job={selectedJob} />
|
||||
<JobHeader
|
||||
job={selectedJob}
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(selectedJob.id);
|
||||
await onJobUpdated();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
||||
|
||||
@ -31,6 +31,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type DisplaySettingsSectionProps = {
|
||||
showSponsorInfoDraft: boolean | null
|
||||
setShowSponsorInfoDraft: (value: boolean | null) => void
|
||||
defaultShowSponsorInfo: boolean
|
||||
effectiveShowSponsorInfo: boolean
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
|
||||
showSponsorInfoDraft,
|
||||
setShowSponsorInfoDraft,
|
||||
defaultShowSponsorInfo,
|
||||
effectiveShowSponsorInfo,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const isChecked = showSponsorInfoDraft ?? defaultShowSponsorInfo
|
||||
|
||||
return (
|
||||
<AccordionItem value="display" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Display Settings</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="showSponsorInfo"
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
setShowSponsorInfoDraft(checked === "indeterminate" ? null : checked === true)
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="showSponsorInfo"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Show visa sponsor information
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Display a badge next to the employer name showing the match
|
||||
percentage with the UK visa sponsor list. This helps identify
|
||||
employers that are licensed to sponsor work visas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="break-words font-mono text-xs">
|
||||
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">
|
||||
{defaultShowSponsorInfo ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
32
orchestrator/src/components/ui/tooltip.tsx
Normal file
32
orchestrator/src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@ -70,7 +70,7 @@ describe.sequential('Jobs API routes', () => {
|
||||
|
||||
it('applies a job and syncs to Notion', async () => {
|
||||
const { createNotionEntry } = await import('../../services/notion.js');
|
||||
vi.mocked(createNotionEntry).mockResolvedValue({ pageId: 'page-123' });
|
||||
vi.mocked(createNotionEntry).mockResolvedValue({ success: true, pageId: 'page-123' });
|
||||
|
||||
const { createJob } = await import('../../repositories/jobs.js');
|
||||
const job = await createJob({
|
||||
@ -95,4 +95,26 @@ describe.sequential('Jobs API routes', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('checks visa sponsor status for a job', async () => {
|
||||
const { searchSponsors } = await import('../../services/visa-sponsors/index.js');
|
||||
vi.mocked(searchSponsors).mockReturnValue([
|
||||
{ sponsor: { organisationName: 'ACME CORP SPONSOR' } as any, score: 100, matchedName: 'acme corp sponsor' }
|
||||
]);
|
||||
|
||||
const { createJob } = await import('../../repositories/jobs.js');
|
||||
const job = await createJob({
|
||||
source: 'manual',
|
||||
title: 'Sponsored Dev',
|
||||
employer: 'Acme',
|
||||
jobUrl: 'https://example.com/job/4',
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/jobs/${job.id}/check-sponsor`, { method: 'POST' });
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.sponsorMatchScore).toBe(100);
|
||||
expect(body.data.sponsorMatchNames).toContain('ACME CORP SPONSOR');
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ 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 * as visaSponsors from '../../services/visa-sponsors/index.js';
|
||||
import type { Job, JobStatus, ApiResponse, JobsListResponse } from '../../../shared/types.js';
|
||||
|
||||
export const jobsRouter = Router();
|
||||
@ -47,6 +48,8 @@ const updateJobSchema = z.object({
|
||||
tailoredSummary: z.string().optional(),
|
||||
selectedProjectIds: z.string().optional(),
|
||||
pdfPath: z.string().optional(),
|
||||
sponsorMatchScore: z.number().min(0).max(100).optional(),
|
||||
sponsorMatchNames: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@ -136,6 +139,49 @@ jobsRouter.post('/:id/summarize', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/check-sponsor - Check if employer is a visa sponsor
|
||||
*/
|
||||
jobsRouter.post('/:id/check-sponsor', 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' });
|
||||
}
|
||||
|
||||
if (!job.employer) {
|
||||
return res.status(400).json({ success: false, error: 'Job has no employer name' });
|
||||
}
|
||||
|
||||
// Search for sponsor matches
|
||||
const sponsorResults = visaSponsors.searchSponsors(job.employer, {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
|
||||
const { sponsorMatchScore, sponsorMatchNames } = visaSponsors.calculateSponsorMatchSummary(sponsorResults);
|
||||
|
||||
// Update job with sponsor match info
|
||||
const updatedJob = await jobsRepo.updateJob(job.id, {
|
||||
sponsorMatchScore: sponsorMatchScore,
|
||||
sponsorMatchNames: sponsorMatchNames ?? undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: updatedJob,
|
||||
matchResults: sponsorResults.slice(0, 5).map(r => ({
|
||||
name: r.sponsor.organisationName,
|
||||
score: r.score,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jobs/:id/generate-pdf - Generate PDF using current manual overrides
|
||||
*/
|
||||
|
||||
@ -54,11 +54,14 @@ describe.sequential('Pipeline API routes', () => {
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (reader) {
|
||||
const chunk = await reader.read();
|
||||
controller.abort();
|
||||
await reader.cancel();
|
||||
const text = new TextDecoder().decode(chunk.value);
|
||||
expect(text).toContain('data:');
|
||||
try {
|
||||
const { value } = await reader.read();
|
||||
const text = new TextDecoder().decode(value);
|
||||
expect(text).toContain('data:');
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
controller.abort();
|
||||
}
|
||||
} else {
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
@ -21,13 +21,13 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
|
||||
|
||||
// Specific AI models
|
||||
const overrideModelScorer = await settingsRepo.getSetting('modelScorer');
|
||||
const modelScorer = overrideModelScorer || model;
|
||||
const modelScorer = overrideModelScorer || model;
|
||||
|
||||
const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring');
|
||||
const modelTailoring = overrideModelTailoring || model;
|
||||
const modelTailoring = overrideModelTailoring || model;
|
||||
|
||||
const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection');
|
||||
const modelProjectSelection = overrideModelProjectSelection || model;
|
||||
const modelProjectSelection = overrideModelProjectSelection || model;
|
||||
|
||||
const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl');
|
||||
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
|
||||
@ -89,6 +89,14 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
|
||||
: null;
|
||||
const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription;
|
||||
|
||||
// Show Sponsor Info setting (on by default)
|
||||
const overrideShowSponsorInfoRaw = await settingsRepo.getSetting('showSponsorInfo');
|
||||
const defaultShowSponsorInfo = true;
|
||||
const overrideShowSponsorInfo = overrideShowSponsorInfoRaw
|
||||
? overrideShowSponsorInfoRaw === 'true' || overrideShowSponsorInfoRaw === '1'
|
||||
: null;
|
||||
const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@ -135,6 +143,9 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
|
||||
jobspyLinkedinFetchDescription,
|
||||
defaultJobspyLinkedinFetchDescription,
|
||||
overrideJobspyLinkedinFetchDescription,
|
||||
showSponsorInfo,
|
||||
defaultShowSponsorInfo,
|
||||
overrideShowSponsorInfo,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@ -164,6 +175,7 @@ const updateSettingsSchema = z.object({
|
||||
jobspyCountryIndeed: z.string().trim().min(1).max(100).nullable().optional(),
|
||||
jobspySites: z.array(z.string().trim().min(1).max(50)).max(10).nullable().optional(),
|
||||
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
|
||||
showSponsorInfo: z.boolean().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@ -263,15 +275,20 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||
await settingsRepo.setSetting('jobspyLinkedinFetchDescription', value !== null ? (value ? '1' : '0') : null);
|
||||
}
|
||||
|
||||
if ('showSponsorInfo' in input) {
|
||||
const value = input.showSponsorInfo ?? null;
|
||||
await settingsRepo.setSetting('showSponsorInfo', value !== null ? (value ? '1' : '0') : null);
|
||||
}
|
||||
|
||||
const overrideModel = await settingsRepo.getSetting('model');
|
||||
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
||||
const model = overrideModel || defaultModel;
|
||||
|
||||
const overrideModelScorer = await settingsRepo.getSetting('modelScorer');
|
||||
const modelScorer = overrideModelScorer || model;
|
||||
const modelScorer = overrideModelScorer || model;
|
||||
|
||||
const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring');
|
||||
const modelTailoring = overrideModelTailoring || model;
|
||||
const modelTailoring = overrideModelTailoring || model;
|
||||
|
||||
const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection');
|
||||
const modelProjectSelection = overrideModelProjectSelection || model;
|
||||
@ -337,6 +354,14 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||
: null;
|
||||
const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription;
|
||||
|
||||
// Show Sponsor Info setting
|
||||
const overrideShowSponsorInfoRaw = await settingsRepo.getSetting('showSponsorInfo');
|
||||
const defaultShowSponsorInfo = true;
|
||||
const overrideShowSponsorInfo = overrideShowSponsorInfoRaw
|
||||
? overrideShowSponsorInfoRaw === 'true' || overrideShowSponsorInfoRaw === '1'
|
||||
: null;
|
||||
const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@ -383,6 +408,9 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||
jobspyLinkedinFetchDescription,
|
||||
defaultJobspyLinkedinFetchDescription,
|
||||
overrideJobspyLinkedinFetchDescription,
|
||||
showSponsorInfo,
|
||||
defaultShowSponsorInfo,
|
||||
overrideShowSponsorInfo,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@ -28,7 +28,7 @@ vi.mock('../../pipeline/index.js', () => {
|
||||
getPipelineStatus: vi.fn(() => ({ isRunning: false })),
|
||||
subscribeToProgress: vi.fn((listener: (data: unknown) => void) => {
|
||||
listener(progress);
|
||||
return () => {};
|
||||
return () => { };
|
||||
}),
|
||||
};
|
||||
});
|
||||
@ -54,6 +54,13 @@ vi.mock('../../services/visa-sponsors/index.js', () => ({
|
||||
searchSponsors: vi.fn(),
|
||||
getOrganizationDetails: vi.fn(),
|
||||
downloadLatestCsv: vi.fn(),
|
||||
calculateSponsorMatchSummary: vi.fn((results) => {
|
||||
if (!results || results.length === 0) return { sponsorMatchScore: 0, sponsorMatchNames: null };
|
||||
return {
|
||||
sponsorMatchScore: results[0].score,
|
||||
sponsorMatchNames: JSON.stringify(results.map((r: any) => r.sponsor.organisationName))
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
@ -132,6 +132,10 @@ const migrations = [
|
||||
`ALTER TABLE jobs ADD COLUMN tailored_headline TEXT`,
|
||||
`ALTER TABLE jobs ADD COLUMN tailored_skills TEXT`,
|
||||
|
||||
// Add sponsor match columns for visa sponsor matching feature
|
||||
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
|
||||
`ALTER TABLE jobs ADD COLUMN sponsor_match_names TEXT`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started_at ON pipeline_runs(started_at)`,
|
||||
|
||||
@ -64,6 +64,8 @@ export const jobs = sqliteTable('jobs', {
|
||||
selectedProjectIds: text('selected_project_ids'),
|
||||
pdfPath: text('pdf_path'),
|
||||
notionPageId: text('notion_page_id'),
|
||||
sponsorMatchScore: real('sponsor_match_score'),
|
||||
sponsorMatchNames: text('sponsor_match_names'),
|
||||
|
||||
// Timestamps
|
||||
discoveredAt: text('discovered_at').notNull().default(sql`(datetime('now'))`),
|
||||
|
||||
@ -22,6 +22,7 @@ import { extractProjectsFromProfile, resolveResumeProjectsSettings } from '../se
|
||||
import * as jobsRepo from '../repositories/jobs.js';
|
||||
import * as pipelineRepo from '../repositories/pipeline.js';
|
||||
import * as settingsRepo from '../repositories/settings.js';
|
||||
import * as visaSponsors from '../services/visa-sponsors/index.js';
|
||||
import { progressHelpers, resetProgress, updateProgress } from './progress.js';
|
||||
import type { CreateJobInput, Job, JobSource, PipelineConfig } from '../../shared/types.js';
|
||||
import { getDataDir } from '../config/dataDir.js';
|
||||
@ -293,10 +294,27 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
suitabilityReason: reason,
|
||||
});
|
||||
|
||||
// Update score in database
|
||||
// Calculate sponsor match score using fuzzy search
|
||||
let sponsorMatchScore = 0;
|
||||
let sponsorMatchNames: string | undefined;
|
||||
|
||||
if (job.employer) {
|
||||
const sponsorResults = visaSponsors.searchSponsors(job.employer, {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
|
||||
const summary = visaSponsors.calculateSponsorMatchSummary(sponsorResults);
|
||||
sponsorMatchScore = summary.sponsorMatchScore;
|
||||
sponsorMatchNames = summary.sponsorMatchNames ?? undefined;
|
||||
}
|
||||
|
||||
// Update score and sponsor match in database
|
||||
await jobsRepo.updateJob(job.id, {
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
sponsorMatchScore,
|
||||
sponsorMatchNames,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
417
orchestrator/src/server/pipeline/sponsor-matching.test.ts
Normal file
417
orchestrator/src/server/pipeline/sponsor-matching.test.ts
Normal file
@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Tests for sponsor match calculation logic in the pipeline orchestrator.
|
||||
*
|
||||
* These tests verify that during job scoring, the sponsor matching functionality
|
||||
* correctly calculates and stores sponsor match scores and names.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Job } from '../../shared/types.js';
|
||||
|
||||
// Mock the visa-sponsors module
|
||||
vi.mock('../services/visa-sponsors/index.js', () => ({
|
||||
searchSponsors: vi.fn(),
|
||||
calculateSponsorMatchSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the scorer module
|
||||
vi.mock('../services/scorer.js', () => ({
|
||||
scoreJobSuitability: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the jobs repository
|
||||
vi.mock('../repositories/jobs.js', () => ({
|
||||
updateJob: vi.fn(),
|
||||
getUnscoredDiscoveredJobs: vi.fn(),
|
||||
getJobById: vi.fn(),
|
||||
bulkCreateJobs: vi.fn(),
|
||||
getAllJobUrls: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock other dependencies to prevent side effects
|
||||
vi.mock('../repositories/pipeline.js', () => ({
|
||||
createPipelineRun: vi.fn(() => ({ id: 'test-run-id' })),
|
||||
updatePipelineRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../repositories/settings.js', () => ({
|
||||
getSetting: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock('../services/crawler.js', () => ({
|
||||
runCrawler: vi.fn(() => ({ success: true, jobs: [] })),
|
||||
}));
|
||||
|
||||
vi.mock('../services/jobspy.js', () => ({
|
||||
runJobSpy: vi.fn(() => ({ success: true, jobs: [] })),
|
||||
}));
|
||||
|
||||
vi.mock('../services/ukvisajobs.js', () => ({
|
||||
runUkVisaJobs: vi.fn(() => ({ success: true, jobs: [] })),
|
||||
}));
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Mock job template
|
||||
const createMockJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
id: 'test-job-1',
|
||||
source: 'gradcracker',
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: 'Software Engineer',
|
||||
employer: 'Acme Corporation Ltd',
|
||||
employerUrl: null,
|
||||
jobUrl: 'http://test.com/job',
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: 'London',
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: 'Looking for a TypeScript developer.',
|
||||
status: 'discovered',
|
||||
suitabilityScore: null,
|
||||
suitabilityReason: null,
|
||||
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: now,
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Sponsor Match Calculation', () => {
|
||||
let searchSponsors: ReturnType<typeof vi.fn>;
|
||||
let calculateSponsorMatchSummary: ReturnType<typeof vi.fn>;
|
||||
let scoreJobSuitability: ReturnType<typeof vi.fn>;
|
||||
let updateJob: ReturnType<typeof vi.fn>;
|
||||
let getUnscoredDiscoveredJobs: ReturnType<typeof vi.fn>;
|
||||
let bulkCreateJobs: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Get mocked functions
|
||||
const visaSponsors = await import('../services/visa-sponsors/index.js');
|
||||
const scorer = await import('../services/scorer.js');
|
||||
const jobsRepo = await import('../repositories/jobs.js');
|
||||
|
||||
searchSponsors = visaSponsors.searchSponsors as ReturnType<typeof vi.fn>;
|
||||
calculateSponsorMatchSummary = visaSponsors.calculateSponsorMatchSummary as ReturnType<typeof vi.fn>;
|
||||
scoreJobSuitability = scorer.scoreJobSuitability as ReturnType<typeof vi.fn>;
|
||||
updateJob = jobsRepo.updateJob as ReturnType<typeof vi.fn>;
|
||||
getUnscoredDiscoveredJobs = jobsRepo.getUnscoredDiscoveredJobs as ReturnType<typeof vi.fn>;
|
||||
bulkCreateJobs = jobsRepo.bulkCreateJobs as ReturnType<typeof vi.fn>;
|
||||
|
||||
// Default mock implementations
|
||||
scoreJobSuitability.mockResolvedValue({ score: 75, reason: 'Good match' });
|
||||
bulkCreateJobs.mockResolvedValue({ created: 0, skipped: 0 });
|
||||
updateJob.mockResolvedValue(undefined);
|
||||
|
||||
calculateSponsorMatchSummary.mockImplementation((results: any[]) => {
|
||||
if (results.length === 0) return { sponsorMatchScore: 0, sponsorMatchNames: null };
|
||||
const topScore = results[0].score;
|
||||
const perfectMatches = results.filter((r: any) => r.score === 100);
|
||||
const matchesToReport = perfectMatches.length >= 2 ? perfectMatches.slice(0, 2) : [results[0]];
|
||||
return {
|
||||
sponsorMatchScore: topScore,
|
||||
sponsorMatchNames: JSON.stringify(matchesToReport.map((r: any) => r.sponsor.organisationName)),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('searchSponsors integration', () => {
|
||||
it('should calculate sponsor match score when employer matches a sponsor', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Acme Corporation Ltd' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning a match
|
||||
searchSponsors.mockReturnValue([
|
||||
{
|
||||
sponsor: { organisationName: 'ACME CORPORATION LIMITED' },
|
||||
score: 85,
|
||||
matchedName: 'acme corporation',
|
||||
},
|
||||
]);
|
||||
|
||||
// Import and run pipeline
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Verify searchSponsors was called with correct parameters
|
||||
expect(searchSponsors).toHaveBeenCalledWith('Acme Corporation Ltd', {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
|
||||
// Verify updateJob was called with sponsor match data
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
suitabilityScore: 75,
|
||||
suitabilityReason: 'Good match',
|
||||
sponsorMatchScore: 85,
|
||||
sponsorMatchNames: JSON.stringify(['ACME CORPORATION LIMITED']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle 100% perfect matches correctly', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Microsoft UK' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning perfect matches
|
||||
searchSponsors.mockReturnValue([
|
||||
{
|
||||
sponsor: { organisationName: 'MICROSOFT UK LIMITED' },
|
||||
score: 100,
|
||||
matchedName: 'microsoft uk',
|
||||
},
|
||||
{
|
||||
sponsor: { organisationName: 'MICROSOFT UK LTD' },
|
||||
score: 100,
|
||||
matchedName: 'microsoft uk',
|
||||
},
|
||||
{
|
||||
sponsor: { organisationName: 'MICROSOFT LIMITED' },
|
||||
score: 80,
|
||||
matchedName: 'microsoft',
|
||||
},
|
||||
]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Should include up to 2 perfect matches
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 100,
|
||||
sponsorMatchNames: JSON.stringify([
|
||||
'MICROSOFT UK LIMITED',
|
||||
'MICROSOFT UK LTD',
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should report single top match when no perfect matches exist', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Tech Corp' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning partial matches only
|
||||
searchSponsors.mockReturnValue([
|
||||
{
|
||||
sponsor: { organisationName: 'TECH CORPORATION' },
|
||||
score: 75,
|
||||
matchedName: 'tech corporation',
|
||||
},
|
||||
{
|
||||
sponsor: { organisationName: 'TECHNO CORP' },
|
||||
score: 60,
|
||||
matchedName: 'techno corp',
|
||||
},
|
||||
]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Should only include the top match since none are 100%
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 75,
|
||||
sponsorMatchNames: JSON.stringify(['TECH CORPORATION']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set sponsor match when no matches found', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Unknown Company XYZ' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
// Mock sponsor search returning no matches
|
||||
searchSponsors.mockReturnValue([]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// sponsorMatchScore should be 0 (not set) and sponsorMatchNames undefined
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
suitabilityScore: 75,
|
||||
suitabilityReason: 'Good match',
|
||||
})
|
||||
);
|
||||
|
||||
// Verify that sponsorMatchScore is 0 and sponsorMatchNames is not included
|
||||
// when there are no matches
|
||||
const updateCall = updateJob.mock.calls[0][1];
|
||||
expect(updateCall.sponsorMatchScore).toBe(0);
|
||||
expect(updateCall.sponsorMatchNames).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip sponsor matching when job has no employer', async () => {
|
||||
const mockJob = createMockJob({ employer: null as unknown as string });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// searchSponsors should not be called
|
||||
expect(searchSponsors).not.toHaveBeenCalled();
|
||||
|
||||
// updateJob should still be called but without sponsor data
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
suitabilityScore: 75,
|
||||
suitabilityReason: 'Good match',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip sponsor matching when job has empty employer string', async () => {
|
||||
const mockJob = createMockJob({ employer: '' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// searchSponsors should not be called for empty string
|
||||
expect(searchSponsors).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sponsor match edge cases', () => {
|
||||
it('should use correct limit and minScore options', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Test Company' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
searchSponsors.mockReturnValue([]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
expect(searchSponsors).toHaveBeenCalledWith('Test Company', {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single 100% match correctly', async () => {
|
||||
const mockJob = createMockJob({ employer: 'Google UK' });
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
|
||||
|
||||
searchSponsors.mockReturnValue([
|
||||
{
|
||||
sponsor: { organisationName: 'GOOGLE UK LIMITED' },
|
||||
score: 100,
|
||||
matchedName: 'google uk',
|
||||
},
|
||||
]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Single perfect match should be reported
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'test-job-1',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 100,
|
||||
sponsorMatchNames: JSON.stringify(['GOOGLE UK LIMITED']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should process multiple jobs with different sponsor matches', async () => {
|
||||
const mockJob1 = createMockJob({
|
||||
id: 'job-1',
|
||||
employer: 'Amazon UK',
|
||||
});
|
||||
const mockJob2 = createMockJob({
|
||||
id: 'job-2',
|
||||
employer: 'Meta Platforms',
|
||||
});
|
||||
|
||||
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob1, mockJob2]);
|
||||
|
||||
// Different results for each employer
|
||||
searchSponsors
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
sponsor: { organisationName: 'AMAZON UK SERVICES LTD' },
|
||||
score: 90,
|
||||
matchedName: 'amazon uk',
|
||||
},
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{
|
||||
sponsor: { organisationName: 'META PLATFORMS IRELAND LIMITED' },
|
||||
score: 80,
|
||||
matchedName: 'meta platforms',
|
||||
},
|
||||
]);
|
||||
|
||||
const { runPipeline } = await import('./orchestrator.js');
|
||||
await runPipeline({ sources: [], enableCrawling: false });
|
||||
|
||||
// Verify both jobs were processed with different sponsor data
|
||||
expect(updateJob).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'job-1',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 90,
|
||||
sponsorMatchNames: JSON.stringify(['AMAZON UK SERVICES LTD']),
|
||||
})
|
||||
);
|
||||
|
||||
expect(updateJob).toHaveBeenCalledWith(
|
||||
'job-2',
|
||||
expect.objectContaining({
|
||||
sponsorMatchScore: 80,
|
||||
sponsorMatchNames: JSON.stringify(['META PLATFORMS IRELAND LIMITED']),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -16,7 +16,7 @@ export async function getAllJobs(statuses?: JobStatus[]): Promise<Job[]> {
|
||||
const query = statuses && statuses.length > 0
|
||||
? db.select().from(jobs).where(inArray(jobs.status, statuses)).orderBy(desc(jobs.discoveredAt))
|
||||
: db.select().from(jobs).orderBy(desc(jobs.discoveredAt));
|
||||
|
||||
|
||||
const rows = await query;
|
||||
return rows.map(mapRowToJob);
|
||||
}
|
||||
@ -54,10 +54,10 @@ export async function createJob(input: CreateJobInput): Promise<Job> {
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
|
||||
await db.insert(jobs).values({
|
||||
id,
|
||||
source: input.source,
|
||||
@ -105,7 +105,7 @@ export async function createJob(input: CreateJobInput): Promise<Job> {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
|
||||
return (await getJobById(id))!;
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@ export async function createJob(input: CreateJobInput): Promise<Job> {
|
||||
*/
|
||||
export async function updateJob(id: string, input: UpdateJobInput): Promise<Job | null> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
|
||||
await db.update(jobs)
|
||||
.set({
|
||||
...input,
|
||||
@ -123,7 +123,7 @@ export async function updateJob(id: string, input: UpdateJobInput): Promise<Job
|
||||
...(input.status === 'applied' && !input.appliedAt ? { appliedAt: now } : {}),
|
||||
})
|
||||
.where(eq(jobs.id, id));
|
||||
|
||||
|
||||
return getJobById(id);
|
||||
}
|
||||
|
||||
@ -133,18 +133,18 @@ export async function updateJob(id: string, input: UpdateJobInput): Promise<Job
|
||||
export async function bulkCreateJobs(inputs: CreateJobInput[]): Promise<{ created: number; skipped: number }> {
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
|
||||
for (const input of inputs) {
|
||||
const existing = await getJobByUrl(input.jobUrl);
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
await createJob(input);
|
||||
created++;
|
||||
}
|
||||
|
||||
|
||||
return { created, skipped };
|
||||
}
|
||||
|
||||
@ -159,7 +159,7 @@ export async function getJobStats(): Promise<Record<JobStatus, number>> {
|
||||
})
|
||||
.from(jobs)
|
||||
.groupBy(jobs.status);
|
||||
|
||||
|
||||
const stats: Record<JobStatus, number> = {
|
||||
discovered: 0,
|
||||
processing: 0,
|
||||
@ -168,11 +168,11 @@ export async function getJobStats(): Promise<Record<JobStatus, number>> {
|
||||
skipped: 0,
|
||||
expired: 0,
|
||||
};
|
||||
|
||||
|
||||
for (const row of result) {
|
||||
stats[row.status as JobStatus] = row.count;
|
||||
}
|
||||
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
@ -191,7 +191,7 @@ export async function getJobsForProcessing(limit: number = 10): Promise<Job[]> {
|
||||
)
|
||||
.orderBy(desc(jobs.discoveredAt))
|
||||
.limit(limit);
|
||||
|
||||
|
||||
return rows.map(mapRowToJob);
|
||||
}
|
||||
|
||||
@ -246,6 +246,8 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
|
||||
selectedProjectIds: row.selectedProjectIds ?? null,
|
||||
pdfPath: row.pdfPath,
|
||||
notionPageId: row.notionPageId,
|
||||
sponsorMatchScore: row.sponsorMatchScore ?? null,
|
||||
sponsorMatchNames: row.sponsorMatchNames ?? null,
|
||||
jobType: row.jobType ?? null,
|
||||
salarySource: row.salarySource ?? null,
|
||||
salaryInterval: row.salaryInterval ?? null,
|
||||
|
||||
@ -23,6 +23,7 @@ export type SettingKey = 'model'
|
||||
| 'jobspyCountryIndeed'
|
||||
| 'jobspySites'
|
||||
| 'jobspyLinkedinFetchDescription'
|
||||
| 'showSponsorInfo'
|
||||
|
||||
export async function getSetting(key: SettingKey): Promise<string | null> {
|
||||
const [row] = await db.select().from(settings).where(eq(settings.key, key))
|
||||
|
||||
@ -38,6 +38,8 @@ const mockJob: Job = {
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
@ -103,15 +105,15 @@ describe('AI Service Resilience', () => {
|
||||
|
||||
it('should fallback to mock scoring if API Key is missing', async () => {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
|
||||
|
||||
// Should NOT call fetch
|
||||
const result = await scoreJobSuitability(mockJob, mockProfile);
|
||||
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
// Mock score logic gives 50 + points for keywords.
|
||||
// 'TypeScript' and 'React' are in JD (5+5) -> 60?
|
||||
// "Senior" is bad keyword (-10)? -> 50?
|
||||
// Let's just check it didn't crash and returned a number
|
||||
// Let's just check it didn't crash and returned a number
|
||||
expect(typeof result.score).toBe('number');
|
||||
expect(result.reason).toContain('keyword matching');
|
||||
});
|
||||
@ -124,7 +126,7 @@ describe('AI Service Resilience', () => {
|
||||
} as any);
|
||||
|
||||
// Spy on console.error to keep test output clean
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
||||
|
||||
const result = await scoreJobSuitability(mockJob, mockProfile);
|
||||
|
||||
@ -134,22 +136,22 @@ describe('AI Service Resilience', () => {
|
||||
});
|
||||
|
||||
it('should handle Malformed/Invalid JSON in API response', async () => {
|
||||
const mockResponse = {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: 'This is not JSON at all, just text.' } }]
|
||||
})
|
||||
};
|
||||
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => { });
|
||||
|
||||
const result = await scoreJobSuitability(mockJob, mockProfile);
|
||||
|
||||
|
||||
expect(result.reason).toContain('keyword matching'); // Fell back
|
||||
});
|
||||
|
||||
it('should extract JSON from markdown code blocks', async () => {
|
||||
const mockResponse = {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: 'Here is the score: ```json\n{ "score": 90, "reason": "Good" }\n```' } }]
|
||||
@ -169,7 +171,7 @@ describe('AI Service Resilience', () => {
|
||||
];
|
||||
|
||||
it('should return projects selected by AI', async () => {
|
||||
const mockResponse = {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: JSON.stringify({ selectedProjectIds: ['p1'] }) } }]
|
||||
@ -187,9 +189,9 @@ describe('AI Service Resilience', () => {
|
||||
});
|
||||
|
||||
it('should fallback if API fails', async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(new Error('Network error'));
|
||||
vi.mocked(global.fetch).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await pickProjectIdsForJob({
|
||||
const result = await pickProjectIdsForJob({
|
||||
jobDescription: 'React dev', // Should match p1 due to keyword 'React'
|
||||
eligibleProjects: mockProjects,
|
||||
desiredCount: 1
|
||||
@ -218,18 +220,18 @@ describe('AI Service Resilience', () => {
|
||||
expect(result).toEqual(['p2']);
|
||||
});
|
||||
|
||||
it('should validate returned IDs exist in eligible list', async () => {
|
||||
it('should validate returned IDs exist in eligible list', async () => {
|
||||
// AI returns an ID that doesn't exist ('p999')
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: JSON.stringify({ selectedProjectIds: ['p999', 'p1'] }) } }]
|
||||
choices: [{ message: { content: JSON.stringify({ selectedProjectIds: ['p999', 'p1'] }) } }]
|
||||
})
|
||||
};
|
||||
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
|
||||
|
||||
const result = await pickProjectIdsForJob({
|
||||
jobDescription: 'stuff',
|
||||
jobDescription: 'stuff',
|
||||
eligibleProjects: mockProjects,
|
||||
desiredCount: 2
|
||||
});
|
||||
|
||||
107
orchestrator/src/server/services/visa-sponsors/index.test.ts
Normal file
107
orchestrator/src/server/services/visa-sponsors/index.test.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateSponsorMatchSummary } from './index.js';
|
||||
import type { VisaSponsorSearchResult } from '../../../shared/types.js';
|
||||
|
||||
describe('calculateSponsorMatchSummary', () => {
|
||||
it('should return default values for empty results', () => {
|
||||
const results: VisaSponsorSearchResult[] = [];
|
||||
const summary = calculateSponsorMatchSummary(results);
|
||||
|
||||
expect(summary.sponsorMatchScore).toBe(0);
|
||||
expect(summary.sponsorMatchNames).toBeNull();
|
||||
});
|
||||
|
||||
it('should report the top match when it is not a perfect match', () => {
|
||||
const results: VisaSponsorSearchResult[] = [
|
||||
{
|
||||
score: 85,
|
||||
sponsor: { organisationName: 'Tech Corp' } as any,
|
||||
matchedName: 'tech corp'
|
||||
},
|
||||
{
|
||||
score: 60,
|
||||
sponsor: { organisationName: 'Other Ltd' } as any,
|
||||
matchedName: 'other'
|
||||
}
|
||||
];
|
||||
|
||||
const summary = calculateSponsorMatchSummary(results);
|
||||
|
||||
expect(summary.sponsorMatchScore).toBe(85);
|
||||
expect(summary.sponsorMatchNames).toBe(JSON.stringify(['Tech Corp']));
|
||||
});
|
||||
|
||||
it('should report a single perfect match', () => {
|
||||
const results: VisaSponsorSearchResult[] = [
|
||||
{
|
||||
score: 100,
|
||||
sponsor: { organisationName: 'Exact Match Ltd' } as any,
|
||||
matchedName: 'exact match'
|
||||
},
|
||||
{
|
||||
score: 90,
|
||||
sponsor: { organisationName: 'Close Match' } as any,
|
||||
matchedName: 'close'
|
||||
}
|
||||
];
|
||||
|
||||
const summary = calculateSponsorMatchSummary(results);
|
||||
|
||||
expect(summary.sponsorMatchScore).toBe(100);
|
||||
expect(summary.sponsorMatchNames).toBe(JSON.stringify(['Exact Match Ltd']));
|
||||
});
|
||||
|
||||
it('should report exactly two 100% matches when two or more exist', () => {
|
||||
const results: VisaSponsorSearchResult[] = [
|
||||
{
|
||||
score: 100,
|
||||
sponsor: { organisationName: 'First PerfectMatch' } as any,
|
||||
matchedName: 'match'
|
||||
},
|
||||
{
|
||||
score: 100,
|
||||
sponsor: { organisationName: 'Second PerfectMatch' } as any,
|
||||
matchedName: 'match'
|
||||
},
|
||||
{
|
||||
score: 100,
|
||||
sponsor: { organisationName: 'Third PerfectMatch' } as any,
|
||||
matchedName: 'match'
|
||||
},
|
||||
{
|
||||
score: 50,
|
||||
sponsor: { organisationName: 'Common Co' } as any,
|
||||
matchedName: 'common'
|
||||
}
|
||||
];
|
||||
|
||||
const summary = calculateSponsorMatchSummary(results);
|
||||
|
||||
expect(summary.sponsorMatchScore).toBe(100);
|
||||
const names = JSON.parse(summary.sponsorMatchNames!);
|
||||
expect(names).toHaveLength(2);
|
||||
expect(names).toContain('First PerfectMatch');
|
||||
expect(names).toContain('Second PerfectMatch');
|
||||
expect(names).not.toContain('Third PerfectMatch');
|
||||
});
|
||||
|
||||
it('should only report the single top result if no 100% matches exist', () => {
|
||||
const results: VisaSponsorSearchResult[] = [
|
||||
{
|
||||
score: 99,
|
||||
sponsor: { organisationName: 'Almost Perfect' } as any,
|
||||
matchedName: 'almost'
|
||||
},
|
||||
{
|
||||
score: 98,
|
||||
sponsor: { organisationName: 'Second Best' } as any,
|
||||
matchedName: 'best'
|
||||
}
|
||||
];
|
||||
|
||||
const summary = calculateSponsorMatchSummary(results);
|
||||
|
||||
expect(summary.sponsorMatchScore).toBe(99);
|
||||
expect(summary.sponsorMatchNames).toBe(JSON.stringify(['Almost Perfect']));
|
||||
});
|
||||
});
|
||||
@ -57,20 +57,20 @@ let updateError: string | null = null;
|
||||
*/
|
||||
export function normalizeCompanyName(name: string): string {
|
||||
let normalized = name.toLowerCase().trim();
|
||||
|
||||
|
||||
// Remove common punctuation and special chars
|
||||
normalized = normalized.replace(/[.,'"()[\]{}!?@#$%^&*+=|\\/<>:;`~]/g, ' ');
|
||||
|
||||
|
||||
// Remove suffixes
|
||||
for (const suffix of COMPANY_SUFFIXES) {
|
||||
// Word boundary matching
|
||||
const regex = new RegExp(`\\b${suffix}\\b`, 'gi');
|
||||
normalized = normalized.replace(regex, '');
|
||||
}
|
||||
|
||||
|
||||
// Collapse whitespace
|
||||
normalized = normalized.replace(/\s+/g, ' ').trim();
|
||||
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@ -81,27 +81,27 @@ export function normalizeCompanyName(name: string): string {
|
||||
export function calculateSimilarity(str1: string, str2: string): number {
|
||||
const s1 = str1.toLowerCase();
|
||||
const s2 = str2.toLowerCase();
|
||||
|
||||
|
||||
if (s1 === s2) return 100;
|
||||
if (s1.length === 0 || s2.length === 0) return 0;
|
||||
|
||||
|
||||
// Check if one contains the other
|
||||
if (s1.includes(s2) || s2.includes(s1)) {
|
||||
const longerLen = Math.max(s1.length, s2.length);
|
||||
const shorterLen = Math.min(s1.length, s2.length);
|
||||
return Math.round((shorterLen / longerLen) * 100);
|
||||
}
|
||||
|
||||
|
||||
// Levenshtein distance
|
||||
const matrix: number[][] = [];
|
||||
|
||||
|
||||
for (let i = 0; i <= s1.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= s2.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
|
||||
for (let i = 1; i <= s1.length; i++) {
|
||||
for (let j = 1; j <= s2.length; j++) {
|
||||
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
|
||||
@ -112,10 +112,10 @@ export function calculateSimilarity(str1: string, str2: string): number {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const distance = matrix[s1.length][s2.length];
|
||||
const maxLen = Math.max(s1.length, s2.length);
|
||||
|
||||
|
||||
return Math.round(((maxLen - distance) / maxLen) * 100);
|
||||
}
|
||||
|
||||
@ -125,12 +125,12 @@ export function calculateSimilarity(str1: string, str2: string): number {
|
||||
export function parseCsv(content: string): VisaSponsor[] {
|
||||
const lines = content.split('\n');
|
||||
const sponsors: VisaSponsor[] = [];
|
||||
|
||||
|
||||
// Skip header
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
|
||||
// Parse CSV with proper quote handling
|
||||
const fields = parseCSVLine(line);
|
||||
if (fields.length >= 5) {
|
||||
@ -143,7 +143,7 @@ export function parseCsv(content: string): VisaSponsor[] {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return sponsors;
|
||||
}
|
||||
|
||||
@ -154,11 +154,11 @@ function parseCSVLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
const nextChar = line[i + 1];
|
||||
|
||||
|
||||
if (char === '"' && !inQuotes) {
|
||||
inQuotes = true;
|
||||
} else if (char === '"' && inQuotes) {
|
||||
@ -176,7 +176,7 @@ function parseCSVLine(line: string): string[] {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fields.push(current.trim());
|
||||
return fields;
|
||||
}
|
||||
@ -186,7 +186,7 @@ function parseCSVLine(line: string): string[] {
|
||||
*/
|
||||
function getCsvFiles(): string[] {
|
||||
if (!fs.existsSync(DATA_DIR)) return [];
|
||||
|
||||
|
||||
return fs.readdirSync(DATA_DIR)
|
||||
.filter(f => f.endsWith('.csv'))
|
||||
.sort()
|
||||
@ -245,25 +245,25 @@ function cleanupOldCsvFiles(): void {
|
||||
*/
|
||||
async function extractCsvUrl(): Promise<string> {
|
||||
const pageUrl = 'https://www.gov.uk/government/publications/register-of-licensed-sponsors-workers';
|
||||
|
||||
|
||||
console.log('📄 Fetching gov.uk page to find CSV link...');
|
||||
const response = await fetch(pageUrl);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch gov.uk page: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
|
||||
// Look for the Worker and Temporary Worker CSV link
|
||||
const csvMatch = html.match(
|
||||
/href="(https:\/\/assets\.publishing\.service\.gov\.uk\/media\/[^"]+Worker_and_Temporary_Worker\.csv)"/
|
||||
);
|
||||
|
||||
|
||||
if (!csvMatch) {
|
||||
throw new Error('Could not find Worker and Temporary Worker CSV link on gov.uk page');
|
||||
}
|
||||
|
||||
|
||||
return csvMatch[1];
|
||||
}
|
||||
|
||||
@ -274,52 +274,52 @@ export async function downloadLatestCsv(): Promise<{ success: boolean; message:
|
||||
if (isUpdating) {
|
||||
return { success: false, message: 'Update already in progress' };
|
||||
}
|
||||
|
||||
|
||||
isUpdating = true;
|
||||
updateError = null;
|
||||
|
||||
|
||||
try {
|
||||
// Extract the CSV URL from the page
|
||||
const csvUrl = await extractCsvUrl();
|
||||
console.log(`📥 Downloading CSV from: ${csvUrl}`);
|
||||
|
||||
|
||||
const response = await fetch(csvUrl);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download CSV: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
const csvContent = await response.text();
|
||||
|
||||
|
||||
// Validate CSV has content
|
||||
const sponsors = parseCsv(csvContent);
|
||||
if (sponsors.length === 0) {
|
||||
throw new Error('Downloaded CSV appears to be empty or invalid');
|
||||
}
|
||||
|
||||
|
||||
// Generate filename with date
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `visa_sponsors_${dateStr}.csv`;
|
||||
const filepath = path.join(DATA_DIR, filename);
|
||||
|
||||
|
||||
// Save the CSV
|
||||
fs.writeFileSync(filepath, csvContent);
|
||||
|
||||
|
||||
// Update metadata
|
||||
writeMetadata({
|
||||
lastUpdated: new Date().toISOString(),
|
||||
csvFile: filename,
|
||||
});
|
||||
|
||||
|
||||
// Cleanup old files
|
||||
cleanupOldCsvFiles();
|
||||
|
||||
|
||||
// Clear cache so next search loads new data
|
||||
sponsorsCache = null;
|
||||
cacheLoadedAt = null;
|
||||
|
||||
|
||||
console.log(`✅ Downloaded visa sponsor list: ${sponsors.length} sponsors`);
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully downloaded ${sponsors.length} sponsors`,
|
||||
@ -345,17 +345,17 @@ export function loadSponsors(): VisaSponsor[] {
|
||||
return sponsorsCache;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const metadata = readMetadata();
|
||||
if (!metadata.csvFile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const csvPath = path.join(DATA_DIR, metadata.csvFile);
|
||||
if (!fs.existsSync(csvPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(csvPath, 'utf-8');
|
||||
sponsorsCache = parseCsv(content);
|
||||
@ -375,26 +375,26 @@ export function searchSponsors(
|
||||
options: { limit?: number; minScore?: number } = {}
|
||||
): VisaSponsorSearchResult[] {
|
||||
const { limit = 50, minScore = 30 } = options;
|
||||
|
||||
|
||||
const sponsors = loadSponsors();
|
||||
if (sponsors.length === 0 || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const normalizedQuery = normalizeCompanyName(query);
|
||||
const results: VisaSponsorSearchResult[] = [];
|
||||
const seen = new Set<string>(); // Dedupe by org name
|
||||
|
||||
|
||||
for (const sponsor of sponsors) {
|
||||
// Skip if we've already seen this org name
|
||||
if (seen.has(sponsor.organisationName)) continue;
|
||||
seen.add(sponsor.organisationName);
|
||||
|
||||
|
||||
const normalizedSponsor = normalizeCompanyName(sponsor.organisationName);
|
||||
|
||||
|
||||
// Calculate similarity
|
||||
const score = calculateSimilarity(normalizedQuery, normalizedSponsor);
|
||||
|
||||
|
||||
if (score >= minScore) {
|
||||
results.push({
|
||||
sponsor,
|
||||
@ -403,20 +403,43 @@ export function searchSponsors(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Sort by score descending
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
|
||||
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate match summary from search results
|
||||
*/
|
||||
export function calculateSponsorMatchSummary(
|
||||
results: VisaSponsorSearchResult[]
|
||||
): { sponsorMatchScore: number; sponsorMatchNames: string | null } {
|
||||
if (results.length === 0) {
|
||||
return { sponsorMatchScore: 0, sponsorMatchNames: null };
|
||||
}
|
||||
|
||||
const topScore = results[0].score;
|
||||
// Get all 100% matches, or just the top match
|
||||
const perfectMatches = results.filter(r => r.score === 100);
|
||||
const matchesToReport = perfectMatches.length >= 2
|
||||
? perfectMatches.slice(0, 2)
|
||||
: [results[0]];
|
||||
|
||||
return {
|
||||
sponsorMatchScore: topScore,
|
||||
sponsorMatchNames: JSON.stringify(matchesToReport.map(r => r.sponsor.organisationName)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of the visa sponsor service
|
||||
*/
|
||||
export function getStatus(): VisaSponsorStatus {
|
||||
const metadata = readMetadata();
|
||||
const sponsors = loadSponsors();
|
||||
|
||||
|
||||
return {
|
||||
lastUpdated: metadata.lastUpdated,
|
||||
csvPath: metadata.csvFile ? path.join(DATA_DIR, metadata.csvFile) : null,
|
||||
@ -449,12 +472,12 @@ function calculateNextUpdateTime(hour = 2): Date {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(hour, 0, 0, 0);
|
||||
|
||||
|
||||
// If we've passed the time today, schedule for tomorrow
|
||||
if (next <= now) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
@ -472,12 +495,12 @@ function scheduleNextUpdate(hour = 2): void {
|
||||
if (scheduledTimer) {
|
||||
clearTimeout(scheduledTimer);
|
||||
}
|
||||
|
||||
|
||||
nextScheduledUpdateTime = calculateNextUpdateTime(hour);
|
||||
const delay = nextScheduledUpdateTime.getTime() - Date.now();
|
||||
|
||||
|
||||
console.log(`⏰ Next visa sponsor update scheduled for: ${nextScheduledUpdateTime.toISOString()}`);
|
||||
|
||||
|
||||
scheduledTimer = setTimeout(async () => {
|
||||
console.log('🔄 Running scheduled visa sponsor update...');
|
||||
await downloadLatestCsv();
|
||||
@ -510,7 +533,7 @@ export function stopScheduler(): void {
|
||||
*/
|
||||
export async function initialize(): Promise<void> {
|
||||
const metadata = readMetadata();
|
||||
|
||||
|
||||
if (!metadata.csvFile) {
|
||||
console.log('📥 No visa sponsor data found, downloading...');
|
||||
await downloadLatestCsv();
|
||||
@ -518,7 +541,7 @@ export async function initialize(): Promise<void> {
|
||||
const sponsors = loadSponsors();
|
||||
console.log(`✅ Visa sponsor service initialized with ${sponsors.length} sponsors`);
|
||||
}
|
||||
|
||||
|
||||
// Start the scheduler for automatic daily updates at 2 AM
|
||||
startScheduler(2);
|
||||
}
|
||||
|
||||
@ -50,6 +50,8 @@ export interface Job {
|
||||
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
|
||||
pdfPath: string | null; // Path to generated PDF
|
||||
notionPageId: string | null; // Notion page ID if synced
|
||||
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
|
||||
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
|
||||
|
||||
// JobSpy fields (nullable for non-JobSpy sources)
|
||||
jobType: string | null;
|
||||
@ -164,6 +166,8 @@ export interface UpdateJobInput {
|
||||
pdfPath?: string;
|
||||
notionPageId?: string;
|
||||
appliedAt?: string;
|
||||
sponsorMatchScore?: number;
|
||||
sponsorMatchNames?: string;
|
||||
}
|
||||
|
||||
export interface PipelineConfig {
|
||||
@ -275,7 +279,7 @@ export interface AppSettings {
|
||||
overrideModelTailoring: string | null;
|
||||
modelProjectSelection: string; // resolved
|
||||
overrideModelProjectSelection: string | null;
|
||||
|
||||
|
||||
pipelineWebhookUrl: string;
|
||||
defaultPipelineWebhookUrl: string;
|
||||
overridePipelineWebhookUrl: string | null;
|
||||
@ -313,4 +317,7 @@ export interface AppSettings {
|
||||
jobspyLinkedinFetchDescription: boolean;
|
||||
defaultJobspyLinkedinFetchDescription: boolean;
|
||||
overrideJobspyLinkedinFetchDescription: boolean | null;
|
||||
showSponsorInfo: boolean;
|
||||
defaultShowSponsorInfo: boolean;
|
||||
overrideShowSponsorInfo: boolean | null;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user