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:
Shaheer Sarfaraz 2026-02-05 19:17:14 +00:00 committed by GitHub
parent c4749b4211
commit 6353a23f6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 858 additions and 9 deletions

View File

@ -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
export async function getVisaSponsorStatus(): Promise<VisaSponsorStatusResponse> {
return fetchApi<VisaSponsorStatusResponse>("/visa-sponsors/status");

View File

@ -187,11 +187,11 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
return (
<div className={cn("space-y-3", className)}>
{/* Detail header: lighter weight than list items */}
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<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 w-full sm:w-auto sm:flex-1">
<Link
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}
</Link>
@ -199,7 +199,7 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
<span>{job.employer}</span>
</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
variant="outline"
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"

View File

@ -16,7 +16,14 @@ import {
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useVersionCheck } from "../hooks/useVersionCheck";
import { isNavActive, NAV_LINKS } from "./navigation";
// ============================================================================
@ -43,6 +50,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
const location = useLocation();
const navigate = useNavigate();
const [navOpen, setNavOpen] = useState(false);
const { version, updateAvailable } = useVersionCheck();
const handleNavClick = (to: string, activePaths?: string[]) => {
if (isNavActive(location.pathname, to, activePaths)) {
@ -64,7 +72,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
<span className="sr-only">Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64">
<SheetContent side="left" className="w-64 flex flex-col">
<SheetHeader>
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
@ -86,6 +94,28 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
</button>
))}
</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>
</Sheet>

View File

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

View File

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

View File

