From 1fd6a4b4c2d9946fef29272384e27837eb304a3d Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 22:11:09 +0000 Subject: [PATCH 01/17] initial commit --- orchestrator/package-lock.json | 76 +++++++++++ orchestrator/package.json | 1 + orchestrator/src/client/api/client.ts | 7 + .../src/client/components/JobHeader.tsx | 126 +++++++++++++++++- .../src/client/components/ReadyPanel.tsx | 11 +- .../discovered-panel/DecideMode.tsx | 4 +- .../discovered-panel/DiscoveredPanel.tsx | 4 + .../client/pages/OrchestratorPage.test.tsx | 4 +- .../src/client/pages/SettingsPage.test.tsx | 3 + .../src/client/pages/SettingsPage.tsx | 24 +++- .../orchestrator/JobDetailPanel.test.tsx | 2 + .../pages/orchestrator/JobDetailPanel.tsx | 8 +- .../pages/orchestrator/JobListPanel.test.tsx | 2 + .../components/DisplaySettingsSection.tsx | 77 +++++++++++ orchestrator/src/components/ui/tooltip.tsx | 32 +++++ .../src/server/api/routes/jobs.test.ts | 2 +- orchestrator/src/server/api/routes/jobs.ts | 59 ++++++++ .../src/server/api/routes/settings.ts | 38 +++++- orchestrator/src/server/db/migrate.ts | 4 + orchestrator/src/server/db/schema.ts | 2 + .../src/server/pipeline/orchestrator.ts | 28 +++- orchestrator/src/server/repositories/jobs.ts | 28 ++-- .../src/server/repositories/settings.ts | 1 + .../src/server/services/ai-resilience.test.ts | 30 +++-- orchestrator/src/shared/types.ts | 9 +- 25 files changed, 537 insertions(+), 45 deletions(-) create mode 100644 orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx create mode 100644 orchestrator/src/components/ui/tooltip.tsx diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 5322da3..2af6a58 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -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", diff --git a/orchestrator/package.json b/orchestrator/package.json index 1130faf..d810575 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -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", diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 9b3cae5..6558b78 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -86,6 +86,12 @@ export async function generateJobPdf(id: string): Promise { }); } +export async function checkSponsor(id: string): Promise { + return fetchApi(`/jobs/${id}/check-sponsor`, { + method: 'POST', + }); +} + export async function markAsApplied(id: string): Promise { return fetchApi(`/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 { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index 3d1f9be..6437604 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -1,6 +1,8 @@ -import React from "react"; -import { Calendar, DollarSign, MapPin } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { Calendar, DollarSign, Loader2, MapPin, Search, Shield } 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"; @@ -8,6 +10,8 @@ import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constant interface JobHeaderProps { job: Job; className?: string; + showSponsorInfo?: boolean; + onCheckSponsor?: () => Promise; } const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => { @@ -42,7 +46,112 @@ const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => { ); }; -export const JobHeader: React.FC = ({ job, className }) => { +interface SponsorBadgeProps { + score: number | null; + names: string | null; + onCheck?: () => Promise; +} + +const SponsorBadge: React.FC = ({ 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 ( + + + + + + +

Check if employer is a visa sponsor

+
+
+
+ ); + } + + // If no score (and no callback), show nothing + if (score == null || score < 50) { + return null; + } + + // Color tokens based on score + const getScoreTokens = (s: number) => { + if (s >= 90) return { + badge: "border-emerald-500/40 bg-emerald-500/15 text-emerald-300", + label: "Visa Sponsor" + }; + if (s >= 70) return { + badge: "border-amber-500/40 bg-amber-500/15 text-amber-300", + label: "Likely Sponsor" + }; + return { + badge: "border-orange-500/40 bg-orange-500/15 text-orange-300", + label: "Possible Sponsor" + }; + }; + + const tokens = getScoreTokens(score); + const tooltipContent = parsedNames.length > 0 + ? `${score}% match: ${parsedNames.join(", ")}` + : `${score}% match with visa sponsor list`; + + return ( + + + + + + {score}% + + + +

{tooltipContent}

+
+
+
+ ); +}; + +export const JobHeader: React.FC = ({ job, className, showSponsorInfo = true, onCheckSponsor }) => { const deadline = formatDate(job.deadline); return ( @@ -51,7 +160,16 @@ export const JobHeader: React.FC = ({ job, className }) => {
{job.title}
-
{job.employer}
+
+ {job.employer} + {showSponsorInfo && ( + + )} +
{sourceLabel[job.source]} diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index c3f0de6..acc2ade 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -141,7 +141,7 @@ export const ReadyPanel: React.FC = ({ // 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 = ({ return (
- + { + await api.checkSponsor(job.id); + await onJobUpdated(); + }} + /> {/* ───────────────────────────────────────────────────────────────────── PRIMARY ACTION CLUSTER diff --git a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx index df9aced..4308e4e 100644 --- a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx @@ -14,6 +14,7 @@ interface DecideModeProps { onTailor: () => void; onSkip: () => void; isSkipping: boolean; + onCheckSponsor?: () => Promise; } export const DecideMode: React.FC = ({ @@ -21,6 +22,7 @@ export const DecideMode: React.FC = ({ onTailor, onSkip, isSkipping, + onCheckSponsor, }) => { const [showDescription, setShowDescription] = useState(false); const jobLink = job.applicationLink || job.jobUrl; @@ -33,7 +35,7 @@ export const DecideMode: React.FC = ({ return (
- +
@@ -103,34 +103,30 @@ const SponsorBadge: React.FC = ({ score, names, onCheck }) => ); } - // If no score (and no callback), or error in score, show nothing if (score == null) { return null; } - const isFound = score >= 95; - const tooltipContent = isFound - ? `Confirmed Visa Sponsor (${score}% match: ${parsedNames.join(", ")})` - : `Sponsor Not Found (${score}% match${parsedNames.length > 0 ? `: ${parsedNames.join(", ")}` : ""})`; + const canSponsor = score >= 95; + const label = canSponsor ? "Can Sponsor" : "Unsure if Sponsor"; + const dotClass = canSponsor ? "bg-emerald-500" : "bg-slate-500"; + const tooltipContent = canSponsor + ? `${score}% match` + : `Closest: ${parsedNames.join(", ")} (${score}% match)`; return ( - + - - + + + {label} -

{isFound ? "Found" : "Not Found"}

-

{tooltipContent}

+ {canSponsor &&

{parsedNames.join(", ")}

} + {!canSponsor &&

Unsure if sponsor

} +

{tooltipContent}

@@ -148,13 +144,6 @@ export const JobHeader: React.FC = ({ job, className, showSponso
{job.title}
{job.employer} - {showSponsorInfo && ( - - )}
@@ -186,7 +175,16 @@ export const JobHeader: React.FC = ({ job, className, showSponso {/* Status and score: single line, subdued */}
- +
+ + {showSponsorInfo && ( + + )} +
diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index acc2ade..068d4ea 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -50,6 +50,7 @@ interface ReadyPanelProps { onJobMoved: (jobId: string) => void; onEditTailoring: () => void; onEditDescription: () => void; + showSponsorInfo?: boolean; } const safeFilenamePart = (value: string | null | undefined) => @@ -61,6 +62,7 @@ export const ReadyPanel: React.FC = ({ onJobMoved, onEditTailoring, onEditDescription, + showSponsorInfo, }) => { const [isMarkingApplied, setIsMarkingApplied] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); @@ -222,6 +224,7 @@ export const ReadyPanel: React.FC = ({ await api.checkSponsor(job.id); await onJobUpdated(); }} + showSponsorInfo={showSponsorInfo} /> {/* ───────────────────────────────────────────────────────────────────── diff --git a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx index 4308e4e..6d7418b 100644 --- a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx @@ -15,6 +15,7 @@ interface DecideModeProps { onSkip: () => void; isSkipping: boolean; onCheckSponsor?: () => Promise; + showSponsorInfo?: boolean; } export const DecideMode: React.FC = ({ @@ -23,6 +24,7 @@ export const DecideMode: React.FC = ({ onSkip, isSkipping, onCheckSponsor, + showSponsorInfo, }) => { const [showDescription, setShowDescription] = useState(false); const jobLink = job.applicationLink || job.jobUrl; @@ -35,7 +37,11 @@ export const DecideMode: React.FC = ({ return (
- +
)} diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index e7a1297..563c0bc 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -41,6 +41,7 @@ interface JobDetailPanelProps { onSelectJobId: (jobId: string | null) => void; onJobUpdated: () => Promise; onSetActiveTab: (tab: FilterTab) => void; + showSponsorInfo?: boolean; } export const JobDetailPanel: React.FC = ({ @@ -50,6 +51,7 @@ export const JobDetailPanel: React.FC = ({ onSelectJobId, onJobUpdated, onSetActiveTab, + showSponsorInfo, }) => { const [detailTab, setDetailTab] = useState<"overview" | "tailoring" | "description">("overview"); const [isEditingDescription, setIsEditingDescription] = useState(false); @@ -233,6 +235,7 @@ export const JobDetailPanel: React.FC = ({ job={selectedJob} onJobUpdated={onJobUpdated} onJobMoved={handleJobMoved} + showSponsorInfo={showSponsorInfo} /> ); } @@ -254,6 +257,7 @@ export const JobDetailPanel: React.FC = ({ setIsEditingDescription(true); }, 50); }} + showSponsorInfo={showSponsorInfo} /> ); } @@ -275,6 +279,7 @@ export const JobDetailPanel: React.FC = ({ await api.checkSponsor(selectedJob.id); await onJobUpdated(); }} + showSponsorInfo={showSponsorInfo} />
diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts index 2c440cb..0f6dac9 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import type { Job, JobStatus } from "../../../shared/types"; +import type { Job, JobStatus, AppSettings } from "../../../shared/types"; import * as api from "../../api"; const initialStats: Record = { @@ -16,15 +16,20 @@ const initialStats: Record = { export const useOrchestratorData = () => { const [jobs, setJobs] = useState([]); const [stats, setStats] = useState>(initialStats); + const [settings, setSettings] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isPipelineRunning, setIsPipelineRunning] = useState(false); const loadJobs = useCallback(async () => { try { setIsLoading(true); - const data = await api.getJobs(); - setJobs(data.jobs); - setStats(data.byStatus); + const [jobsData, settingsData] = await Promise.all([ + api.getJobs(), + api.getSettings(), + ]); + setJobs(jobsData.jobs); + setStats(jobsData.byStatus); + setSettings(settingsData); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load jobs"; toast.error(message); @@ -54,5 +59,5 @@ export const useOrchestratorData = () => { return () => clearInterval(interval); }, [loadJobs, checkPipelineStatus]); - return { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus }; + return { jobs, stats, settings, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus }; }; From 158e4b108b5c9aaaf9a4ca4271db97725963564d Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 22:46:50 +0000 Subject: [PATCH 04/17] reduce prop drilling --- .../src/client/components/JobHeader.tsx | 14 +++-- .../src/client/components/ReadyPanel.tsx | 3 - .../discovered-panel/DecideMode.tsx | 3 - .../discovered-panel/DiscoveredPanel.tsx | 3 - orchestrator/src/client/hooks/useSettings.ts | 58 +++++++++++++++++++ .../src/client/pages/OrchestratorPage.tsx | 5 +- .../pages/orchestrator/JobDetailPanel.tsx | 5 -- .../pages/orchestrator/useOrchestratorData.ts | 15 ++--- 8 files changed, 72 insertions(+), 34 deletions(-) create mode 100644 orchestrator/src/client/hooks/useSettings.ts diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index 704d9d4..f42da72 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -7,10 +7,11 @@ 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; - showSponsorInfo?: boolean; onCheckSponsor?: () => Promise; } @@ -83,16 +84,16 @@ const SponsorPill: React.FC = ({ score, names, onCheck }) => { @@ -133,7 +134,8 @@ const SponsorPill: React.FC = ({ score, names, onCheck }) => { ); }; -export const JobHeader: React.FC = ({ job, className, showSponsorInfo = true, onCheckSponsor }) => { +export const JobHeader: React.FC = ({ job, className, onCheckSponsor }) => { + const { showSponsorInfo } = useSettings(); const deadline = formatDate(job.deadline); return ( diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index 068d4ea..acc2ade 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -50,7 +50,6 @@ interface ReadyPanelProps { onJobMoved: (jobId: string) => void; onEditTailoring: () => void; onEditDescription: () => void; - showSponsorInfo?: boolean; } const safeFilenamePart = (value: string | null | undefined) => @@ -62,7 +61,6 @@ export const ReadyPanel: React.FC = ({ onJobMoved, onEditTailoring, onEditDescription, - showSponsorInfo, }) => { const [isMarkingApplied, setIsMarkingApplied] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); @@ -224,7 +222,6 @@ export const ReadyPanel: React.FC = ({ await api.checkSponsor(job.id); await onJobUpdated(); }} - showSponsorInfo={showSponsorInfo} /> {/* ───────────────────────────────────────────────────────────────────── diff --git a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx index 6d7418b..777ea89 100644 --- a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx @@ -15,7 +15,6 @@ interface DecideModeProps { onSkip: () => void; isSkipping: boolean; onCheckSponsor?: () => Promise; - showSponsorInfo?: boolean; } export const DecideMode: React.FC = ({ @@ -24,7 +23,6 @@ export const DecideMode: React.FC = ({ onSkip, isSkipping, onCheckSponsor, - showSponsorInfo, }) => { const [showDescription, setShowDescription] = useState(false); const jobLink = job.applicationLink || job.jobUrl; @@ -40,7 +38,6 @@ export const DecideMode: React.FC = ({
diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx index 9593b0f..3065cec 100644 --- a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx @@ -14,14 +14,12 @@ interface DiscoveredPanelProps { job: Job | null; onJobUpdated: () => void | Promise; onJobMoved: (jobId: string) => void; - showSponsorInfo?: boolean; } export const DiscoveredPanel: React.FC = ({ job, onJobUpdated, onJobMoved, - showSponsorInfo, }) => { const [mode, setMode] = useState("decide"); const [isSkipping, setIsSkipping] = useState(false); @@ -91,7 +89,6 @@ export const DiscoveredPanel: React.FC = ({ await api.checkSponsor(job.id); await onJobUpdated(); }} - showSponsorInfo={showSponsorInfo} /> ) : ( void> = new Set(); +let isFetching = false; + +export function useSettings() { + const [settings, setSettings] = useState(settingsCache); + + useEffect(() => { + if (settingsCache) { + setSettings(settingsCache); + } + + const handleUpdate = (newSettings: AppSettings) => { + setSettings(newSettings); + }; + + subscribers.add(handleUpdate); + + if (!settingsCache && !isFetching) { + isFetching = true; + api.getSettings() + .then((data) => { + settingsCache = data; + subscribers.forEach(sub => sub(data)); + }) + .finally(() => { + isFetching = false; + }); + } + + return () => { + subscribers.delete(handleUpdate); + }; + }, []); + + const refreshSettings = async () => { + isFetching = true; + try { + const data = await api.getSettings(); + settingsCache = data; + subscribers.forEach(sub => sub(data)); + return data; + } finally { + isFetching = false; + } + }; + + return { + settings, + isLoading: !settings && isFetching, + showSponsorInfo: settings?.showSponsorInfo ?? true, + refreshSettings, + }; +} diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 612fe23..c6fc6ee 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -122,9 +122,7 @@ export const OrchestratorPage: React.FC = () => { }; const { pipelineSources, setPipelineSources, toggleSource } = usePipelineSources(); - const { jobs, stats, settings, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs } = useOrchestratorData(); - - const showSponsorInfo = settings?.showSponsorInfo ?? true; + const { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs } = useOrchestratorData(); const activeJobs = useFilteredJobs(jobs, activeTab, sourceFilter, searchQuery, sort); const counts = useMemo(() => getJobCounts(jobs), [jobs]); @@ -278,7 +276,6 @@ export const OrchestratorPage: React.FC = () => { onSelectJobId={handleSelectJobId} onJobUpdated={loadJobs} onSetActiveTab={setActiveTab} - showSponsorInfo={showSponsorInfo} />
)} diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index 563c0bc..e7a1297 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -41,7 +41,6 @@ interface JobDetailPanelProps { onSelectJobId: (jobId: string | null) => void; onJobUpdated: () => Promise; onSetActiveTab: (tab: FilterTab) => void; - showSponsorInfo?: boolean; } export const JobDetailPanel: React.FC = ({ @@ -51,7 +50,6 @@ export const JobDetailPanel: React.FC = ({ onSelectJobId, onJobUpdated, onSetActiveTab, - showSponsorInfo, }) => { const [detailTab, setDetailTab] = useState<"overview" | "tailoring" | "description">("overview"); const [isEditingDescription, setIsEditingDescription] = useState(false); @@ -235,7 +233,6 @@ export const JobDetailPanel: React.FC = ({ job={selectedJob} onJobUpdated={onJobUpdated} onJobMoved={handleJobMoved} - showSponsorInfo={showSponsorInfo} /> ); } @@ -257,7 +254,6 @@ export const JobDetailPanel: React.FC = ({ setIsEditingDescription(true); }, 50); }} - showSponsorInfo={showSponsorInfo} /> ); } @@ -279,7 +275,6 @@ export const JobDetailPanel: React.FC = ({ await api.checkSponsor(selectedJob.id); await onJobUpdated(); }} - showSponsorInfo={showSponsorInfo} />
diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts index 0f6dac9..2c440cb 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import type { Job, JobStatus, AppSettings } from "../../../shared/types"; +import type { Job, JobStatus } from "../../../shared/types"; import * as api from "../../api"; const initialStats: Record = { @@ -16,20 +16,15 @@ const initialStats: Record = { export const useOrchestratorData = () => { const [jobs, setJobs] = useState([]); const [stats, setStats] = useState>(initialStats); - const [settings, setSettings] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isPipelineRunning, setIsPipelineRunning] = useState(false); const loadJobs = useCallback(async () => { try { setIsLoading(true); - const [jobsData, settingsData] = await Promise.all([ - api.getJobs(), - api.getSettings(), - ]); - setJobs(jobsData.jobs); - setStats(jobsData.byStatus); - setSettings(settingsData); + const data = await api.getJobs(); + setJobs(data.jobs); + setStats(data.byStatus); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load jobs"; toast.error(message); @@ -59,5 +54,5 @@ export const useOrchestratorData = () => { return () => clearInterval(interval); }, [loadJobs, checkPipelineStatus]); - return { jobs, stats, settings, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus }; + return { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus }; }; From 06d25b63a228f25d0d4fd66e92204c1f39c8bd91 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 22:51:36 +0000 Subject: [PATCH 05/17] more granular messages --- .../src/client/components/JobHeader.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index f42da72..bbca08e 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -108,26 +108,32 @@ const SponsorPill: React.FC = ({ score, names, onCheck }) => { return null; } - const canSponsor = score >= 95; - const label = canSponsor ? "Can Sponsor" : "Unsure if Sponsor"; - const dotClass = canSponsor ? "bg-emerald-500" : "bg-slate-500"; - const tooltipContent = canSponsor - ? `${score}% match` - : `Closest: ${parsedNames.join(", ")} (${score}% match)`; + 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${parsedNames.length > 0 ? `: ${parsedNames.join(", ")}` : ""}`; return ( - - {label} + + {status.label} - {canSponsor &&

{parsedNames.join(", ")}

} - {!canSponsor &&

Unsure if sponsor

} -

{tooltipContent}

+ {parsedNames.length > 0 && ( +

+ Matched: + {parsedNames.join(", ")} +

+ )} +

{tooltipContent}

From 1106e95ad6a9ba6f94bd95ddd5cb773b97d9a459 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 22:53:13 +0000 Subject: [PATCH 06/17] better copy --- orchestrator/src/client/components/JobHeader.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index bbca08e..b1e15f1 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -115,7 +115,7 @@ const SponsorPill: React.FC = ({ score, names, onCheck }) => { }; const status = getStatus(score); - const tooltipContent = `${score}% match${parsedNames.length > 0 ? `: ${parsedNames.join(", ")}` : ""}`; + const tooltipContent = `${score}% match`; return ( @@ -129,7 +129,7 @@ const SponsorPill: React.FC = ({ score, names, onCheck }) => { {parsedNames.length > 0 && (

- Matched: + Matched {parsedNames.join(", ")}

)} From 3d55e786148477a99c87d9c23c1e9f7be15c58d9 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 23:07:55 +0000 Subject: [PATCH 07/17] tests --- .../src/client/components/JobHeader.test.tsx | 105 ++++++++++++++++++ .../src/client/hooks/useSettings.test.ts | 66 +++++++++++ orchestrator/src/client/hooks/useSettings.ts | 7 ++ .../src/server/api/routes/jobs.test.ts | 22 ++++ 4 files changed, 200 insertions(+) create mode 100644 orchestrator/src/client/components/JobHeader.test.tsx create mode 100644 orchestrator/src/client/hooks/useSettings.test.ts diff --git a/orchestrator/src/client/components/JobHeader.test.tsx b/orchestrator/src/client/components/JobHeader.test.tsx new file mode 100644 index 0000000..b9a0232 --- /dev/null +++ b/orchestrator/src/client/components/JobHeader.test.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { JobHeader } from "./JobHeader"; +import { useSettings } from "../hooks/useSettings"; +import * as api from "../api"; +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 }) => ( +
{children}
+ ), +})); + +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(); + 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(); + + 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(); + + 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(); + + expect(screen.getByText("Potential Sponsor")).toBeInTheDocument(); + }); + + it("shows 'Sponsor Not Found' when score < 80", () => { + const jobNoSponsor = { ...mockJob, sponsorMatchScore: 40, sponsorMatchNames: '["Other Corp"]' }; + render(); + + 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(); + + expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument(); + expect(screen.queryByText("Check Sponsorship Status")).not.toBeInTheDocument(); + }); +}); diff --git a/orchestrator/src/client/hooks/useSettings.test.ts b/orchestrator/src/client/hooks/useSettings.test.ts new file mode 100644 index 0000000..e8aff35 --- /dev/null +++ b/orchestrator/src/client/hooks/useSettings.test.ts @@ -0,0 +1,66 @@ +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); + }); +}); diff --git a/orchestrator/src/client/hooks/useSettings.ts b/orchestrator/src/client/hooks/useSettings.ts index c970690..cb8fae3 100644 --- a/orchestrator/src/client/hooks/useSettings.ts +++ b/orchestrator/src/client/hooks/useSettings.ts @@ -56,3 +56,10 @@ export function useSettings() { refreshSettings, }; } + +/** @internal For testing only */ +export function _resetSettingsCache() { + settingsCache = null; + isFetching = false; + subscribers.clear(); +} diff --git a/orchestrator/src/server/api/routes/jobs.test.ts b/orchestrator/src/server/api/routes/jobs.test.ts index 14324de..7fba136 100644 --- a/orchestrator/src/server/api/routes/jobs.test.ts +++ b/orchestrator/src/server/api/routes/jobs.test.ts @@ -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'); + }); }); From ba9dcc78676d12aa6518cda0836e3e9893f28351 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 23:10:02 +0000 Subject: [PATCH 08/17] tests fix --- orchestrator/src/server/api/routes/pipeline.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/orchestrator/src/server/api/routes/pipeline.test.ts b/orchestrator/src/server/api/routes/pipeline.test.ts index 0d7dc34..68c80f3 100644 --- a/orchestrator/src/server/api/routes/pipeline.test.ts +++ b/orchestrator/src/server/api/routes/pipeline.test.ts @@ -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(); } From 53e16013a85c651bb0fcfc2b1682a0e23606961e Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:17:41 +0000 Subject: [PATCH 09/17] unused import Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- orchestrator/src/server/services/ai-resilience.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/server/services/ai-resilience.test.ts b/orchestrator/src/server/services/ai-resilience.test.ts index dea96a4..fc45424 100644 --- a/orchestrator/src/server/services/ai-resilience.test.ts +++ b/orchestrator/src/server/services/ai-resilience.test.ts @@ -143,7 +143,7 @@ describe('AI Service Resilience', () => { }) }; 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); From af2ee1fa9c1b53b7806a8e48aa60e92edda2226e Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:17:48 +0000 Subject: [PATCH 10/17] unused import Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- orchestrator/src/client/components/JobHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index b1e15f1..2498049 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from "react"; -import { Calendar, DollarSign, Loader2, MapPin, Search, Shield } from "lucide-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"; From 0ec707143a0ed6ce362762424b0e66a89e840e4b Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:18:00 +0000 Subject: [PATCH 11/17] unused import Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- orchestrator/src/client/components/JobHeader.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/orchestrator/src/client/components/JobHeader.test.tsx b/orchestrator/src/client/components/JobHeader.test.tsx index b9a0232..0cc701d 100644 --- a/orchestrator/src/client/components/JobHeader.test.tsx +++ b/orchestrator/src/client/components/JobHeader.test.tsx @@ -3,7 +3,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { JobHeader } from "./JobHeader"; import { useSettings } from "../hooks/useSettings"; -import * as api from "../api"; import type { Job } from "../../shared/types"; // Mock useSettings From dd03eed0fbefcd0ee78fa6b2837715b438aa86f9 Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:18:08 +0000 Subject: [PATCH 12/17] unused import Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- orchestrator/src/client/components/JobHeader.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/client/components/JobHeader.test.tsx b/orchestrator/src/client/components/JobHeader.test.tsx index 0cc701d..a96407c 100644 --- a/orchestrator/src/client/components/JobHeader.test.tsx +++ b/orchestrator/src/client/components/JobHeader.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import { JobHeader } from "./JobHeader"; import { useSettings } from "../hooks/useSettings"; import type { Job } from "../../shared/types"; From ba27eb07b1edf44c3f440235183734c3bb7ef351 Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:19:03 +0000 Subject: [PATCH 13/17] remove redundant check Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- orchestrator/src/server/pipeline/orchestrator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index 08166e9..bfd605a 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -321,8 +321,8 @@ export async function runPipeline(config: Partial = {}): Promise await jobsRepo.updateJob(job.id, { suitabilityScore: score, suitabilityReason: reason, - ...(sponsorMatchScore !== undefined && { sponsorMatchScore }), - ...(sponsorMatchNames !== undefined && { sponsorMatchNames }), + sponsorMatchScore, + sponsorMatchNames, }); } From 6c3bb681d140bca4102368db814177fcd562f7d8 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 23:23:03 +0000 Subject: [PATCH 14/17] tests --- .../server/pipeline/sponsor-matching.test.ts | 403 ++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 orchestrator/src/server/pipeline/sponsor-matching.test.ts diff --git a/orchestrator/src/server/pipeline/sponsor-matching.test.ts b/orchestrator/src/server/pipeline/sponsor-matching.test.ts new file mode 100644 index 0000000..d8c8d00 --- /dev/null +++ b/orchestrator/src/server/pipeline/sponsor-matching.test.ts @@ -0,0 +1,403 @@ +/** + * 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(), +})); + +// 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 => ({ + 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; + let scoreJobSuitability: ReturnType; + let updateJob: ReturnType; + let getUnscoredDiscoveredJobs: ReturnType; + let bulkCreateJobs: ReturnType; + + 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; + scoreJobSuitability = scorer.scoreJobSuitability as ReturnType; + updateJob = jobsRepo.updateJob as ReturnType; + getUnscoredDiscoveredJobs = jobsRepo.getUnscoredDiscoveredJobs as ReturnType; + bulkCreateJobs = jobsRepo.bulkCreateJobs as ReturnType; + + // Default mock implementations + scoreJobSuitability.mockResolvedValue({ score: 75, reason: 'Good match' }); + bulkCreateJobs.mockResolvedValue({ created: 0, skipped: 0 }); + updateJob.mockResolvedValue(undefined); + }); + + 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']), + }) + ); + }); + }); +}); From ad0ca7f183729eb14e08a56a58069beb43d106c8 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 23:27:57 +0000 Subject: [PATCH 15/17] dedupe and tests --- orchestrator/src/server/api/routes/jobs.ts | 15 +- .../src/server/pipeline/orchestrator.ts | 14 +- .../server/pipeline/sponsor-matching.test.ts | 14 ++ .../services/visa-sponsors/index.test.ts | 107 ++++++++++++++ .../server/services/visa-sponsors/index.ts | 135 ++++++++++-------- 5 files changed, 204 insertions(+), 81 deletions(-) create mode 100644 orchestrator/src/server/services/visa-sponsors/index.test.ts diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts index f33e5e5..366cbcc 100644 --- a/orchestrator/src/server/api/routes/jobs.ts +++ b/orchestrator/src/server/api/routes/jobs.ts @@ -160,20 +160,7 @@ jobsRouter.post('/:id/check-sponsor', async (req: Request, res: Response) => { minScore: 50, }); - let sponsorMatchScore = 0; - let sponsorMatchNames: string | null = null; - - if (sponsorResults.length > 0) { - const topScore = sponsorResults[0].score; - // Get all 100% matches, or just the top match - const perfectMatches = sponsorResults.filter(r => r.score === 100); - const matchesToReport = perfectMatches.length >= 2 - ? perfectMatches.slice(0, 2) - : [sponsorResults[0]]; - - sponsorMatchScore = topScore; - sponsorMatchNames = JSON.stringify(matchesToReport.map(r => r.sponsor.organisationName)); - } + const { sponsorMatchScore, sponsorMatchNames } = visaSponsors.calculateSponsorMatchSummary(sponsorResults); // Update job with sponsor match info const updatedJob = await jobsRepo.updateJob(job.id, { diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index bfd605a..dd204c9 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -304,17 +304,9 @@ export async function runPipeline(config: Partial = {}): Promise minScore: 50, }); - if (sponsorResults.length > 0) { - const topScore = sponsorResults[0].score; - // Get all 100% matches, or just the top match - const perfectMatches = sponsorResults.filter(r => r.score === 100); - const matchesToReport = perfectMatches.length >= 2 - ? perfectMatches.slice(0, 2) - : [sponsorResults[0]]; - - sponsorMatchScore = topScore; - sponsorMatchNames = JSON.stringify(matchesToReport.map(r => r.sponsor.organisationName)); - } + const summary = visaSponsors.calculateSponsorMatchSummary(sponsorResults); + sponsorMatchScore = summary.sponsorMatchScore; + sponsorMatchNames = summary.sponsorMatchNames ?? undefined; } // Update score and sponsor match in database diff --git a/orchestrator/src/server/pipeline/sponsor-matching.test.ts b/orchestrator/src/server/pipeline/sponsor-matching.test.ts index d8c8d00..5ef0daa 100644 --- a/orchestrator/src/server/pipeline/sponsor-matching.test.ts +++ b/orchestrator/src/server/pipeline/sponsor-matching.test.ts @@ -11,6 +11,7 @@ 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 @@ -115,6 +116,7 @@ const createMockJob = (overrides: Partial = {}): Job => ({ describe('Sponsor Match Calculation', () => { let searchSponsors: ReturnType; + let calculateSponsorMatchSummary: ReturnType; let scoreJobSuitability: ReturnType; let updateJob: ReturnType; let getUnscoredDiscoveredJobs: ReturnType; @@ -129,6 +131,7 @@ describe('Sponsor Match Calculation', () => { const jobsRepo = await import('../repositories/jobs.js'); searchSponsors = visaSponsors.searchSponsors as ReturnType; + calculateSponsorMatchSummary = visaSponsors.calculateSponsorMatchSummary as ReturnType; scoreJobSuitability = scorer.scoreJobSuitability as ReturnType; updateJob = jobsRepo.updateJob as ReturnType; getUnscoredDiscoveredJobs = jobsRepo.getUnscoredDiscoveredJobs as ReturnType; @@ -138,6 +141,17 @@ describe('Sponsor Match Calculation', () => { 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(() => { diff --git a/orchestrator/src/server/services/visa-sponsors/index.test.ts b/orchestrator/src/server/services/visa-sponsors/index.test.ts new file mode 100644 index 0000000..93272a2 --- /dev/null +++ b/orchestrator/src/server/services/visa-sponsors/index.test.ts @@ -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'])); + }); +}); diff --git a/orchestrator/src/server/services/visa-sponsors/index.ts b/orchestrator/src/server/services/visa-sponsors/index.ts index b01c5f1..963d887 100644 --- a/orchestrator/src/server/services/visa-sponsors/index.ts +++ b/orchestrator/src/server/services/visa-sponsors/index.ts @@ -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 { 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(); // 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 { 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 { 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); } From 2d0d91cd2eba5f3a6b373d2607e114eff5eda1dc Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:32:35 +0000 Subject: [PATCH 16/17] Update orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../client/pages/settings/components/DisplaySettingsSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx b/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx index 564d0d9..88ba312 100644 --- a/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx @@ -35,7 +35,7 @@ export const DisplaySettingsSection: React.FC = ({ id="showSponsorInfo" checked={isChecked} onCheckedChange={(checked) => { - setShowSponsorInfoDraft(checked === true ? true : false) + setShowSponsorInfoDraft(checked === "indeterminate" ? null : checked === true) }} disabled={isLoading || isSaving} /> From 2f9845338a1c7fff8393e793d3d4ecf0ecf9e5a9 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 23:37:47 +0000 Subject: [PATCH 17/17] useSettings error handling --- .../src/client/hooks/useSettings.test.ts | 14 ++++++++ orchestrator/src/client/hooks/useSettings.ts | 32 ++++++++++++++++--- .../src/server/api/routes/test-utils.ts | 9 +++++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/orchestrator/src/client/hooks/useSettings.test.ts b/orchestrator/src/client/hooks/useSettings.test.ts index e8aff35..6fba317 100644 --- a/orchestrator/src/client/hooks/useSettings.test.ts +++ b/orchestrator/src/client/hooks/useSettings.test.ts @@ -63,4 +63,18 @@ describe('useSettings', () => { 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(); + }); }); diff --git a/orchestrator/src/client/hooks/useSettings.ts b/orchestrator/src/client/hooks/useSettings.ts index cb8fae3..bf1c7cd 100644 --- a/orchestrator/src/client/hooks/useSettings.ts +++ b/orchestrator/src/client/hooks/useSettings.ts @@ -3,29 +3,41 @@ import type { AppSettings } from '../../shared/types'; import * as api from '../api'; let settingsCache: AppSettings | null = null; -let subscribers: Set<(settings: AppSettings) => void> = new Set(); +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(settingsCache); + const [error, setError] = useState(settingsError); useEffect(() => { if (settingsCache) { setSettings(settingsCache); } + if (settingsError) { + setError(settingsError); + } - const handleUpdate = (newSettings: AppSettings) => { + 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; - subscribers.forEach(sub => sub(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; @@ -39,11 +51,19 @@ export function useSettings() { const refreshSettings = async () => { isFetching = true; + settingsError = null; + subscribers.forEach(sub => sub(settingsCache, null)); + try { const data = await api.getSettings(); settingsCache = data; - subscribers.forEach(sub => sub(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; } @@ -51,7 +71,8 @@ export function useSettings() { return { settings, - isLoading: !settings && isFetching, + error, + isLoading: !settings && isFetching && !error, showSponsorInfo: settings?.showSponsorInfo ?? true, refreshSettings, }; @@ -60,6 +81,7 @@ export function useSettings() { /** @internal For testing only */ export function _resetSettingsCache() { settingsCache = null; + settingsError = null; isFetching = false; subscribers.clear(); } diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts index ec6bd96..246f0aa 100644 --- a/orchestrator/src/server/api/routes/test-utils.ts +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -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 };