diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 94dab4c..ba5f6f5 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -9,6 +9,7 @@ import { Toaster } from "@/components/ui/sonner"; import { OrchestratorPage } from "./pages/OrchestratorPage"; import { SettingsPage } from "./pages/SettingsPage"; import { UkVisaJobsPage } from "./pages/UkVisaJobsPage"; +import { VisaSponsorsPage } from "./pages/VisaSponsorsPage"; export const App: React.FC = () => ( <> @@ -16,6 +17,7 @@ export const App: React.FC = () => ( } /> } /> } /> + } /> diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index b6318bf..b21d72b 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -11,9 +11,13 @@ import type { PipelineRun, AppSettings, ResumeProjectsSettings, + ResumeProjectCatalogItem, UkVisaJobsSearchResponse, UkVisaJobsImportResponse, CreateJobInput, + VisaSponsorSearchResponse, + VisaSponsorStatusResponse, + VisaSponsor, } from '../../shared/types'; const API_BASE = '/api'; @@ -191,4 +195,36 @@ export async function deleteJobsByStatus(status: string): Promise<{ }); } +// Visa Sponsors API +export async function getVisaSponsorStatus(): Promise { + return fetchApi('/visa-sponsors/status'); +} + +export async function searchVisaSponsors(input: { + query: string; + limit?: number; + minScore?: number; +}): Promise { + return fetchApi('/visa-sponsors/search', { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export async function getVisaSponsorOrganization(name: string): Promise { + return fetchApi(`/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) diff --git a/orchestrator/src/client/components/Header.tsx b/orchestrator/src/client/components/Header.tsx index 057174b..1927da8 100644 --- a/orchestrator/src/client/components/Header.tsx +++ b/orchestrator/src/client/components/Header.tsx @@ -10,6 +10,7 @@ import { RefreshCcw, Rocket, Settings, + Shield, Trash2, } from "lucide-react"; import { Link } from "react-router-dom"; @@ -99,6 +100,17 @@ export const Header: React.FC = ({ Refresh + + + + + + + + + {/* Main content */} +
+ {/* Left panel - Search and results */} +
+ {/* Search input */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 pr-10" + autoFocus + /> + {searchQuery && ( + + )} +
+ {isSearching && ( +
+ + Searching... +
+ )} +
+ + {/* Results list */} +
+ {/* No data state */} + {!isLoadingStatus && status?.totalSponsors === 0 && ( +
+ +
+ No sponsor data available +
+

+ The visa sponsor list hasn't been downloaded yet. +

+ +
+ )} + + {/* Empty search state */} + {status && status.totalSponsors > 0 && !searchQuery && ( +
+ +
+ Search for a company +
+

+ Enter a company name to check if they're on the UK visa sponsor register. +

+
+ )} + + {/* No results state */} + {searchQuery && !isSearching && results.length === 0 && ( +
+ +
+ No matches found +
+

+ No sponsors match "{searchQuery}". Try a different spelling. +

+
+ )} + + {/* Results */} + {results.length > 0 && ( +
+ {results.map((result, index) => ( + + ))} +
+ )} +
+ + {/* Results count footer */} + {results.length > 0 && ( +
+ {results.length} result{results.length !== 1 ? "s" : ""} +
+ )} +
+ + {/* Right panel - Details */} +
+ {!selectedOrg ? ( +
+ +
+ Select a company +
+

+ Click on a search result to view details. +

+
+ ) : isLoadingDetails ? ( +
+ +
+ ) : ( +
+ {/* Header */} +
+
+ + + Licensed Sponsor + + {selectedResult && ( + + {selectedResult.score}% Match + + )} +
+

+ {selectedOrg} +

+
+ + {/* Location */} + {orgDetails.length > 0 && (orgDetails[0].townCity || orgDetails[0].county) && ( +
+
+ Location +
+
+ + {[orgDetails[0].townCity, orgDetails[0].county] + .filter(Boolean) + .join(", ")} +
+
+ )} + + {/* Licence types / routes */} +
+
+ Licensed Routes ({orgDetails.length}) +
+
+ {orgDetails.map((entry, index) => ( +
+
+ + {entry.route} + +
+
+ Type & Rating:{" "} + {entry.typeRating} +
+
+ ))} +
+
+ + {/* Info box */} +
+
+ What does this mean? +
+

+ 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. +

+
+
+ )} +
+
+ + ); +}; diff --git a/orchestrator/src/server/api/routes.ts b/orchestrator/src/server/api/routes.ts index 3b0a397..9ca9cda 100644 --- a/orchestrator/src/server/api/routes.ts +++ b/orchestrator/src/server/api/routes.ts @@ -17,7 +17,8 @@ import { normalizeResumeProjectsSettings, resolveResumeProjectsSettings, } 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(); let isUkVisaJobsSearchRunning = false; @@ -928,3 +929,107 @@ apiRouter.delete('/database', async (req: Request, res: Response) => { 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 = { + 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 = { + 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 }); + } +}); + diff --git a/orchestrator/src/server/index.ts b/orchestrator/src/server/index.ts index d2b769d..5911f70 100644 --- a/orchestrator/src/server/index.ts +++ b/orchestrator/src/server/index.ts @@ -8,6 +8,7 @@ import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { config } from 'dotenv'; import { apiRouter } from './api/index.js'; +import { initialize as initializeVisaSponsors } from './services/visa-sponsors/index.js'; // Load environment variables from orchestrator root const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -56,7 +57,7 @@ if (process.env.NODE_ENV === 'production') { } // Start server -app.listen(PORT, () => { +app.listen(PORT, async () => { 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); + } }); diff --git a/orchestrator/src/server/services/visa-sponsors/index.ts b/orchestrator/src/server/services/visa-sponsors/index.ts new file mode 100644 index 0000000..cc961cb --- /dev/null +++ b/orchestrator/src/server/services/visa-sponsors/index.ts @@ -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 { + 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(); // 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 | 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 { + 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); +} diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index 697233f..b47f56b 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -187,6 +187,36 @@ export interface UkVisaJobsImportResponse { 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 { isRunning: boolean; lastRun: PipelineRun | null;