@ -131,6 +131,9 @@ const baseSettings: AppSettings = {
missingSalaryPenalty: 10,
defaultMissingSalaryPenalty: 10,
overrideMissingSalaryPenalty: null,
autoSkipScoreThreshold: null,
defaultAutoSkipScoreThreshold: null,
overrideAutoSkipScoreThreshold: null,
};
const renderPage = () => {

View File

@ -75,6 +75,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
backupMaxCount: null,
penalizeMissingSalary: null,
missingSalaryPenalty: null,
autoSkipScoreThreshold: null,
};
type LlmProviderValue = LlmProviderId | null;
@ -120,6 +121,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
backupMaxCount: null,
penalizeMissingSalary: null,
missingSalaryPenalty: null,
autoSkipScoreThreshold: null,
};
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
@ -159,6 +161,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
backupMaxCount: data.overrideBackupMaxCount,
penalizeMissingSalary: data.overridePenalizeMissingSalary,
missingSalaryPenalty: data.overrideMissingSalaryPenalty,
autoSkipScoreThreshold: data.overrideAutoSkipScoreThreshold,
});
const normalizeString = (value: string | null | undefined) => {
@ -349,6 +352,10 @@ const getDerivedSettings = (settings: AppSettings | null) => {
effective: settings?.missingSalaryPenalty ?? 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) => {
setStatusesToClear((prev) =>
prev.includes(status)
@ -900,6 +932,7 @@ export const SettingsPage: React.FC = () => {
toggleStatusToClear={toggleStatusToClear}
handleClearByStatuses={handleClearByStatuses}
handleClearDatabase={handleClearDatabase}
handleClearByScore={handleClearByScore}
isLoading={isLoading}
isSaving={isSaving}
/>

View File

@ -2,9 +2,12 @@ import {
ALL_JOB_STATUSES,
STATUS_DESCRIPTIONS,
} 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 type React from "react";
import { useState } from "react";
import {
AccordionContent,
AccordionItem,
@ -22,6 +25,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
type DangerZoneSectionProps = {
@ -29,6 +33,7 @@ type DangerZoneSectionProps = {
toggleStatusToClear: (status: JobStatus) => void;
handleClearByStatuses: () => void;
handleClearDatabase: () => void;
handleClearByScore?: (threshold: number) => void;
isLoading: boolean;
isSaving: boolean;
};
@ -38,9 +43,16 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
toggleStatusToClear,
handleClearByStatuses,
handleClearDatabase,
handleClearByScore,
isLoading,
isSaving,
}) => {
const [scoreThreshold, setScoreThreshold] = useState<string>("");
const parsedThreshold = parseInt(scoreThreshold, 10);
const isValidThreshold =
!Number.isNaN(parsedThreshold) &&
parsedThreshold >= 0 &&
parsedThreshold <= 100;
return (
<AccordionItem
value="danger-zone"
@ -142,6 +154,85 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
<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="space-y-0.5">
<div className="text-sm font-semibold text-destructive">

View File

@ -22,13 +22,20 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
isLoading,
isSaving,
}) => {
const { penalizeMissingSalary, missingSalaryPenalty } = values;
const {
penalizeMissingSalary,
missingSalaryPenalty,
autoSkipScoreThreshold,
} = values;
const { control, watch } = useFormContext<UpdateSettingsInput>();
// Watch the current form value to conditionally show/hide penalty input
const currentPenalizeEnabled =
watch("penalizeMissingSalary") ?? penalizeMissingSalary.default;
// Watch auto-skip threshold to show current value
const currentAutoSkipThreshold = watch("autoSkipScoreThreshold");
return (
<AccordionItem value="scoring" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@ -106,6 +113,47 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
<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 */}
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
@ -126,6 +174,15 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
{missingSalaryPenalty.default}
</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>
</AccordionContent>

View File

@ -52,4 +52,5 @@ export type BackupValues = {
export type ScoringValues = {
penalizeMissingSalary: EffectiveDefault<boolean>;
missingSalaryPenalty: EffectiveDefault<number>;
autoSkipScoreThreshold: EffectiveDefault<number | null>;
};

View File

@ -140,6 +140,103 @@ describe.sequential("Jobs API routes", () => {
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 () => {
const { searchSponsors } = await import(
"../../services/visa-sponsors/index"

View File

@ -623,3 +623,57 @@ jobsRouter.delete("/status/:status", async (req: Request, res: Response) => {
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" },
});
}
});

View File

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

View File

@ -1,6 +1,7 @@
import { logger } from "@infra/logger";
import type { Job } from "@shared/types";
import * as jobsRepo from "../../repositories/jobs";
import * as settingsRepo from "../../repositories/settings";
import { scoreJobSuitability } from "../../services/scorer";
import * as visaSponsors from "../../services/visa-sponsors/index";
import { progressHelpers, updateProgress } from "../progress";
@ -12,6 +13,14 @@ export async function scoreJobsStep(args: {
logger.info("Running scoring step");
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({
step: "scoring",
jobsDiscovered: unprocessedJobs.length,
@ -65,12 +74,29 @@ export async function scoreJobsStep(args: {
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, {
suitabilityScore: score,
suitabilityReason: reason,
sponsorMatchScore,
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);

View File

@ -9,7 +9,7 @@ import type {
JobStatus,
UpdateJobInput,
} 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";
const { jobs } = schema;
@ -242,6 +242,19 @@ export async function deleteJobsByStatus(status: JobStatus): Promise<number> {
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
function mapRowToJob(row: typeof jobs.$inferSelect): Job {
return {

View File

@ -42,7 +42,8 @@ export type SettingKey =
| "backupHour"
| "backupMaxCount"
| "penalizeMissingSalary"
| "missingSalaryPenalty";
| "missingSalaryPenalty"
| "autoSkipScoreThreshold";
export async function getSetting(key: SettingKey): Promise<string | null> {
const [row] = await db.select().from(settings).where(eq(settings.key, key));

View File

@ -112,6 +112,47 @@ describe("settings-conversion", () => {
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", () => {
process.env.PENALIZE_MISSING_SALARY = "true";
process.env.MISSING_SALARY_PENALTY = "25";

View File

@ -22,6 +22,7 @@ type SettingsConversionValueMap = {
backupMaxCount: number;
penalizeMissingSalary: boolean;
missingSalaryPenalty: number;
autoSkipScoreThreshold: number | null;
};
type SettingsConversionInputMap = {
@ -219,6 +220,25 @@ export const settingsConversionMetadata: SettingsConversionMetadata = {
serialize: serializeNullableNumber,
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>(

View File

@ -224,6 +224,16 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
missingSalaryPenaltySetting.overrideValue;
const missingSalaryPenalty = missingSalaryPenaltySetting.value;
const autoSkipScoreThresholdSetting = resolveSettingValue(
"autoSkipScoreThreshold",
overrides.autoSkipScoreThreshold,
);
const defaultAutoSkipScoreThreshold =
autoSkipScoreThresholdSetting.defaultValue;
const overrideAutoSkipScoreThreshold =
autoSkipScoreThresholdSetting.overrideValue;
const autoSkipScoreThreshold = autoSkipScoreThresholdSetting.value;
return {
...envSettings,
model,
@ -297,6 +307,9 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
missingSalaryPenalty,
defaultMissingSalaryPenalty,
overrideMissingSalaryPenalty,
autoSkipScoreThreshold,
defaultAutoSkipScoreThreshold,
overrideAutoSkipScoreThreshold,
} as AppSettings;
}

View File

@ -1,10 +1,27 @@
/// <reference types="vitest" />
import { execSync } from "node:child_process";
import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
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({
plugins: [react(), tailwindcss()],
test: {
@ -44,4 +61,7 @@ export default defineConfig({
outDir: "dist/client",
emptyOutDir: true,
},
define: {
__APP_VERSION__: JSON.stringify(gitVersion),
},
});

View File

@ -83,6 +83,13 @@ export const updateSettingsSchema = z
.max(100)
.nullable()
.optional(),
autoSkipScoreThreshold: z
.number()
.int()
.min(0)
.max(100)
.nullable()
.optional(),
})
.superRefine((data, ctx) => {
if (data.enableBasicAuth) {

View File

@ -589,6 +589,10 @@ export interface AppSettings {
missingSalaryPenalty: number;
defaultMissingSalaryPenalty: number;
overrideMissingSalaryPenalty: number | null;
// Auto-skip settings
autoSkipScoreThreshold: number | null;
defaultAutoSkipScoreThreshold: number | null;
overrideAutoSkipScoreThreshold: number | null;
}
export interface BackupInfo {