Small wins style tickets (#88)
* wrap text * version check! * initial commit for "remove below score" in pipeline, or manually * comments
This commit is contained in:
@@ -525,6 +525,20 @@ export async function deleteJobsByStatus(status: string): Promise<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteJobsBelowScore(threshold: number): Promise<{
|
||||||
|
message: string;
|
||||||
|
count: number;
|
||||||
|
threshold: number;
|
||||||
|
}> {
|
||||||
|
return fetchApi<{
|
||||||
|
message: string;
|
||||||
|
count: number;
|
||||||
|
threshold: number;
|
||||||
|
}>(`/jobs/score/${threshold}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Visa Sponsors API
|
// Visa Sponsors API
|
||||||
export async function getVisaSponsorStatus(): Promise<VisaSponsorStatusResponse> {
|
export async function getVisaSponsorStatus(): Promise<VisaSponsorStatusResponse> {
|
||||||
return fetchApi<VisaSponsorStatusResponse>("/visa-sponsors/status");
|
return fetchApi<VisaSponsorStatusResponse>("/visa-sponsors/status");
|
||||||
|
|||||||
@@ -187,11 +187,11 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className={cn("space-y-3", className)}>
|
<div className={cn("space-y-3", className)}>
|
||||||
{/* Detail header: lighter weight than list items */}
|
{/* Detail header: lighter weight than list items */}
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
<div className="flex flex-col items-start gap-2 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 w-full sm:w-auto sm:flex-1">
|
||||||
<Link
|
<Link
|
||||||
to={`/job/${job.id}`}
|
to={`/job/${job.id}`}
|
||||||
className="flex items-center gap-2 text-base font-semibold underline-offset-2 text-foreground/90 hover:underline"
|
className="block text-base font-semibold leading-snug text-foreground/90 underline-offset-2 break-words hover:underline"
|
||||||
>
|
>
|
||||||
{job.title}
|
{job.title}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -199,7 +199,7 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
|
|||||||
<span>{job.employer}</span>
|
<span>{job.employer}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
|
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useVersionCheck } from "../hooks/useVersionCheck";
|
||||||
import { isNavActive, NAV_LINKS } from "./navigation";
|
import { isNavActive, NAV_LINKS } from "./navigation";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -43,6 +50,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [navOpen, setNavOpen] = useState(false);
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
|
const { version, updateAvailable } = useVersionCheck();
|
||||||
|
|
||||||
const handleNavClick = (to: string, activePaths?: string[]) => {
|
const handleNavClick = (to: string, activePaths?: string[]) => {
|
||||||
if (isNavActive(location.pathname, to, activePaths)) {
|
if (isNavActive(location.pathname, to, activePaths)) {
|
||||||
@@ -64,7 +72,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
|||||||
<span className="sr-only">Open navigation menu</span>
|
<span className="sr-only">Open navigation menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" className="w-64">
|
<SheetContent side="left" className="w-64 flex flex-col">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>JobOps</SheetTitle>
|
<SheetTitle>JobOps</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
@@ -86,6 +94,28 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
<div className="mt-auto pt-6 pb-2">
|
||||||
|
<TooltipProvider>
|
||||||
|
<a
|
||||||
|
href="https://github.com/DaKheera47/job-ops/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span>Version {version}</span>
|
||||||
|
{updateAvailable && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-500 cursor-pointer" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Update available</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { checkForUpdate, parseVersion } from "../lib/version";
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
|
||||||
|
interface VersionState {
|
||||||
|
version: string;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
latestVersion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVersionCheck(): VersionState {
|
||||||
|
const [state, setState] = useState<VersionState>(() => ({
|
||||||
|
version:
|
||||||
|
typeof __APP_VERSION__ !== "undefined"
|
||||||
|
? parseVersion(__APP_VERSION__ as string)
|
||||||
|
: "unknown",
|
||||||
|
updateAvailable: false,
|
||||||
|
latestVersion: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
checkForUpdate().then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setState({
|
||||||
|
version: result.currentVersion,
|
||||||
|
updateAvailable: result.updateAvailable,
|
||||||
|
latestVersion: result.latestVersion,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
|
||||||
|
const GITHUB_REPO = "DaKheera47/job-ops";
|
||||||
|
const STORAGE_KEY = "jobops_version_check";
|
||||||
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
export interface VersionCheckResult {
|
||||||
|
currentVersion: string;
|
||||||
|
latestVersion: string | null;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
lastChecked: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse git version string into display format.
|
||||||
|
* - Clean semver tags (v0.1.12) → v0.1.12
|
||||||
|
* - Dev builds (v0.1.12-8-gabc123) → abc123-dev
|
||||||
|
*/
|
||||||
|
export function parseVersion(rawVersion: string): string {
|
||||||
|
// If it's a clean semver tag (v0.1.12), return as-is
|
||||||
|
if (/^v\d+\.\d+\.\d+$/.test(rawVersion)) {
|
||||||
|
return rawVersion;
|
||||||
|
}
|
||||||
|
// If it's a dev build (v0.1.12-8-gabc123), extract commit hash and add -dev
|
||||||
|
const match = rawVersion.match(/-g([a-f0-9]+)$/);
|
||||||
|
if (match) {
|
||||||
|
return `${match[1].slice(0, 7)}-dev`;
|
||||||
|
}
|
||||||
|
// Fallback: return shortened hash
|
||||||
|
return rawVersion.length > 7
|
||||||
|
? `${rawVersion.slice(0, 7)}-dev`
|
||||||
|
: `${rawVersion}-dev`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for updates against GitHub releases API.
|
||||||
|
* Results are cached for 24 hours to avoid rate limits.
|
||||||
|
*/
|
||||||
|
export async function checkForUpdate(): Promise<VersionCheckResult> {
|
||||||
|
const currentRaw =
|
||||||
|
typeof __APP_VERSION__ !== "undefined"
|
||||||
|
? (__APP_VERSION__ as string)
|
||||||
|
: "unknown";
|
||||||
|
const currentVersion = parseVersion(currentRaw);
|
||||||
|
|
||||||
|
// Check cached result
|
||||||
|
const cached = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const parsed: VersionCheckResult = JSON.parse(cached);
|
||||||
|
const timeSinceCheck = Date.now() - parsed.lastChecked;
|
||||||
|
if (timeSinceCheck < CHECK_INTERVAL_MS) {
|
||||||
|
return { ...parsed, currentVersion };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid cache, continue to fetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`,
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch");
|
||||||
|
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
typeof data !== "object" ||
|
||||||
|
typeof (data as { tag_name?: unknown }).tag_name !== "string" ||
|
||||||
|
!(data as { tag_name: string }).tag_name.trim()
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid response format");
|
||||||
|
}
|
||||||
|
const latestVersion = (data as { tag_name: string }).tag_name;
|
||||||
|
|
||||||
|
// Update available if current is a clean tag and differs from latest
|
||||||
|
const updateAvailable =
|
||||||
|
/^v\d+\.\d+\.\d+$/.test(currentRaw) && latestVersion !== currentRaw;
|
||||||
|
|
||||||
|
const result: VersionCheckResult = {
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
updateAvailable,
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(result));
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
// On error, return current version with no update info
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
latestVersion: null,
|
||||||
|
updateAvailable: false,
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,6 +131,9 @@ const baseSettings: AppSettings = {
|
|||||||
missingSalaryPenalty: 10,
|
missingSalaryPenalty: 10,
|
||||||
defaultMissingSalaryPenalty: 10,
|
defaultMissingSalaryPenalty: 10,
|
||||||
overrideMissingSalaryPenalty: null,
|
overrideMissingSalaryPenalty: null,
|
||||||
|
autoSkipScoreThreshold: null,
|
||||||
|
defaultAutoSkipScoreThreshold: null,
|
||||||
|
overrideAutoSkipScoreThreshold: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
|||||||
backupMaxCount: null,
|
backupMaxCount: null,
|
||||||
penalizeMissingSalary: null,
|
penalizeMissingSalary: null,
|
||||||
missingSalaryPenalty: null,
|
missingSalaryPenalty: null,
|
||||||
|
autoSkipScoreThreshold: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
type LlmProviderValue = LlmProviderId | null;
|
type LlmProviderValue = LlmProviderId | null;
|
||||||
@@ -120,6 +121,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
|||||||
backupMaxCount: null,
|
backupMaxCount: null,
|
||||||
penalizeMissingSalary: null,
|
penalizeMissingSalary: null,
|
||||||
missingSalaryPenalty: null,
|
missingSalaryPenalty: null,
|
||||||
|
autoSkipScoreThreshold: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||||
@@ -159,6 +161,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
|||||||
backupMaxCount: data.overrideBackupMaxCount,
|
backupMaxCount: data.overrideBackupMaxCount,
|
||||||
penalizeMissingSalary: data.overridePenalizeMissingSalary,
|
penalizeMissingSalary: data.overridePenalizeMissingSalary,
|
||||||
missingSalaryPenalty: data.overrideMissingSalaryPenalty,
|
missingSalaryPenalty: data.overrideMissingSalaryPenalty,
|
||||||
|
autoSkipScoreThreshold: data.overrideAutoSkipScoreThreshold,
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizeString = (value: string | null | undefined) => {
|
const normalizeString = (value: string | null | undefined) => {
|
||||||
@@ -349,6 +352,10 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
|||||||
effective: settings?.missingSalaryPenalty ?? 10,
|
effective: settings?.missingSalaryPenalty ?? 10,
|
||||||
default: settings?.defaultMissingSalaryPenalty ?? 10,
|
default: settings?.defaultMissingSalaryPenalty ?? 10,
|
||||||
},
|
},
|
||||||
|
autoSkipScoreThreshold: {
|
||||||
|
effective: settings?.autoSkipScoreThreshold ?? null,
|
||||||
|
default: settings?.defaultAutoSkipScoreThreshold ?? null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -790,6 +797,31 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClearByScore = async (threshold: number) => {
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
const result = await api.deleteJobsBelowScore(threshold);
|
||||||
|
|
||||||
|
if (result.count > 0) {
|
||||||
|
toast.success("Jobs cleared", {
|
||||||
|
description: `Deleted ${result.count} jobs with score below ${threshold}. Applied jobs were preserved.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.info("No jobs found", {
|
||||||
|
description: `No jobs with score below ${threshold} found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to clear jobs by score";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleStatusToClear = (status: JobStatus) => {
|
const toggleStatusToClear = (status: JobStatus) => {
|
||||||
setStatusesToClear((prev) =>
|
setStatusesToClear((prev) =>
|
||||||
prev.includes(status)
|
prev.includes(status)
|
||||||
@@ -900,6 +932,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
toggleStatusToClear={toggleStatusToClear}
|
toggleStatusToClear={toggleStatusToClear}
|
||||||
handleClearByStatuses={handleClearByStatuses}
|
handleClearByStatuses={handleClearByStatuses}
|
||||||
handleClearDatabase={handleClearDatabase}
|
handleClearDatabase={handleClearDatabase}
|
||||||
|
handleClearByScore={handleClearByScore}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import {
|
|||||||
ALL_JOB_STATUSES,
|
ALL_JOB_STATUSES,
|
||||||
STATUS_DESCRIPTIONS,
|
STATUS_DESCRIPTIONS,
|
||||||
} from "@client/pages/settings/constants";
|
} from "@client/pages/settings/constants";
|
||||||
import type { JobStatus } from "@shared/types.js";
|
import type { JobStatus } from "@shared/types";
|
||||||
import { AlertTriangle, Trash2 } from "lucide-react";
|
import { AlertTriangle, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
@@ -22,6 +25,7 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
type DangerZoneSectionProps = {
|
type DangerZoneSectionProps = {
|
||||||
@@ -29,6 +33,7 @@ type DangerZoneSectionProps = {
|
|||||||
toggleStatusToClear: (status: JobStatus) => void;
|
toggleStatusToClear: (status: JobStatus) => void;
|
||||||
handleClearByStatuses: () => void;
|
handleClearByStatuses: () => void;
|
||||||
handleClearDatabase: () => void;
|
handleClearDatabase: () => void;
|
||||||
|
handleClearByScore?: (threshold: number) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
};
|
};
|
||||||
@@ -38,9 +43,16 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
|||||||
toggleStatusToClear,
|
toggleStatusToClear,
|
||||||
handleClearByStatuses,
|
handleClearByStatuses,
|
||||||
handleClearDatabase,
|
handleClearDatabase,
|
||||||
|
handleClearByScore,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [scoreThreshold, setScoreThreshold] = useState<string>("");
|
||||||
|
const parsedThreshold = parseInt(scoreThreshold, 10);
|
||||||
|
const isValidThreshold =
|
||||||
|
!Number.isNaN(parsedThreshold) &&
|
||||||
|
parsedThreshold >= 0 &&
|
||||||
|
parsedThreshold <= 100;
|
||||||
return (
|
return (
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
value="danger-zone"
|
value="danger-zone"
|
||||||
@@ -142,6 +154,85 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* Clear Jobs Below Score */}
|
||||||
|
{handleClearByScore && (
|
||||||
|
<div className="p-3 rounded-md space-y-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-sm font-semibold text-destructive">
|
||||||
|
Clear Jobs Below Score
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Remove all jobs with a suitability score below the specified
|
||||||
|
threshold. Applied jobs will not be deleted.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label
|
||||||
|
htmlFor="score-threshold"
|
||||||
|
className="text-sm font-medium mb-1.5 block"
|
||||||
|
>
|
||||||
|
Score Threshold (0-100)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="score-threshold"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
placeholder="Enter score threshold"
|
||||||
|
value={scoreThreshold}
|
||||||
|
onChange={(e) => setScoreThreshold(e.target.value)}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="default"
|
||||||
|
disabled={isLoading || isSaving || !isValidThreshold}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Clear Below {isValidThreshold ? parsedThreshold : "..."}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Clear jobs below score {parsedThreshold}?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete all jobs with a suitability
|
||||||
|
score below {parsedThreshold}. Applied jobs will be
|
||||||
|
preserved. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
if (isValidThreshold) {
|
||||||
|
handleClearByScore(parsedThreshold);
|
||||||
|
setScoreThreshold("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Clear jobs
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="text-sm font-semibold text-destructive">
|
<div className="text-sm font-semibold text-destructive">
|
||||||
|
|||||||
@@ -22,13 +22,20 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
const { penalizeMissingSalary, missingSalaryPenalty } = values;
|
const {
|
||||||
|
penalizeMissingSalary,
|
||||||
|
missingSalaryPenalty,
|
||||||
|
autoSkipScoreThreshold,
|
||||||
|
} = values;
|
||||||
const { control, watch } = useFormContext<UpdateSettingsInput>();
|
const { control, watch } = useFormContext<UpdateSettingsInput>();
|
||||||
|
|
||||||
// Watch the current form value to conditionally show/hide penalty input
|
// Watch the current form value to conditionally show/hide penalty input
|
||||||
const currentPenalizeEnabled =
|
const currentPenalizeEnabled =
|
||||||
watch("penalizeMissingSalary") ?? penalizeMissingSalary.default;
|
watch("penalizeMissingSalary") ?? penalizeMissingSalary.default;
|
||||||
|
|
||||||
|
// Watch auto-skip threshold to show current value
|
||||||
|
const currentAutoSkipThreshold = watch("autoSkipScoreThreshold");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="scoring" className="border rounded-lg px-4">
|
<AccordionItem value="scoring" className="border rounded-lg px-4">
|
||||||
<AccordionTrigger className="hover:no-underline py-4">
|
<AccordionTrigger className="hover:no-underline py-4">
|
||||||
@@ -106,6 +113,47 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* Auto-skip threshold input */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Controller
|
||||||
|
name="autoSkipScoreThreshold"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<SettingsInput
|
||||||
|
label="Auto-skip Score Threshold"
|
||||||
|
type="number"
|
||||||
|
inputProps={{
|
||||||
|
...field,
|
||||||
|
inputMode: "numeric",
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
value: field.value ?? "",
|
||||||
|
onChange: (event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (value === "" || value === null) {
|
||||||
|
field.onChange(null);
|
||||||
|
} else {
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
field.onChange(null);
|
||||||
|
} else {
|
||||||
|
field.onChange(Math.min(100, Math.max(0, parsed)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder: "Disabled",
|
||||||
|
}}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
helper="Jobs scoring below this threshold will be automatically skipped during scoring. Leave empty to disable auto-skip. (0-100)"
|
||||||
|
current={`Effective: ${currentAutoSkipThreshold === null || currentAutoSkipThreshold === undefined ? "Disabled" : currentAutoSkipThreshold} | Default: ${autoSkipScoreThreshold.default ?? "Disabled"}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* Effective/Default values display */}
|
{/* Effective/Default values display */}
|
||||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -126,6 +174,15 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
|
|||||||
{missingSalaryPenalty.default}
|
{missingSalaryPenalty.default}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Auto-skip Threshold
|
||||||
|
</div>
|
||||||
|
<div className="break-words font-mono text-xs">
|
||||||
|
Effective: {autoSkipScoreThreshold.effective ?? "Disabled"} |
|
||||||
|
Default: {autoSkipScoreThreshold.default ?? "Disabled"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
|
|||||||
@@ -52,4 +52,5 @@ export type BackupValues = {
|
|||||||
export type ScoringValues = {
|
export type ScoringValues = {
|
||||||
penalizeMissingSalary: EffectiveDefault<boolean>;
|
penalizeMissingSalary: EffectiveDefault<boolean>;
|
||||||
missingSalaryPenalty: EffectiveDefault<number>;
|
missingSalaryPenalty: EffectiveDefault<number>;
|
||||||
|
autoSkipScoreThreshold: EffectiveDefault<number | null>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -140,6 +140,103 @@ describe.sequential("Jobs API routes", () => {
|
|||||||
expect(body.data.suitabilityReason).toBe("Updated fit");
|
expect(body.data.suitabilityReason).toBe("Updated fit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("deletes jobs below a score threshold (excluding applied)", async () => {
|
||||||
|
const { createJob, updateJob } = await import("../../repositories/jobs");
|
||||||
|
|
||||||
|
// Create jobs with different scores and statuses
|
||||||
|
const lowScoreJob = await createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Low Score Job",
|
||||||
|
employer: "Company A",
|
||||||
|
jobUrl: "https://example.com/job/low",
|
||||||
|
jobDescription: "Test description",
|
||||||
|
});
|
||||||
|
await updateJob(lowScoreJob.id, { suitabilityScore: 30 });
|
||||||
|
|
||||||
|
const mediumScoreJob = await createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Medium Score Job",
|
||||||
|
employer: "Company B",
|
||||||
|
jobUrl: "https://example.com/job/medium",
|
||||||
|
jobDescription: "Test description",
|
||||||
|
});
|
||||||
|
await updateJob(mediumScoreJob.id, { suitabilityScore: 60 });
|
||||||
|
|
||||||
|
const boundaryScoreJob = await createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Boundary Score Job",
|
||||||
|
employer: "Company Boundary",
|
||||||
|
jobUrl: "https://example.com/job/boundary",
|
||||||
|
jobDescription: "Test description",
|
||||||
|
});
|
||||||
|
await updateJob(boundaryScoreJob.id, { suitabilityScore: 50 });
|
||||||
|
|
||||||
|
const highScoreJob = await createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "High Score Job",
|
||||||
|
employer: "Company C",
|
||||||
|
jobUrl: "https://example.com/job/high",
|
||||||
|
jobDescription: "Test description",
|
||||||
|
});
|
||||||
|
await updateJob(highScoreJob.id, { suitabilityScore: 90 });
|
||||||
|
|
||||||
|
const appliedLowScoreJob = await createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Applied Low Score Job",
|
||||||
|
employer: "Company D",
|
||||||
|
jobUrl: "https://example.com/job/applied-low",
|
||||||
|
jobDescription: "Test description",
|
||||||
|
});
|
||||||
|
await updateJob(appliedLowScoreJob.id, {
|
||||||
|
suitabilityScore: 30,
|
||||||
|
status: "applied",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete jobs below score 50
|
||||||
|
const deleteRes = await fetch(`${baseUrl}/api/jobs/score/50`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
const deleteBody = await deleteRes.json();
|
||||||
|
|
||||||
|
expect(deleteBody.ok).toBe(true);
|
||||||
|
expect(deleteBody.data.count).toBe(1);
|
||||||
|
expect(deleteBody.data.threshold).toBe(50);
|
||||||
|
|
||||||
|
// Verify only the low score non-applied job was deleted
|
||||||
|
const listRes = await fetch(`${baseUrl}/api/jobs`);
|
||||||
|
const listBody = await listRes.json();
|
||||||
|
|
||||||
|
const remainingJobIds = listBody.data.jobs.map((j: any) => j.id);
|
||||||
|
expect(remainingJobIds).not.toContain(lowScoreJob.id);
|
||||||
|
expect(remainingJobIds).toContain(boundaryScoreJob.id);
|
||||||
|
expect(remainingJobIds).toContain(mediumScoreJob.id);
|
||||||
|
expect(remainingJobIds).toContain(highScoreJob.id);
|
||||||
|
expect(remainingJobIds).toContain(appliedLowScoreJob.id); // Applied job preserved
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid score thresholds", async () => {
|
||||||
|
// Test invalid threshold (above 100)
|
||||||
|
const invalidRes = await fetch(`${baseUrl}/api/jobs/score/150`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
expect(invalidRes.status).toBe(400);
|
||||||
|
const invalidBody = await invalidRes.json();
|
||||||
|
expect(invalidBody.ok).toBe(false);
|
||||||
|
expect(invalidBody.error.code).toBe("INVALID_REQUEST");
|
||||||
|
|
||||||
|
// Test invalid threshold (below 0)
|
||||||
|
const negativeRes = await fetch(`${baseUrl}/api/jobs/score/-10`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
expect(negativeRes.status).toBe(400);
|
||||||
|
|
||||||
|
// Test non-numeric threshold
|
||||||
|
const nanRes = await fetch(`${baseUrl}/api/jobs/score/abc`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
expect(nanRes.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it("checks visa sponsor status for a job", async () => {
|
it("checks visa sponsor status for a job", async () => {
|
||||||
const { searchSponsors } = await import(
|
const { searchSponsors } = await import(
|
||||||
"../../services/visa-sponsors/index"
|
"../../services/visa-sponsors/index"
|
||||||
|
|||||||
@@ -623,3 +623,57 @@ jobsRouter.delete("/status/:status", async (req: Request, res: Response) => {
|
|||||||
res.status(500).json({ success: false, error: message });
|
res.status(500).json({ success: false, error: message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/jobs/score/:threshold - Clear jobs with score below threshold (excluding applied)
|
||||||
|
*/
|
||||||
|
jobsRouter.delete("/score/:threshold", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
return sendDemoBlocked(
|
||||||
|
res,
|
||||||
|
"Clearing jobs by score is disabled to keep the demo stable.",
|
||||||
|
{
|
||||||
|
route: "DELETE /api/jobs/score/:threshold",
|
||||||
|
threshold: req.params.threshold,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = parseInt(req.params.threshold, 10);
|
||||||
|
if (Number.isNaN(threshold) || threshold < 0 || threshold > 100) {
|
||||||
|
return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "INVALID_REQUEST",
|
||||||
|
message: "Threshold must be a number between 0 and 100",
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
requestId: (req.headers["x-request-id"] as string) || "unknown",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await jobsRepo.deleteJobsBelowScore(threshold);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
message: `Cleared ${count} jobs with score below ${threshold}`,
|
||||||
|
count,
|
||||||
|
threshold,
|
||||||
|
},
|
||||||
|
meta: { requestId: (req.headers["x-request-id"] as string) || "unknown" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "INTERNAL_ERROR",
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
meta: { requestId: (req.headers["x-request-id"] as string) || "unknown" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import type { Job } from "@shared/types";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { scoreJobsStep } from "./score-jobs";
|
||||||
|
|
||||||
|
vi.mock("@infra/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../repositories/jobs", () => ({
|
||||||
|
getUnscoredDiscoveredJobs: vi.fn(),
|
||||||
|
updateJob: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../repositories/settings", () => ({
|
||||||
|
getSetting: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../services/scorer", () => ({
|
||||||
|
scoreJobSuitability: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../services/visa-sponsors/index", () => ({
|
||||||
|
searchSponsors: vi.fn(),
|
||||||
|
calculateSponsorMatchSummary: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../progress", () => ({
|
||||||
|
updateProgress: vi.fn(),
|
||||||
|
progressHelpers: {
|
||||||
|
scoringJob: vi.fn(),
|
||||||
|
scoringComplete: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createMockJob(overrides: Partial<Job> = {}): Job {
|
||||||
|
return {
|
||||||
|
id: "job-1",
|
||||||
|
title: "Software Engineer",
|
||||||
|
employer: "Acme Corp",
|
||||||
|
status: "discovered",
|
||||||
|
suitabilityScore: null,
|
||||||
|
suitabilityReason: null,
|
||||||
|
...overrides,
|
||||||
|
} as Job;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("scoreJobsStep auto-skip behavior", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const jobsRepo = await import("../../repositories/jobs");
|
||||||
|
const settingsRepo = await import("../../repositories/settings");
|
||||||
|
const scorer = await import("../../services/scorer");
|
||||||
|
const visaSponsors = await import("../../services/visa-sponsors/index");
|
||||||
|
|
||||||
|
vi.mocked(jobsRepo.getUnscoredDiscoveredJobs).mockResolvedValue([
|
||||||
|
createMockJob(),
|
||||||
|
]);
|
||||||
|
vi.mocked(jobsRepo.updateJob).mockResolvedValue(null);
|
||||||
|
vi.mocked(settingsRepo.getSetting).mockResolvedValue(null);
|
||||||
|
vi.mocked(scorer.scoreJobSuitability).mockResolvedValue({
|
||||||
|
score: 40,
|
||||||
|
reason: "Low fit",
|
||||||
|
});
|
||||||
|
vi.mocked(visaSponsors.searchSponsors).mockReturnValue([]);
|
||||||
|
vi.mocked(visaSponsors.calculateSponsorMatchSummary).mockReturnValue({
|
||||||
|
sponsorMatchScore: 0,
|
||||||
|
sponsorMatchNames: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-skips jobs when score is below threshold", async () => {
|
||||||
|
const settingsRepo = await import("../../repositories/settings");
|
||||||
|
const jobsRepo = await import("../../repositories/jobs");
|
||||||
|
const { logger } = await import("@infra/logger");
|
||||||
|
|
||||||
|
vi.mocked(settingsRepo.getSetting).mockResolvedValue("50");
|
||||||
|
|
||||||
|
await scoreJobsStep({ profile: {} });
|
||||||
|
|
||||||
|
expect(jobsRepo.updateJob).toHaveBeenCalledWith(
|
||||||
|
"job-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
suitabilityScore: 40,
|
||||||
|
status: "skipped",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
"Auto-skipped job due to low score",
|
||||||
|
expect.objectContaining({
|
||||||
|
jobId: "job-1",
|
||||||
|
score: 40,
|
||||||
|
threshold: 50,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-skip jobs when score equals threshold", async () => {
|
||||||
|
const settingsRepo = await import("../../repositories/settings");
|
||||||
|
const jobsRepo = await import("../../repositories/jobs");
|
||||||
|
const scorer = await import("../../services/scorer");
|
||||||
|
const { logger } = await import("@infra/logger");
|
||||||
|
|
||||||
|
vi.mocked(settingsRepo.getSetting).mockResolvedValue("50");
|
||||||
|
vi.mocked(scorer.scoreJobSuitability).mockResolvedValue({
|
||||||
|
score: 50,
|
||||||
|
reason: "At threshold",
|
||||||
|
});
|
||||||
|
|
||||||
|
await scoreJobsStep({ profile: {} });
|
||||||
|
|
||||||
|
expect(jobsRepo.updateJob).toHaveBeenCalledWith(
|
||||||
|
"job-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
suitabilityScore: 50,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const updatePayload = vi.mocked(jobsRepo.updateJob).mock.calls[0][1] as {
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
expect(updatePayload).not.toHaveProperty("status");
|
||||||
|
expect(logger.info).not.toHaveBeenCalledWith(
|
||||||
|
"Auto-skipped job due to low score",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-skip when threshold setting is null", async () => {
|
||||||
|
const settingsRepo = await import("../../repositories/settings");
|
||||||
|
const jobsRepo = await import("../../repositories/jobs");
|
||||||
|
|
||||||
|
vi.mocked(settingsRepo.getSetting).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await scoreJobsStep({ profile: {} });
|
||||||
|
|
||||||
|
const updatePayload = vi.mocked(jobsRepo.updateJob).mock.calls[0][1] as {
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
expect(updatePayload).not.toHaveProperty("status");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-skip when threshold setting is NaN", async () => {
|
||||||
|
const settingsRepo = await import("../../repositories/settings");
|
||||||
|
const jobsRepo = await import("../../repositories/jobs");
|
||||||
|
|
||||||
|
vi.mocked(settingsRepo.getSetting).mockResolvedValue("not-a-number");
|
||||||
|
|
||||||
|
await scoreJobsStep({ profile: {} });
|
||||||
|
|
||||||
|
const updatePayload = vi.mocked(jobsRepo.updateJob).mock.calls[0][1] as {
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
expect(updatePayload).not.toHaveProperty("status");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never auto-skips applied jobs even when score is below threshold", async () => {
|
||||||
|
const settingsRepo = await import("../../repositories/settings");
|
||||||
|
const jobsRepo = await import("../../repositories/jobs");
|
||||||
|
const { logger } = await import("@infra/logger");
|
||||||
|
|
||||||
|
vi.mocked(settingsRepo.getSetting).mockResolvedValue("50");
|
||||||
|
vi.mocked(jobsRepo.getUnscoredDiscoveredJobs).mockResolvedValue([
|
||||||
|
createMockJob({ id: "job-applied", status: "applied" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await scoreJobsStep({ profile: {} });
|
||||||
|
|
||||||
|
expect(jobsRepo.updateJob).toHaveBeenCalledWith(
|
||||||
|
"job-applied",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
const updatePayload = vi.mocked(jobsRepo.updateJob).mock.calls[0][1] as {
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
expect(updatePayload).not.toHaveProperty("status");
|
||||||
|
expect(logger.info).not.toHaveBeenCalledWith(
|
||||||
|
"Auto-skipped job due to low score",
|
||||||
|
expect.objectContaining({ jobId: "job-applied" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { logger } from "@infra/logger";
|
import { logger } from "@infra/logger";
|
||||||
import type { Job } from "@shared/types";
|
import type { Job } from "@shared/types";
|
||||||
import * as jobsRepo from "../../repositories/jobs";
|
import * as jobsRepo from "../../repositories/jobs";
|
||||||
|
import * as settingsRepo from "../../repositories/settings";
|
||||||
import { scoreJobSuitability } from "../../services/scorer";
|
import { scoreJobSuitability } from "../../services/scorer";
|
||||||
import * as visaSponsors from "../../services/visa-sponsors/index";
|
import * as visaSponsors from "../../services/visa-sponsors/index";
|
||||||
import { progressHelpers, updateProgress } from "../progress";
|
import { progressHelpers, updateProgress } from "../progress";
|
||||||
@@ -12,6 +13,14 @@ export async function scoreJobsStep(args: {
|
|||||||
logger.info("Running scoring step");
|
logger.info("Running scoring step");
|
||||||
const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs();
|
const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs();
|
||||||
|
|
||||||
|
// Check if auto-skip threshold is configured
|
||||||
|
const autoSkipThresholdRaw = await settingsRepo.getSetting(
|
||||||
|
"autoSkipScoreThreshold",
|
||||||
|
);
|
||||||
|
const autoSkipThreshold = autoSkipThresholdRaw
|
||||||
|
? parseInt(autoSkipThresholdRaw, 10)
|
||||||
|
: null;
|
||||||
|
|
||||||
updateProgress({
|
updateProgress({
|
||||||
step: "scoring",
|
step: "scoring",
|
||||||
jobsDiscovered: unprocessedJobs.length,
|
jobsDiscovered: unprocessedJobs.length,
|
||||||
@@ -65,12 +74,29 @@ export async function scoreJobsStep(args: {
|
|||||||
sponsorMatchNames = summary.sponsorMatchNames ?? undefined;
|
sponsorMatchNames = summary.sponsorMatchNames ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if job should be auto-skipped based on score threshold
|
||||||
|
const shouldAutoSkip =
|
||||||
|
job.status !== "applied" &&
|
||||||
|
autoSkipThreshold !== null &&
|
||||||
|
!Number.isNaN(autoSkipThreshold) &&
|
||||||
|
score < autoSkipThreshold;
|
||||||
|
|
||||||
await jobsRepo.updateJob(job.id, {
|
await jobsRepo.updateJob(job.id, {
|
||||||
suitabilityScore: score,
|
suitabilityScore: score,
|
||||||
suitabilityReason: reason,
|
suitabilityReason: reason,
|
||||||
sponsorMatchScore,
|
sponsorMatchScore,
|
||||||
sponsorMatchNames,
|
sponsorMatchNames,
|
||||||
|
...(shouldAutoSkip ? { status: "skipped" } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (shouldAutoSkip) {
|
||||||
|
logger.info("Auto-skipped job due to low score", {
|
||||||
|
jobId: job.id,
|
||||||
|
title: job.title,
|
||||||
|
score,
|
||||||
|
threshold: autoSkipThreshold,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
progressHelpers.scoringComplete(scoredJobs.length);
|
progressHelpers.scoringComplete(scoredJobs.length);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
JobStatus,
|
JobStatus,
|
||||||
UpdateJobInput,
|
UpdateJobInput,
|
||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, isNull, lt, ne, sql } from "drizzle-orm";
|
||||||
import { db, schema } from "../db/index";
|
import { db, schema } from "../db/index";
|
||||||
|
|
||||||
const { jobs } = schema;
|
const { jobs } = schema;
|
||||||
@@ -242,6 +242,19 @@ export async function deleteJobsByStatus(status: JobStatus): Promise<number> {
|
|||||||
return result.changes;
|
return result.changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete jobs with suitability score below threshold (excluding applied jobs).
|
||||||
|
*/
|
||||||
|
export async function deleteJobsBelowScore(threshold: number): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.delete(jobs)
|
||||||
|
.where(
|
||||||
|
and(lt(jobs.suitabilityScore, threshold), ne(jobs.status, "applied")),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
return result.changes;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to map database row to Job type
|
// Helper to map database row to Job type
|
||||||
function mapRowToJob(row: typeof jobs.$inferSelect): Job {
|
function mapRowToJob(row: typeof jobs.$inferSelect): Job {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ export type SettingKey =
|
|||||||
| "backupHour"
|
| "backupHour"
|
||||||
| "backupMaxCount"
|
| "backupMaxCount"
|
||||||
| "penalizeMissingSalary"
|
| "penalizeMissingSalary"
|
||||||
| "missingSalaryPenalty";
|
| "missingSalaryPenalty"
|
||||||
|
| "autoSkipScoreThreshold";
|
||||||
|
|
||||||
export async function getSetting(key: SettingKey): Promise<string | null> {
|
export async function getSetting(key: SettingKey): Promise<string | null> {
|
||||||
const [row] = await db.select().from(settings).where(eq(settings.key, key));
|
const [row] = await db.select().from(settings).where(eq(settings.key, key));
|
||||||
|
|||||||
@@ -112,6 +112,47 @@ describe("settings-conversion", () => {
|
|||||||
expect(resolveSettingValue("missingSalaryPenalty", "100").value).toBe(100);
|
expect(resolveSettingValue("missingSalaryPenalty", "100").value).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips autoSkipScoreThreshold with clamping and null fallback", () => {
|
||||||
|
const serialized = serializeSettingValue("autoSkipScoreThreshold", 35);
|
||||||
|
expect(serialized).toBe("35");
|
||||||
|
|
||||||
|
const resolved = resolveSettingValue(
|
||||||
|
"autoSkipScoreThreshold",
|
||||||
|
serialized ?? undefined,
|
||||||
|
);
|
||||||
|
expect(resolved.overrideValue).toBe(35);
|
||||||
|
expect(resolved.value).toBe(35);
|
||||||
|
expect(resolved.defaultValue).toBeNull();
|
||||||
|
|
||||||
|
// Test clamping
|
||||||
|
expect(resolveSettingValue("autoSkipScoreThreshold", "150").value).toBe(
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
expect(resolveSettingValue("autoSkipScoreThreshold", "-5").value).toBe(0);
|
||||||
|
expect(resolveSettingValue("autoSkipScoreThreshold", "0").value).toBe(0);
|
||||||
|
expect(resolveSettingValue("autoSkipScoreThreshold", "100").value).toBe(
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test explicit null handling
|
||||||
|
expect(serializeSettingValue("autoSkipScoreThreshold", null)).toBeNull();
|
||||||
|
expect(resolveSettingValue("autoSkipScoreThreshold", undefined).value).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(resolveSettingValue("autoSkipScoreThreshold", "null").value).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(resolveSettingValue("autoSkipScoreThreshold", "").value).toBe(null);
|
||||||
|
|
||||||
|
// Invalid input falls back to default (null)
|
||||||
|
const invalid = resolveSettingValue(
|
||||||
|
"autoSkipScoreThreshold",
|
||||||
|
"not-a-number",
|
||||||
|
);
|
||||||
|
expect(invalid.overrideValue).toBeNull();
|
||||||
|
expect(invalid.value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("respects environment variables for new salary settings", () => {
|
it("respects environment variables for new salary settings", () => {
|
||||||
process.env.PENALIZE_MISSING_SALARY = "true";
|
process.env.PENALIZE_MISSING_SALARY = "true";
|
||||||
process.env.MISSING_SALARY_PENALTY = "25";
|
process.env.MISSING_SALARY_PENALTY = "25";
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type SettingsConversionValueMap = {
|
|||||||
backupMaxCount: number;
|
backupMaxCount: number;
|
||||||
penalizeMissingSalary: boolean;
|
penalizeMissingSalary: boolean;
|
||||||
missingSalaryPenalty: number;
|
missingSalaryPenalty: number;
|
||||||
|
autoSkipScoreThreshold: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsConversionInputMap = {
|
type SettingsConversionInputMap = {
|
||||||
@@ -219,6 +220,25 @@ export const settingsConversionMetadata: SettingsConversionMetadata = {
|
|||||||
serialize: serializeNullableNumber,
|
serialize: serializeNullableNumber,
|
||||||
resolve: resolveWithNullishFallback,
|
resolve: resolveWithNullishFallback,
|
||||||
},
|
},
|
||||||
|
autoSkipScoreThreshold: {
|
||||||
|
defaultValue: () => null,
|
||||||
|
parseOverride: (raw) => {
|
||||||
|
if (!raw || raw === "null" || raw === "") return null;
|
||||||
|
const parsed = parseInt(raw, 10);
|
||||||
|
if (Number.isNaN(parsed)) return null;
|
||||||
|
return Math.min(100, Math.max(0, parsed));
|
||||||
|
},
|
||||||
|
serialize: (value: number | null | undefined) => {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
return String(value);
|
||||||
|
},
|
||||||
|
resolve: (args: {
|
||||||
|
defaultValue: number | null;
|
||||||
|
overrideValue: number | null;
|
||||||
|
}) => {
|
||||||
|
return args.overrideValue ?? args.defaultValue;
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveSettingValue<K extends SettingsConversionKey>(
|
export function resolveSettingValue<K extends SettingsConversionKey>(
|
||||||
|
|||||||
@@ -224,6 +224,16 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
missingSalaryPenaltySetting.overrideValue;
|
missingSalaryPenaltySetting.overrideValue;
|
||||||
const missingSalaryPenalty = missingSalaryPenaltySetting.value;
|
const missingSalaryPenalty = missingSalaryPenaltySetting.value;
|
||||||
|
|
||||||
|
const autoSkipScoreThresholdSetting = resolveSettingValue(
|
||||||
|
"autoSkipScoreThreshold",
|
||||||
|
overrides.autoSkipScoreThreshold,
|
||||||
|
);
|
||||||
|
const defaultAutoSkipScoreThreshold =
|
||||||
|
autoSkipScoreThresholdSetting.defaultValue;
|
||||||
|
const overrideAutoSkipScoreThreshold =
|
||||||
|
autoSkipScoreThresholdSetting.overrideValue;
|
||||||
|
const autoSkipScoreThreshold = autoSkipScoreThresholdSetting.value;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...envSettings,
|
...envSettings,
|
||||||
model,
|
model,
|
||||||
@@ -297,6 +307,9 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
missingSalaryPenalty,
|
missingSalaryPenalty,
|
||||||
defaultMissingSalaryPenalty,
|
defaultMissingSalaryPenalty,
|
||||||
overrideMissingSalaryPenalty,
|
overrideMissingSalaryPenalty,
|
||||||
|
autoSkipScoreThreshold,
|
||||||
|
defaultAutoSkipScoreThreshold,
|
||||||
|
overrideAutoSkipScoreThreshold,
|
||||||
} as AppSettings;
|
} as AppSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
let gitVersion: string;
|
||||||
|
try {
|
||||||
|
gitVersion = execSync("git describe --tags --always", {
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
} catch {
|
||||||
|
gitVersion = process.env.APP_VERSION ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __APP_VERSION__: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
test: {
|
test: {
|
||||||
@@ -44,4 +61,7 @@ export default defineConfig({
|
|||||||
outDir: "dist/client",
|
outDir: "dist/client",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(gitVersion),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ export const updateSettingsSchema = z
|
|||||||
.max(100)
|
.max(100)
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
autoSkipScoreThreshold: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(100)
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.enableBasicAuth) {
|
if (data.enableBasicAuth) {
|
||||||
|
|||||||
@@ -589,6 +589,10 @@ export interface AppSettings {
|
|||||||
missingSalaryPenalty: number;
|
missingSalaryPenalty: number;
|
||||||
defaultMissingSalaryPenalty: number;
|
defaultMissingSalaryPenalty: number;
|
||||||
overrideMissingSalaryPenalty: number | null;
|
overrideMissingSalaryPenalty: number | null;
|
||||||
|
// Auto-skip settings
|
||||||
|
autoSkipScoreThreshold: number | null;
|
||||||
|
defaultAutoSkipScoreThreshold: number | null;
|
||||||
|
overrideAutoSkipScoreThreshold: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupInfo {
|
export interface BackupInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user