visa sponsors page

This commit is contained in:
DaKheera47 2026-01-15 12:35:44 +00:00
parent e54b5f2178
commit 383403f0ac
8 changed files with 1228 additions and 2 deletions

View File

@ -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 />

View File

@ -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)

View File

@ -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'

View 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>
</>
);
};

View File

@ -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 });
}
});

View File

@ -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);
}
}); });

View 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);
}

View File

@ -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;