visa sponsors page
This commit is contained in:
parent
e54b5f2178
commit
383403f0ac
@ -9,6 +9,7 @@ import { Toaster } from "@/components/ui/sonner";
|
|||||||
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||||
import { SettingsPage } from "./pages/SettingsPage";
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
|
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
|
||||||
|
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
||||||
|
|
||||||
export const App: React.FC = () => (
|
export const App: React.FC = () => (
|
||||||
<>
|
<>
|
||||||
@ -16,6 +17,7 @@ export const App: React.FC = () => (
|
|||||||
<Route path="/" element={<OrchestratorPage />} />
|
<Route path="/" element={<OrchestratorPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
|
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
|
||||||
|
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
<Toaster position="bottom-right" richColors closeButton />
|
<Toaster position="bottom-right" richColors closeButton />
|
||||||
|
|||||||
@ -11,9 +11,13 @@ import type {
|
|||||||
PipelineRun,
|
PipelineRun,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
ResumeProjectsSettings,
|
ResumeProjectsSettings,
|
||||||
|
ResumeProjectCatalogItem,
|
||||||
UkVisaJobsSearchResponse,
|
UkVisaJobsSearchResponse,
|
||||||
UkVisaJobsImportResponse,
|
UkVisaJobsImportResponse,
|
||||||
CreateJobInput,
|
CreateJobInput,
|
||||||
|
VisaSponsorSearchResponse,
|
||||||
|
VisaSponsorStatusResponse,
|
||||||
|
VisaSponsor,
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
|
|
||||||
const API_BASE = '/api';
|
const API_BASE = '/api';
|
||||||
@ -191,4 +195,36 @@ export async function deleteJobsByStatus(status: string): Promise<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visa Sponsors API
|
||||||
|
export async function getVisaSponsorStatus(): Promise<VisaSponsorStatusResponse> {
|
||||||
|
return fetchApi<VisaSponsorStatusResponse>('/visa-sponsors/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchVisaSponsors(input: {
|
||||||
|
query: string;
|
||||||
|
limit?: number;
|
||||||
|
minScore?: number;
|
||||||
|
}): Promise<VisaSponsorSearchResponse> {
|
||||||
|
return fetchApi<VisaSponsorSearchResponse>('/visa-sponsors/search', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVisaSponsorOrganization(name: string): Promise<VisaSponsor[]> {
|
||||||
|
return fetchApi<VisaSponsor[]>(`/visa-sponsors/organization/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateVisaSponsorList(): Promise<{
|
||||||
|
message: string;
|
||||||
|
status: VisaSponsorStatusResponse;
|
||||||
|
}> {
|
||||||
|
return fetchApi<{
|
||||||
|
message: string;
|
||||||
|
status: VisaSponsorStatusResponse;
|
||||||
|
}>('/visa-sponsors/update', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Bulk operations (intentionally none - processing is manual)
|
// Bulk operations (intentionally none - processing is manual)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Rocket,
|
Rocket,
|
||||||
Settings,
|
Settings,
|
||||||
|
Shield,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@ -99,6 +100,17 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
<span className='hidden sm:inline'>Refresh</span>
|
<span className='hidden sm:inline'>Refresh</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
>
|
||||||
|
<Link to='/visa-sponsors'>
|
||||||
|
<Shield className='h-4 w-4' />
|
||||||
|
<span className='hidden sm:inline'>Visa Sponsors</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
variant='outline'
|
variant='outline'
|
||||||
|
|||||||
508
orchestrator/src/client/pages/VisaSponsorsPage.tsx
Normal file
508
orchestrator/src/client/pages/VisaSponsorsPage.tsx
Normal file
@ -0,0 +1,508 @@
|
|||||||
|
/**
|
||||||
|
* UK Visa Sponsors search page.
|
||||||
|
* Allows searching the government's list of licensed visa sponsors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
Download,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Loader2,
|
||||||
|
MapPin,
|
||||||
|
Search,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as api from "../api";
|
||||||
|
import type {
|
||||||
|
VisaSponsor,
|
||||||
|
VisaSponsorSearchResult,
|
||||||
|
VisaSponsorStatusResponse,
|
||||||
|
} from "../../shared/types";
|
||||||
|
|
||||||
|
const formatDateTime = (dateStr?: string | null) => {
|
||||||
|
if (!dateStr) return "Never";
|
||||||
|
try {
|
||||||
|
const parsed = new Date(dateStr);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return dateStr;
|
||||||
|
const date = parsed.toLocaleDateString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const time = parsed.toLocaleTimeString("en-GB", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
return `${date} ${time}`;
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get score styling based on match quality
|
||||||
|
*/
|
||||||
|
const getScoreTokens = (score: number) => {
|
||||||
|
if (score >= 90)
|
||||||
|
return {
|
||||||
|
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||||
|
bar: "bg-emerald-500/80",
|
||||||
|
};
|
||||||
|
if (score >= 70)
|
||||||
|
return {
|
||||||
|
badge: "border-amber-500/30 bg-amber-500/10 text-amber-200",
|
||||||
|
bar: "bg-amber-500/80",
|
||||||
|
};
|
||||||
|
if (score >= 50)
|
||||||
|
return {
|
||||||
|
badge: "border-orange-500/30 bg-orange-500/10 text-orange-200",
|
||||||
|
bar: "bg-orange-500/80",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
badge: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||||
|
bar: "bg-rose-500/80",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ScoreMeter: React.FC<{ score: number }> = ({ score }) => {
|
||||||
|
const tokens = getScoreTokens(score);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<div className="h-1.5 w-12 rounded-full bg-muted/40">
|
||||||
|
<div
|
||||||
|
className={cn("h-1.5 rounded-full", tokens.bar)}
|
||||||
|
style={{ width: `${Math.max(4, Math.min(100, score))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="tabular-nums text-foreground">{score}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VisaSponsorsPage: React.FC = () => {
|
||||||
|
// State
|
||||||
|
const [status, setStatus] = useState<VisaSponsorStatusResponse | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<VisaSponsorSearchResult[]>([]);
|
||||||
|
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
|
||||||
|
const [orgDetails, setOrgDetails] = useState<VisaSponsor[]>([]);
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
const [isLoadingStatus, setIsLoadingStatus] = useState(true);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
||||||
|
|
||||||
|
// Fetch status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
setIsLoadingStatus(true);
|
||||||
|
try {
|
||||||
|
const data = await api.getVisaSponsorStatus();
|
||||||
|
setStatus(data);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to fetch status";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search with debounce
|
||||||
|
const handleSearch = useCallback(async (query: string) => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await api.searchVisaSponsors({
|
||||||
|
query: query.trim(),
|
||||||
|
limit: 100,
|
||||||
|
minScore: 20,
|
||||||
|
});
|
||||||
|
setResults(response.results);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Search failed";
|
||||||
|
toast.error(message);
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Debounced search effect
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
handleSearch(searchQuery);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, handleSearch]);
|
||||||
|
|
||||||
|
// Auto-select first result
|
||||||
|
useEffect(() => {
|
||||||
|
if (results.length === 0) {
|
||||||
|
setSelectedOrg(null);
|
||||||
|
setOrgDetails([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedOrg || !results.some((r) => r.sponsor.organisationName === selectedOrg)) {
|
||||||
|
const firstOrg = results[0].sponsor.organisationName;
|
||||||
|
setSelectedOrg(firstOrg);
|
||||||
|
fetchOrgDetails(firstOrg);
|
||||||
|
}
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
// Fetch organization details
|
||||||
|
const fetchOrgDetails = async (orgName: string) => {
|
||||||
|
setIsLoadingDetails(true);
|
||||||
|
setSelectedOrg(orgName);
|
||||||
|
try {
|
||||||
|
const details = await api.getVisaSponsorOrganization(orgName);
|
||||||
|
setOrgDetails(details);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to fetch details";
|
||||||
|
toast.error(message);
|
||||||
|
setOrgDetails([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingDetails(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger manual update
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
const result = await api.updateVisaSponsorList();
|
||||||
|
setStatus(result.status);
|
||||||
|
toast.success(result.message);
|
||||||
|
// Re-run search if there was a query
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
handleSearch(searchQuery);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Update failed";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedResult = useMemo(
|
||||||
|
() => results.find((r) => r.sponsor.organisationName === selectedOrg) ?? null,
|
||||||
|
[results, selectedOrg]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
|
||||||
|
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-sm font-semibold tracking-tight">Visa Sponsors</div>
|
||||||
|
<div className="text-xs text-muted-foreground">UK Register Search</div>
|
||||||
|
</div>
|
||||||
|
{(isUpdating || status?.isUpdating) && (
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-200">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||||
|
Updating
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Status info */}
|
||||||
|
{status && (
|
||||||
|
<div className="hidden md:flex items-center gap-4 text-xs text-muted-foreground mr-2">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<FileSpreadsheet className="h-3.5 w-3.5" />
|
||||||
|
{status.totalSponsors.toLocaleString()} sponsors
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
{formatDateTime(status.lastUpdated)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleUpdate}
|
||||||
|
disabled={isUpdating || status?.isUpdating}
|
||||||
|
aria-label="Update sponsor list"
|
||||||
|
>
|
||||||
|
{isUpdating || status?.isUpdating ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button asChild variant="ghost" size="icon" aria-label="Back to Orchestrator">
|
||||||
|
<Link to="/">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Left panel - Search and results */}
|
||||||
|
<div className="flex w-[420px] flex-col border-r">
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search for a company name..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 pr-10"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSearching && (
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* No data state */}
|
||||||
|
{!isLoadingStatus && status?.totalSponsors === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||||
|
<AlertCircle className="h-10 w-10 text-amber-400 mb-4" />
|
||||||
|
<div className="text-sm font-medium text-foreground mb-1">
|
||||||
|
No sponsor data available
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4 max-w-xs">
|
||||||
|
The visa sponsor list hasn't been downloaded yet.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" onClick={handleUpdate} disabled={isUpdating}>
|
||||||
|
{isUpdating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Downloading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download List
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty search state */}
|
||||||
|
{status && status.totalSponsors > 0 && !searchQuery && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||||
|
<Search className="h-10 w-10 text-muted-foreground/50 mb-4" />
|
||||||
|
<div className="text-sm font-medium text-foreground mb-1">
|
||||||
|
Search for a company
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground max-w-xs">
|
||||||
|
Enter a company name to check if they're on the UK visa sponsor register.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No results state */}
|
||||||
|
{searchQuery && !isSearching && results.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||||
|
<AlertCircle className="h-10 w-10 text-muted-foreground/50 mb-4" />
|
||||||
|
<div className="text-sm font-medium text-foreground mb-1">
|
||||||
|
No matches found
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground max-w-xs">
|
||||||
|
No sponsors match "{searchQuery}". Try a different spelling.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<button
|
||||||
|
key={`${result.sponsor.organisationName}-${index}`}
|
||||||
|
onClick={() => fetchOrgDetails(result.sponsor.organisationName)}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-4 py-3 text-left transition-colors",
|
||||||
|
selectedOrg === result.sponsor.organisationName
|
||||||
|
? "bg-muted/50"
|
||||||
|
: "hover:bg-muted/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Building2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">
|
||||||
|
{result.sponsor.organisationName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(result.sponsor.townCity || result.sponsor.county) && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
{[result.sponsor.townCity, result.sponsor.county]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<ScoreMeter score={result.score} />
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results count footer */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="border-t px-4 py-2 text-xs text-muted-foreground">
|
||||||
|
{results.length} result{results.length !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel - Details */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{!selectedOrg ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||||
|
<Building2 className="h-10 w-10 text-muted-foreground/50 mb-4" />
|
||||||
|
<div className="text-sm font-medium text-foreground mb-1">
|
||||||
|
Select a company
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Click on a search result to view details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : isLoadingDetails ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-200">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
Licensed Sponsor
|
||||||
|
</span>
|
||||||
|
{selectedResult && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
|
||||||
|
getScoreTokens(selectedResult.score).badge
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedResult.score}% Match
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">
|
||||||
|
{selectedOrg}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{orgDetails.length > 0 && (orgDetails[0].townCity || orgDetails[0].county) && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2">
|
||||||
|
Location
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{[orgDetails[0].townCity, orgDetails[0].county]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Licence types / routes */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3">
|
||||||
|
Licensed Routes ({orgDetails.length})
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{orgDetails.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-lg border border-border/60 bg-muted/20 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{entry.route}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">Type & Rating:</span>{" "}
|
||||||
|
{entry.typeRating}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info box */}
|
||||||
|
<div className="rounded-lg border border-sky-500/30 bg-sky-500/10 p-4 text-sm">
|
||||||
|
<div className="font-medium text-sky-200 mb-1">
|
||||||
|
What does this mean?
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-sky-300/80">
|
||||||
|
This organisation is licensed by the UK Home Office to sponsor workers
|
||||||
|
on the routes listed above. An "A rating" means they're fully compliant
|
||||||
|
with their sponsor duties.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -17,7 +17,8 @@ import {
|
|||||||
normalizeResumeProjectsSettings,
|
normalizeResumeProjectsSettings,
|
||||||
resolveResumeProjectsSettings,
|
resolveResumeProjectsSettings,
|
||||||
} from '../services/resumeProjects.js';
|
} from '../services/resumeProjects.js';
|
||||||
import type { Job, JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse, UkVisaJobsSearchResponse, UkVisaJobsImportResponse } from '../../shared/types.js';
|
import * as visaSponsors from '../services/visa-sponsors/index.js';
|
||||||
|
import type { Job, JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse, UkVisaJobsSearchResponse, UkVisaJobsImportResponse, VisaSponsorSearchResponse, VisaSponsorStatusResponse } from '../../shared/types.js';
|
||||||
|
|
||||||
export const apiRouter = Router();
|
export const apiRouter = Router();
|
||||||
let isUkVisaJobsSearchRunning = false;
|
let isUkVisaJobsSearchRunning = false;
|
||||||
@ -928,3 +929,107 @@ apiRouter.delete('/database', async (req: Request, res: Response) => {
|
|||||||
res.status(500).json({ success: false, error: message });
|
res.status(500).json({ success: false, error: message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Visa Sponsors API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/visa-sponsors/status - Get status of the visa sponsor service
|
||||||
|
*/
|
||||||
|
apiRouter.get('/visa-sponsors/status', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const status = visaSponsors.getStatus();
|
||||||
|
const response: ApiResponse<VisaSponsorStatusResponse> = {
|
||||||
|
success: true,
|
||||||
|
data: status,
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/visa-sponsors/search - Search for visa sponsors
|
||||||
|
*/
|
||||||
|
const visaSponsorSearchSchema = z.object({
|
||||||
|
query: z.string().min(1),
|
||||||
|
limit: z.number().int().min(1).max(200).optional(),
|
||||||
|
minScore: z.number().int().min(0).max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
apiRouter.post('/visa-sponsors/search', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const input = visaSponsorSearchSchema.parse(req.body);
|
||||||
|
|
||||||
|
const results = visaSponsors.searchSponsors(input.query, {
|
||||||
|
limit: input.limit,
|
||||||
|
minScore: input.minScore,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ApiResponse<VisaSponsorSearchResponse> = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
results,
|
||||||
|
query: input.query,
|
||||||
|
total: results.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/visa-sponsors/organization/:name - Get all entries for an organization
|
||||||
|
*/
|
||||||
|
apiRouter.get('/visa-sponsors/organization/:name', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const name = decodeURIComponent(req.params.name);
|
||||||
|
const entries = visaSponsors.getOrganizationDetails(name);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Organization not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: entries,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/visa-sponsors/update - Trigger a manual update of the visa sponsor list
|
||||||
|
*/
|
||||||
|
apiRouter.post('/visa-sponsors/update', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await visaSponsors.downloadLatestCsv();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({ success: false, error: result.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: result.message,
|
||||||
|
status: visaSponsors.getStatus(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { join, dirname } from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
import { apiRouter } from './api/index.js';
|
import { apiRouter } from './api/index.js';
|
||||||
|
import { initialize as initializeVisaSponsors } from './services/visa-sponsors/index.js';
|
||||||
|
|
||||||
// Load environment variables from orchestrator root
|
// Load environment variables from orchestrator root
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@ -56,7 +57,7 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, async () => {
|
||||||
console.log(`
|
console.log(`
|
||||||
╔═══════════════════════════════════════════════════════════╗
|
╔═══════════════════════════════════════════════════════════╗
|
||||||
║ ║
|
║ ║
|
||||||
@ -70,4 +71,11 @@ app.listen(PORT, () => {
|
|||||||
║ ║
|
║ ║
|
||||||
╚═══════════════════════════════════════════════════════════╝
|
╚═══════════════════════════════════════════════════════════╝
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Initialize visa sponsors service (downloads data if needed, starts scheduler)
|
||||||
|
try {
|
||||||
|
await initializeVisaSponsors();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Failed to initialize visa sponsors service:', error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
525
orchestrator/src/server/services/visa-sponsors/index.ts
Normal file
525
orchestrator/src/server/services/visa-sponsors/index.ts
Normal file
@ -0,0 +1,525 @@
|
|||||||
|
/**
|
||||||
|
* UK Visa Sponsors Service
|
||||||
|
*
|
||||||
|
* Manages downloading, storing, and searching the UK visa sponsor list.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DATA_DIR = path.join(__dirname, '../../../../data/visa-sponsors');
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisaSponsor {
|
||||||
|
organisationName: string;
|
||||||
|
townCity: string;
|
||||||
|
county: string;
|
||||||
|
typeRating: string;
|
||||||
|
route: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisaSponsorSearchResult {
|
||||||
|
sponsor: VisaSponsor;
|
||||||
|
score: number;
|
||||||
|
matchedName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisaSponsorStatus {
|
||||||
|
lastUpdated: string | null;
|
||||||
|
csvPath: string | null;
|
||||||
|
totalSponsors: number;
|
||||||
|
isUpdating: boolean;
|
||||||
|
nextScheduledUpdate: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common company suffixes to strip during comparison
|
||||||
|
const COMPANY_SUFFIXES = [
|
||||||
|
'limited', 'ltd', 'llp', 'plc', 'inc', 'incorporated',
|
||||||
|
'corporation', 'corp', 'company', 'co', 'llc',
|
||||||
|
'uk', 'international', 'intl', 'group', 'holdings',
|
||||||
|
't/a', 'trading as', '&', 'the'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Cache for loaded sponsors
|
||||||
|
let sponsorsCache: VisaSponsor[] | null = null;
|
||||||
|
let cacheLoadedAt: Date | null = null;
|
||||||
|
let isUpdating = false;
|
||||||
|
let updateError: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a company name for comparison (strips suffixes, punctuation, etc.)
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate similarity score between two strings (0-100)
|
||||||
|
* Uses Levenshtein distance with some optimizations
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
matrix[i][j] = Math.min(
|
||||||
|
matrix[i - 1][j] + 1, // deletion
|
||||||
|
matrix[i][j - 1] + 1, // insertion
|
||||||
|
matrix[i - 1][j - 1] + cost // substitution
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = matrix[s1.length][s2.length];
|
||||||
|
const maxLen = Math.max(s1.length, s2.length);
|
||||||
|
|
||||||
|
return Math.round(((maxLen - distance) / maxLen) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse CSV content into VisaSponsor array
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
sponsors.push({
|
||||||
|
organisationName: fields[0] || '',
|
||||||
|
townCity: fields[1] || '',
|
||||||
|
county: fields[2] || '',
|
||||||
|
typeRating: fields[3] || '',
|
||||||
|
route: fields[4] || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sponsors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single CSV line handling quoted fields
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
if (nextChar === '"') {
|
||||||
|
// Escaped quote
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
fields.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(current.trim());
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of CSV files sorted by date (newest first)
|
||||||
|
*/
|
||||||
|
function getCsvFiles(): string[] {
|
||||||
|
if (!fs.existsSync(DATA_DIR)) return [];
|
||||||
|
|
||||||
|
return fs.readdirSync(DATA_DIR)
|
||||||
|
.filter(f => f.endsWith('.csv'))
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metadata file path
|
||||||
|
*/
|
||||||
|
function getMetadataPath(): string {
|
||||||
|
return path.join(DATA_DIR, 'metadata.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read metadata
|
||||||
|
*/
|
||||||
|
function readMetadata(): { lastUpdated: string | null; csvFile: string | null } {
|
||||||
|
const metaPath = getMetadataPath();
|
||||||
|
if (!fs.existsSync(metaPath)) {
|
||||||
|
return { lastUpdated: null, csvFile: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return { lastUpdated: null, csvFile: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write metadata
|
||||||
|
*/
|
||||||
|
function writeMetadata(data: { lastUpdated: string; csvFile: string }): void {
|
||||||
|
fs.writeFileSync(getMetadataPath(), JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old CSV files (keep only 2)
|
||||||
|
*/
|
||||||
|
function cleanupOldCsvFiles(): void {
|
||||||
|
const files = getCsvFiles();
|
||||||
|
if (files.length > 2) {
|
||||||
|
for (const file of files.slice(2)) {
|
||||||
|
const filePath = path.join(DATA_DIR, file);
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
console.log(`🗑️ Removed old visa sponsor CSV: ${file}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`⚠️ Failed to remove old CSV: ${file}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the CSV download URL from the gov.uk page
|
||||||
|
*/
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the latest visa sponsor CSV
|
||||||
|
*/
|
||||||
|
export async function downloadLatestCsv(): Promise<{ success: boolean; message: string }> {
|
||||||
|
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`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
updateError = message;
|
||||||
|
console.error('❌ Failed to download visa sponsor list:', message);
|
||||||
|
return { success: false, message };
|
||||||
|
} finally {
|
||||||
|
isUpdating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load sponsors from the latest CSV file
|
||||||
|
*/
|
||||||
|
export function loadSponsors(): VisaSponsor[] {
|
||||||
|
// Return cache if valid (less than 1 hour old)
|
||||||
|
if (sponsorsCache && cacheLoadedAt) {
|
||||||
|
const cacheAge = Date.now() - cacheLoadedAt.getTime();
|
||||||
|
if (cacheAge < 60 * 60 * 1000) {
|
||||||
|
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);
|
||||||
|
cacheLoadedAt = new Date();
|
||||||
|
return sponsorsCache;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load sponsors:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for sponsors by company name
|
||||||
|
*/
|
||||||
|
export function searchSponsors(
|
||||||
|
query: string,
|
||||||
|
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,
|
||||||
|
score,
|
||||||
|
matchedName: normalizedSponsor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score descending
|
||||||
|
results.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return results.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
totalSponsors: sponsors.length,
|
||||||
|
isUpdating,
|
||||||
|
nextScheduledUpdate: getNextScheduledUpdate(),
|
||||||
|
error: updateError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all entries for a specific organization (they may have multiple routes)
|
||||||
|
*/
|
||||||
|
export function getOrganizationDetails(organisationName: string): VisaSponsor[] {
|
||||||
|
const sponsors = loadSponsors();
|
||||||
|
return sponsors.filter(s => s.organisationName === organisationName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Scheduled Updates (Cron-style)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let scheduledTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let nextScheduledUpdateTime: Date | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the next update time (default: 2 AM daily)
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next scheduled update time as ISO string
|
||||||
|
*/
|
||||||
|
function getNextScheduledUpdate(): string | null {
|
||||||
|
return nextScheduledUpdateTime?.toISOString() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule the next update
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
scheduleNextUpdate(hour); // Schedule the next one
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the scheduler
|
||||||
|
*/
|
||||||
|
export function startScheduler(hour = 2): void {
|
||||||
|
console.log('🚀 Starting visa sponsor update scheduler...');
|
||||||
|
scheduleNextUpdate(hour);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the scheduler
|
||||||
|
*/
|
||||||
|
export function stopScheduler(): void {
|
||||||
|
if (scheduledTimer) {
|
||||||
|
clearTimeout(scheduledTimer);
|
||||||
|
scheduledTimer = null;
|
||||||
|
nextScheduledUpdateTime = null;
|
||||||
|
console.log('⏹️ Stopped visa sponsor update scheduler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the service (download if no data exists)
|
||||||
|
*/
|
||||||
|
export async function initialize(): Promise<void> {
|
||||||
|
const metadata = readMetadata();
|
||||||
|
|
||||||
|
if (!metadata.csvFile) {
|
||||||
|
console.log('📥 No visa sponsor data found, downloading...');
|
||||||
|
await downloadLatestCsv();
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@ -187,6 +187,36 @@ export interface UkVisaJobsImportResponse {
|
|||||||
skipped: number;
|
skipped: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visa Sponsors types
|
||||||
|
export interface VisaSponsor {
|
||||||
|
organisationName: string;
|
||||||
|
townCity: string;
|
||||||
|
county: string;
|
||||||
|
typeRating: string;
|
||||||
|
route: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisaSponsorSearchResult {
|
||||||
|
sponsor: VisaSponsor;
|
||||||
|
score: number;
|
||||||
|
matchedName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisaSponsorSearchResponse {
|
||||||
|
results: VisaSponsorSearchResult[];
|
||||||
|
query: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisaSponsorStatusResponse {
|
||||||
|
lastUpdated: string | null;
|
||||||
|
csvPath: string | null;
|
||||||
|
totalSponsors: number;
|
||||||
|
isUpdating: boolean;
|
||||||
|
nextScheduledUpdate: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PipelineStatusResponse {
|
export interface PipelineStatusResponse {
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
lastRun: PipelineRun | null;
|
lastRun: PipelineRun | null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user