initial commit

This commit is contained in:
DaKheera47 2026-01-20 22:11:09 +00:00
parent 0eba05a1dd
commit 1fd6a4b4c2
25 changed files with 537 additions and 45 deletions

View File

@ -17,6 +17,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"better-sqlite3": "^11.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -2231,6 +2232,58 @@
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@ -2359,6 +2412,29 @@
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",

View File

@ -29,6 +29,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"better-sqlite3": "^11.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@ -86,6 +86,12 @@ export async function generateJobPdf(id: string): Promise<Job> {
});
}
export async function checkSponsor(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/check-sponsor`, {
method: 'POST',
});
}
export async function markAsApplied(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/apply`, {
method: 'POST',
@ -186,6 +192,7 @@ export async function updateSettings(update: {
jobspyCountryIndeed?: string | null
jobspySites?: string[] | null
jobspyLinkedinFetchDescription?: boolean | null
showSponsorInfo?: boolean | null
}): Promise<AppSettings> {
return fetchApi<AppSettings>('/settings', {
method: 'PATCH',

View File

@ -1,6 +1,8 @@
import React from "react";
import { Calendar, DollarSign, MapPin } from "lucide-react";
import React, { useMemo, useState } from "react";
import { Calendar, DollarSign, Loader2, MapPin, Search, Shield } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, formatDate, sourceLabel } from "@/lib/utils";
import type { Job, JobStatus } from "../../shared/types";
import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants";
@ -8,6 +10,8 @@ import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constant
interface JobHeaderProps {
job: Job;
className?: string;
showSponsorInfo?: boolean;
onCheckSponsor?: () => Promise<void>;
}
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
@ -42,7 +46,112 @@ const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
);
};
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
interface SponsorBadgeProps {
score: number | null;
names: string | null;
onCheck?: () => Promise<void>;
}
const SponsorBadge: React.FC<SponsorBadgeProps> = ({ score, names, onCheck }) => {
const [isChecking, setIsChecking] = useState(false);
const parsedNames = useMemo(() => {
if (!names) return [];
try {
return JSON.parse(names) as string[];
} catch {
return [];
}
}, [names]);
const handleCheck = async () => {
if (!onCheck) return;
setIsChecking(true);
try {
await onCheck();
} finally {
setIsChecking(false);
}
};
// Show "Check" button if no score and callback provided
if (score == null && onCheck) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-5 px-1.5 text-[9px] font-medium text-muted-foreground hover:text-foreground"
onClick={handleCheck}
disabled={isChecking}
>
{isChecking ? (
<Loader2 className="h-2.5 w-2.5 animate-spin" />
) : (
<Search className="h-2.5 w-2.5" />
)}
<span className="ml-0.5">{isChecking ? "Checking..." : "Check Visa"}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">Check if employer is a visa sponsor</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// If no score (and no callback), show nothing
if (score == null || score < 50) {
return null;
}
// Color tokens based on score
const getScoreTokens = (s: number) => {
if (s >= 90) return {
badge: "border-emerald-500/40 bg-emerald-500/15 text-emerald-300",
label: "Visa Sponsor"
};
if (s >= 70) return {
badge: "border-amber-500/40 bg-amber-500/15 text-amber-300",
label: "Likely Sponsor"
};
return {
badge: "border-orange-500/40 bg-orange-500/15 text-orange-300",
label: "Possible Sponsor"
};
};
const tokens = getScoreTokens(score);
const tooltipContent = parsedNames.length > 0
? `${score}% match: ${parsedNames.join(", ")}`
: `${score}% match with visa sponsor list`;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide cursor-help",
tokens.badge
)}
>
<Shield className="h-2.5 w-2.5" />
{score}%
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">{tooltipContent}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className, showSponsorInfo = true, onCheckSponsor }) => {
const deadline = formatDate(job.deadline);
return (
@ -51,7 +160,16 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-base font-semibold text-foreground/90">{job.title}</div>
<div className="text-xs text-muted-foreground">{job.employer}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{job.employer}</span>
{showSponsorInfo && (
<SponsorBadge
score={job.sponsorMatchScore}
names={job.sponsorMatchNames}
onCheck={onCheckSponsor}
/>
)}
</div>
</div>
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
{sourceLabel[job.source]}

View File

@ -141,7 +141,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
// Revert to ready status
await api.updateJob(jobId, { status: "ready" });
toast.success("Reverted to Ready");
if (recentlyApplied?.timeoutId) {
clearTimeout(recentlyApplied.timeoutId);
}
@ -215,7 +215,14 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
return (
<div className="flex flex-col h-full">
<JobHeader job={job} className="pb-4 border-b border-border/40" />
<JobHeader
job={job}
className="pb-4 border-b border-border/40"
onCheckSponsor={async () => {
await api.checkSponsor(job.id);
await onJobUpdated();
}}
/>
{/*
PRIMARY ACTION CLUSTER

View File

@ -14,6 +14,7 @@ interface DecideModeProps {
onTailor: () => void;
onSkip: () => void;
isSkipping: boolean;
onCheckSponsor?: () => Promise<void>;
}
export const DecideMode: React.FC<DecideModeProps> = ({
@ -21,6 +22,7 @@ export const DecideMode: React.FC<DecideModeProps> = ({
onTailor,
onSkip,
isSkipping,
onCheckSponsor,
}) => {
const [showDescription, setShowDescription] = useState(false);
const jobLink = job.applicationLink || job.jobUrl;
@ -33,7 +35,7 @@ export const DecideMode: React.FC<DecideModeProps> = ({
return (
<div className='flex flex-col h-full'>
<div className='space-y-4 pb-4'>
<JobHeader job={job} />
<JobHeader job={job} onCheckSponsor={onCheckSponsor} />
<div className='flex flex-col gap-2.5 pt-2 sm:flex-row'>
<Button

View File

@ -85,6 +85,10 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
onTailor={() => setMode("tailor")}
onSkip={handleSkip}
isSkipping={isSkipping}
onCheckSponsor={async () => {
await api.checkSponsor(job.id);
await onJobUpdated();
}}
/>
) : (
<TailorMode

View File

@ -33,6 +33,8 @@ const jobFixture: Job = {
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
@ -198,7 +200,7 @@ describe("OrchestratorPage", () => {
// Clicking job-2 should update URL
const job2Button = screen.getByTestId("select-job-2");
fireEvent.click(job2Button);
// Wait for URL to update
await waitFor(() => {
expect(locationText()).toContain("/all/job-2");

View File

@ -92,6 +92,9 @@ const baseSettings: AppSettings = {
jobspyLinkedinFetchDescription: true,
defaultJobspyLinkedinFetchDescription: true,
overrideJobspyLinkedinFetchDescription: null,
showSponsorInfo: true,
defaultShowSponsorInfo: true,
overrideShowSponsorInfo: null,
}
const renderPage = () => {

View File

@ -14,6 +14,7 @@ import * as api from "../api"
import { arraysEqual } from "@/lib/utils"
import { resumeProjectsEqual } from "./settings/utils"
import { DangerZoneSection } from "./settings/components/DangerZoneSection"
import { DisplaySettingsSection } from "./settings/components/DisplaySettingsSection"
import { GradcrackerSection } from "./settings/components/GradcrackerSection"
import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection"
import { JobspySection } from "./settings/components/JobspySection"
@ -41,6 +42,7 @@ export const SettingsPage: React.FC = () => {
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
const [showSponsorInfoDraft, setShowSponsorInfoDraft] = useState<boolean | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered'])
@ -69,6 +71,7 @@ export const SettingsPage: React.FC = () => {
setJobspyCountryIndeedDraft(data.overrideJobspyCountryIndeed)
setJobspySitesDraft(data.overrideJobspySites)
setJobspyLinkedinFetchDescriptionDraft(data.overrideJobspyLinkedinFetchDescription)
setShowSponsorInfoDraft(data.overrideShowSponsorInfo)
})
.catch((error) => {
const message = error instanceof Error ? error.message : "Failed to load settings"
@ -126,6 +129,9 @@ export const SettingsPage: React.FC = () => {
const effectiveJobspyLinkedinFetchDescription = settings?.jobspyLinkedinFetchDescription ?? true
const defaultJobspyLinkedinFetchDescription = settings?.defaultJobspyLinkedinFetchDescription ?? true
const overrideJobspyLinkedinFetchDescription = settings?.overrideJobspyLinkedinFetchDescription
const effectiveShowSponsorInfo = settings?.showSponsorInfo ?? true
const defaultShowSponsorInfo = settings?.defaultShowSponsorInfo ?? true
const overrideShowSponsorInfo = settings?.overrideShowSponsorInfo
const profileProjects = settings?.profileProjects ?? []
const maxProjectsTotal = profileProjects.length
const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0
@ -163,7 +169,8 @@ export const SettingsPage: React.FC = () => {
jobspyHoursOldDraft !== (overrideJobspyHoursOld ?? null) ||
jobspyCountryIndeedDraft !== (overrideJobspyCountryIndeed ?? null) ||
JSON.stringify((jobspySitesDraft ?? []).slice().sort()) !== JSON.stringify((overrideJobspySites ?? []).slice().sort()) ||
jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null)
jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null) ||
showSponsorInfoDraft !== (overrideShowSponsorInfo ?? null)
)
}, [
settings,
@ -192,12 +199,14 @@ export const SettingsPage: React.FC = () => {
jobspyCountryIndeedDraft,
jobspySitesDraft,
jobspyLinkedinFetchDescriptionDraft,
showSponsorInfoDraft,
overrideJobspyLocation,
overrideJobspyResultsWanted,
overrideJobspyHoursOld,
overrideJobspyCountryIndeed,
overrideJobspySites,
overrideJobspyLinkedinFetchDescription,
overrideShowSponsorInfo,
])
const handleSave = async () => {
@ -222,6 +231,7 @@ export const SettingsPage: React.FC = () => {
const jobspyCountryIndeedOverride = jobspyCountryIndeedDraft === defaultJobspyCountryIndeed ? null : jobspyCountryIndeedDraft
const jobspySitesOverride = arraysEqual((jobspySitesDraft ?? []).slice().sort(), (defaultJobspySites ?? []).slice().sort()) ? null : jobspySitesDraft
const jobspyLinkedinFetchDescriptionOverride = jobspyLinkedinFetchDescriptionDraft === defaultJobspyLinkedinFetchDescription ? null : jobspyLinkedinFetchDescriptionDraft
const showSponsorInfoOverride = showSponsorInfoDraft === defaultShowSponsorInfo ? null : showSponsorInfoDraft
const updated = await api.updateSettings({
model: trimmed.length > 0 ? trimmed : null,
modelScorer: trimmedScorer.length > 0 ? trimmedScorer : null,
@ -239,6 +249,7 @@ export const SettingsPage: React.FC = () => {
jobspyCountryIndeed: jobspyCountryIndeedOverride,
jobspySites: jobspySitesOverride,
jobspyLinkedinFetchDescription: jobspyLinkedinFetchDescriptionOverride,
showSponsorInfo: showSponsorInfoOverride,
})
setSettings(updated)
setModelDraft(updated.overrideModel ?? "")
@ -257,6 +268,7 @@ export const SettingsPage: React.FC = () => {
setJobspyCountryIndeedDraft(updated.overrideJobspyCountryIndeed)
setJobspySitesDraft(updated.overrideJobspySites)
setJobspyLinkedinFetchDescriptionDraft(updated.overrideJobspyLinkedinFetchDescription)
setShowSponsorInfoDraft(updated.overrideShowSponsorInfo)
toast.success("Settings saved")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save settings"
@ -340,6 +352,7 @@ export const SettingsPage: React.FC = () => {
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
showSponsorInfo: null,
})
setSettings(updated)
setModelDraft("")
@ -358,6 +371,7 @@ export const SettingsPage: React.FC = () => {
setJobspyCountryIndeedDraft(null)
setJobspySitesDraft(null)
setJobspyLinkedinFetchDescriptionDraft(null)
setShowSponsorInfoDraft(null)
toast.success("Reset to default")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to reset settings"
@ -471,6 +485,14 @@ export const SettingsPage: React.FC = () => {
isLoading={isLoading}
isSaving={isSaving}
/>
<DisplaySettingsSection
showSponsorInfoDraft={showSponsorInfoDraft}
setShowSponsorInfoDraft={setShowSponsorInfoDraft}
defaultShowSponsorInfo={defaultShowSponsorInfo}
effectiveShowSponsorInfo={effectiveShowSponsorInfo}
isLoading={isLoading}
isSaving={isSaving}
/>
<DangerZoneSection
statusesToClear={statusesToClear}
toggleStatusToClear={toggleStatusToClear}

View File

@ -100,6 +100,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,

View File

@ -269,7 +269,13 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
return (
<div className="space-y-3">
<JobHeader job={selectedJob} />
<JobHeader
job={selectedJob}
onCheckSponsor={async () => {
await api.checkSponsor(selectedJob.id);
await onJobUpdated();
}}
/>
<div className="flex flex-wrap items-center gap-1.5">
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">

View File

@ -31,6 +31,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,

View File

@ -0,0 +1,77 @@
import React from "react"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Separator } from "@/components/ui/separator"
type DisplaySettingsSectionProps = {
showSponsorInfoDraft: boolean | null
setShowSponsorInfoDraft: (value: boolean | null) => void
defaultShowSponsorInfo: boolean
effectiveShowSponsorInfo: boolean
isLoading: boolean
isSaving: boolean
}
export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
showSponsorInfoDraft,
setShowSponsorInfoDraft,
defaultShowSponsorInfo,
effectiveShowSponsorInfo,
isLoading,
isSaving,
}) => {
const isChecked = showSponsorInfoDraft ?? defaultShowSponsorInfo
return (
<AccordionItem value="display" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Display Settings</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="showSponsorInfo"
checked={isChecked}
onCheckedChange={(checked) => {
setShowSponsorInfoDraft(checked === true ? true : false)
}}
disabled={isLoading || isSaving}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="showSponsorInfo"
className="text-sm font-medium leading-none cursor-pointer"
>
Show visa sponsor information
</label>
<p className="text-xs text-muted-foreground">
Display a badge next to the employer name showing the match
percentage with the UK visa sponsor list. This helps identify
employers that are licensed to sponsor work visas.
</p>
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">
{defaultShowSponsorInfo ? "Enabled" : "Disabled"}
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -70,7 +70,7 @@ describe.sequential('Jobs API routes', () => {
it('applies a job and syncs to Notion', async () => {
const { createNotionEntry } = await import('../../services/notion.js');
vi.mocked(createNotionEntry).mockResolvedValue({ pageId: 'page-123' });
vi.mocked(createNotionEntry).mockResolvedValue({ success: true, pageId: 'page-123' });
const { createJob } = await import('../../repositories/jobs.js');
const job = await createJob({

View File

@ -4,6 +4,7 @@ import * as jobsRepo from '../../repositories/jobs.js';
import * as settingsRepo from '../../repositories/settings.js';
import { processJob, summarizeJob, generateFinalPdf } from '../../pipeline/index.js';
import { createNotionEntry } from '../../services/notion.js';
import * as visaSponsors from '../../services/visa-sponsors/index.js';
import type { Job, JobStatus, ApiResponse, JobsListResponse } from '../../../shared/types.js';
export const jobsRouter = Router();
@ -47,6 +48,8 @@ const updateJobSchema = z.object({
tailoredSummary: z.string().optional(),
selectedProjectIds: z.string().optional(),
pdfPath: z.string().optional(),
sponsorMatchScore: z.number().min(0).max(100).optional(),
sponsorMatchNames: z.string().optional(),
});
/**
@ -136,6 +139,62 @@ jobsRouter.post('/:id/summarize', async (req: Request, res: Response) => {
}
});
/**
* POST /api/jobs/:id/check-sponsor - Check if employer is a visa sponsor
*/
jobsRouter.post('/:id/check-sponsor', async (req: Request, res: Response) => {
try {
const job = await jobsRepo.getJobById(req.params.id);
if (!job) {
return res.status(404).json({ success: false, error: 'Job not found' });
}
if (!job.employer) {
return res.status(400).json({ success: false, error: 'Job has no employer name' });
}
// Search for sponsor matches
const sponsorResults = visaSponsors.searchSponsors(job.employer, {
limit: 10,
minScore: 50,
});
let sponsorMatchScore: number | null = null;
let sponsorMatchNames: string | null = null;
if (sponsorResults.length > 0) {
const topScore = sponsorResults[0].score;
// Get all 100% matches, or just the top match
const perfectMatches = sponsorResults.filter(r => r.score === 100);
const matchesToReport = perfectMatches.length >= 2
? perfectMatches.slice(0, 2)
: [sponsorResults[0]];
sponsorMatchScore = topScore;
sponsorMatchNames = JSON.stringify(matchesToReport.map(r => r.sponsor.organisationName));
}
// Update job with sponsor match info
const updatedJob = await jobsRepo.updateJob(job.id, {
sponsorMatchScore: sponsorMatchScore ?? undefined,
sponsorMatchNames: sponsorMatchNames ?? undefined,
});
res.json({
success: true,
data: updatedJob,
matchResults: sponsorResults.slice(0, 5).map(r => ({
name: r.sponsor.organisationName,
score: r.score,
})),
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/jobs/:id/generate-pdf - Generate PDF using current manual overrides
*/

View File

@ -21,13 +21,13 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
// Specific AI models
const overrideModelScorer = await settingsRepo.getSetting('modelScorer');
const modelScorer = overrideModelScorer || model;
const modelScorer = overrideModelScorer || model;
const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring');
const modelTailoring = overrideModelTailoring || model;
const modelTailoring = overrideModelTailoring || model;
const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection');
const modelProjectSelection = overrideModelProjectSelection || model;
const modelProjectSelection = overrideModelProjectSelection || model;
const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl');
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
@ -89,6 +89,14 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
: null;
const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription;
// Show Sponsor Info setting (on by default)
const overrideShowSponsorInfoRaw = await settingsRepo.getSetting('showSponsorInfo');
const defaultShowSponsorInfo = true;
const overrideShowSponsorInfo = overrideShowSponsorInfoRaw
? overrideShowSponsorInfoRaw === 'true' || overrideShowSponsorInfoRaw === '1'
: null;
const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo;
res.json({
success: true,
data: {
@ -135,6 +143,9 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
jobspyLinkedinFetchDescription,
defaultJobspyLinkedinFetchDescription,
overrideJobspyLinkedinFetchDescription,
showSponsorInfo,
defaultShowSponsorInfo,
overrideShowSponsorInfo,
},
});
} catch (error) {
@ -164,6 +175,7 @@ const updateSettingsSchema = z.object({
jobspyCountryIndeed: z.string().trim().min(1).max(100).nullable().optional(),
jobspySites: z.array(z.string().trim().min(1).max(50)).max(10).nullable().optional(),
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
showSponsorInfo: z.boolean().nullable().optional(),
});
/**
@ -263,15 +275,20 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
await settingsRepo.setSetting('jobspyLinkedinFetchDescription', value !== null ? (value ? '1' : '0') : null);
}
if ('showSponsorInfo' in input) {
const value = input.showSponsorInfo ?? null;
await settingsRepo.setSetting('showSponsorInfo', value !== null ? (value ? '1' : '0') : null);
}
const overrideModel = await settingsRepo.getSetting('model');
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
const model = overrideModel || defaultModel;
const overrideModelScorer = await settingsRepo.getSetting('modelScorer');
const modelScorer = overrideModelScorer || model;
const modelScorer = overrideModelScorer || model;
const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring');
const modelTailoring = overrideModelTailoring || model;
const modelTailoring = overrideModelTailoring || model;
const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection');
const modelProjectSelection = overrideModelProjectSelection || model;
@ -337,6 +354,14 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
: null;
const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription;
// Show Sponsor Info setting
const overrideShowSponsorInfoRaw = await settingsRepo.getSetting('showSponsorInfo');
const defaultShowSponsorInfo = true;
const overrideShowSponsorInfo = overrideShowSponsorInfoRaw
? overrideShowSponsorInfoRaw === 'true' || overrideShowSponsorInfoRaw === '1'
: null;
const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo;
res.json({
success: true,
data: {
@ -383,6 +408,9 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
jobspyLinkedinFetchDescription,
defaultJobspyLinkedinFetchDescription,
overrideJobspyLinkedinFetchDescription,
showSponsorInfo,
defaultShowSponsorInfo,
overrideShowSponsorInfo,
},
});
} catch (error) {

View File

@ -132,6 +132,10 @@ const migrations = [
`ALTER TABLE jobs ADD COLUMN tailored_headline TEXT`,
`ALTER TABLE jobs ADD COLUMN tailored_skills TEXT`,
// Add sponsor match columns for visa sponsor matching feature
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
`ALTER TABLE jobs ADD COLUMN sponsor_match_names TEXT`,
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
`CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started_at ON pipeline_runs(started_at)`,

View File

@ -64,6 +64,8 @@ export const jobs = sqliteTable('jobs', {
selectedProjectIds: text('selected_project_ids'),
pdfPath: text('pdf_path'),
notionPageId: text('notion_page_id'),
sponsorMatchScore: real('sponsor_match_score'),
sponsorMatchNames: text('sponsor_match_names'),
// Timestamps
discoveredAt: text('discovered_at').notNull().default(sql`(datetime('now'))`),

View File

@ -22,6 +22,7 @@ import { extractProjectsFromProfile, resolveResumeProjectsSettings } from '../se
import * as jobsRepo from '../repositories/jobs.js';
import * as pipelineRepo from '../repositories/pipeline.js';
import * as settingsRepo from '../repositories/settings.js';
import * as visaSponsors from '../services/visa-sponsors/index.js';
import { progressHelpers, resetProgress, updateProgress } from './progress.js';
import type { CreateJobInput, Job, JobSource, PipelineConfig } from '../../shared/types.js';
import { getDataDir } from '../config/dataDir.js';
@ -293,10 +294,35 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
suitabilityReason: reason,
});
// Update score in database
// Calculate sponsor match score using fuzzy search
let sponsorMatchScore: number | undefined;
let sponsorMatchNames: string | undefined;
if (job.employer) {
const sponsorResults = visaSponsors.searchSponsors(job.employer, {
limit: 10,
minScore: 50,
});
if (sponsorResults.length > 0) {
const topScore = sponsorResults[0].score;
// Get all 100% matches, or just the top match
const perfectMatches = sponsorResults.filter(r => r.score === 100);
const matchesToReport = perfectMatches.length >= 2
? perfectMatches.slice(0, 2)
: [sponsorResults[0]];
sponsorMatchScore = topScore;
sponsorMatchNames = JSON.stringify(matchesToReport.map(r => r.sponsor.organisationName));
}
}
// Update score and sponsor match in database
await jobsRepo.updateJob(job.id, {
suitabilityScore: score,
suitabilityReason: reason,
...(sponsorMatchScore !== undefined && { sponsorMatchScore }),
...(sponsorMatchNames !== undefined && { sponsorMatchNames }),
});
}

View File

@ -16,7 +16,7 @@ export async function getAllJobs(statuses?: JobStatus[]): Promise<Job[]> {
const query = statuses && statuses.length > 0
? db.select().from(jobs).where(inArray(jobs.status, statuses)).orderBy(desc(jobs.discoveredAt))
: db.select().from(jobs).orderBy(desc(jobs.discoveredAt));
const rows = await query;
return rows.map(mapRowToJob);
}
@ -54,10 +54,10 @@ export async function createJob(input: CreateJobInput): Promise<Job> {
if (existing) {
return existing;
}
const id = randomUUID();
const now = new Date().toISOString();
await db.insert(jobs).values({
id,
source: input.source,
@ -105,7 +105,7 @@ export async function createJob(input: CreateJobInput): Promise<Job> {
createdAt: now,
updatedAt: now,
});
return (await getJobById(id))!;
}
@ -114,7 +114,7 @@ export async function createJob(input: CreateJobInput): Promise<Job> {
*/
export async function updateJob(id: string, input: UpdateJobInput): Promise<Job | null> {
const now = new Date().toISOString();
await db.update(jobs)
.set({
...input,
@ -123,7 +123,7 @@ export async function updateJob(id: string, input: UpdateJobInput): Promise<Job
...(input.status === 'applied' && !input.appliedAt ? { appliedAt: now } : {}),
})
.where(eq(jobs.id, id));
return getJobById(id);
}
@ -133,18 +133,18 @@ export async function updateJob(id: string, input: UpdateJobInput): Promise<Job
export async function bulkCreateJobs(inputs: CreateJobInput[]): Promise<{ created: number; skipped: number }> {
let created = 0;
let skipped = 0;
for (const input of inputs) {
const existing = await getJobByUrl(input.jobUrl);
if (existing) {
skipped++;
continue;
}
await createJob(input);
created++;
}
return { created, skipped };
}
@ -159,7 +159,7 @@ export async function getJobStats(): Promise<Record<JobStatus, number>> {
})
.from(jobs)
.groupBy(jobs.status);
const stats: Record<JobStatus, number> = {
discovered: 0,
processing: 0,
@ -168,11 +168,11 @@ export async function getJobStats(): Promise<Record<JobStatus, number>> {
skipped: 0,
expired: 0,
};
for (const row of result) {
stats[row.status as JobStatus] = row.count;
}
return stats;
}
@ -191,7 +191,7 @@ export async function getJobsForProcessing(limit: number = 10): Promise<Job[]> {
)
.orderBy(desc(jobs.discoveredAt))
.limit(limit);
return rows.map(mapRowToJob);
}
@ -246,6 +246,8 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
selectedProjectIds: row.selectedProjectIds ?? null,
pdfPath: row.pdfPath,
notionPageId: row.notionPageId,
sponsorMatchScore: row.sponsorMatchScore ?? null,
sponsorMatchNames: row.sponsorMatchNames ?? null,
jobType: row.jobType ?? null,
salarySource: row.salarySource ?? null,
salaryInterval: row.salaryInterval ?? null,

View File

@ -23,6 +23,7 @@ export type SettingKey = 'model'
| 'jobspyCountryIndeed'
| 'jobspySites'
| 'jobspyLinkedinFetchDescription'
| 'showSponsorInfo'
export async function getSetting(key: SettingKey): Promise<string | null> {
const [row] = await db.select().from(settings).where(eq(settings.key, key))

View File

@ -38,6 +38,8 @@ const mockJob: Job = {
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
@ -103,15 +105,15 @@ describe('AI Service Resilience', () => {
it('should fallback to mock scoring if API Key is missing', async () => {
delete process.env.OPENROUTER_API_KEY;
// Should NOT call fetch
const result = await scoreJobSuitability(mockJob, mockProfile);
expect(global.fetch).not.toHaveBeenCalled();
// Mock score logic gives 50 + points for keywords.
// 'TypeScript' and 'React' are in JD (5+5) -> 60?
// "Senior" is bad keyword (-10)? -> 50?
// Let's just check it didn't crash and returned a number
// Let's just check it didn't crash and returned a number
expect(typeof result.score).toBe('number');
expect(result.reason).toContain('keyword matching');
});
@ -124,7 +126,7 @@ describe('AI Service Resilience', () => {
} as any);
// Spy on console.error to keep test output clean
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
const result = await scoreJobSuitability(mockJob, mockProfile);
@ -134,22 +136,22 @@ describe('AI Service Resilience', () => {
});
it('should handle Malformed/Invalid JSON in API response', async () => {
const mockResponse = {
const mockResponse = {
ok: true,
json: async () => ({
choices: [{ message: { content: 'This is not JSON at all, just text.' } }]
})
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
const result = await scoreJobSuitability(mockJob, mockProfile);
expect(result.reason).toContain('keyword matching'); // Fell back
});
it('should extract JSON from markdown code blocks', async () => {
const mockResponse = {
const mockResponse = {
ok: true,
json: async () => ({
choices: [{ message: { content: 'Here is the score: ```json\n{ "score": 90, "reason": "Good" }\n```' } }]
@ -169,7 +171,7 @@ describe('AI Service Resilience', () => {
];
it('should return projects selected by AI', async () => {
const mockResponse = {
const mockResponse = {
ok: true,
json: async () => ({
choices: [{ message: { content: JSON.stringify({ selectedProjectIds: ['p1'] }) } }]
@ -187,9 +189,9 @@ describe('AI Service Resilience', () => {
});
it('should fallback if API fails', async () => {
vi.mocked(global.fetch).mockRejectedValue(new Error('Network error'));
vi.mocked(global.fetch).mockRejectedValue(new Error('Network error'));
const result = await pickProjectIdsForJob({
const result = await pickProjectIdsForJob({
jobDescription: 'React dev', // Should match p1 due to keyword 'React'
eligibleProjects: mockProjects,
desiredCount: 1
@ -218,18 +220,18 @@ describe('AI Service Resilience', () => {
expect(result).toEqual(['p2']);
});
it('should validate returned IDs exist in eligible list', async () => {
it('should validate returned IDs exist in eligible list', async () => {
// AI returns an ID that doesn't exist ('p999')
const mockResponse = {
ok: true,
json: async () => ({
choices: [{ message: { content: JSON.stringify({ selectedProjectIds: ['p999', 'p1'] }) } }]
choices: [{ message: { content: JSON.stringify({ selectedProjectIds: ['p999', 'p1'] }) } }]
})
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
const result = await pickProjectIdsForJob({
jobDescription: 'stuff',
jobDescription: 'stuff',
eligibleProjects: mockProjects,
desiredCount: 2
});

View File

@ -50,6 +50,8 @@ export interface Job {
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
pdfPath: string | null; // Path to generated PDF
notionPageId: string | null; // Notion page ID if synced
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
// JobSpy fields (nullable for non-JobSpy sources)
jobType: string | null;
@ -164,6 +166,8 @@ export interface UpdateJobInput {
pdfPath?: string;
notionPageId?: string;
appliedAt?: string;
sponsorMatchScore?: number;
sponsorMatchNames?: string;
}
export interface PipelineConfig {
@ -275,7 +279,7 @@ export interface AppSettings {
overrideModelTailoring: string | null;
modelProjectSelection: string; // resolved
overrideModelProjectSelection: string | null;
pipelineWebhookUrl: string;
defaultPipelineWebhookUrl: string;
overridePipelineWebhookUrl: string | null;
@ -313,4 +317,7 @@ export interface AppSettings {
jobspyLinkedinFetchDescription: boolean;
defaultJobspyLinkedinFetchDescription: boolean;
overrideJobspyLinkedinFetchDescription: boolean | null;
showSponsorInfo: boolean;
defaultShowSponsorInfo: boolean;
overrideShowSponsorInfo: boolean | null;
}