From 6353a23f6f4f4958680cc23ec31f3252d81c552f Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:17:14 +0000 Subject: [PATCH] Small wins style tickets (#88) * wrap text * version check! * initial commit for "remove below score" in pipeline, or manually * comments --- orchestrator/src/client/api/client.ts | 14 ++ .../src/client/components/JobHeader.tsx | 8 +- orchestrator/src/client/components/layout.tsx | 32 ++- .../src/client/hooks/useVersionCheck.ts | 40 ++++ orchestrator/src/client/lib/version.ts | 99 ++++++++++ .../src/client/pages/SettingsPage.test.tsx | 3 + .../src/client/pages/SettingsPage.tsx | 33 ++++ .../settings/components/DangerZoneSection.tsx | 93 ++++++++- .../components/ScoringSettingsSection.tsx | 59 +++++- .../src/client/pages/settings/types.ts | 1 + .../src/server/api/routes/jobs.test.ts | 97 +++++++++ orchestrator/src/server/api/routes/jobs.ts | 54 +++++ .../server/pipeline/steps/score-jobs.test.ts | 185 ++++++++++++++++++ .../src/server/pipeline/steps/score-jobs.ts | 26 +++ orchestrator/src/server/repositories/jobs.ts | 15 +- .../src/server/repositories/settings.ts | 3 +- .../services/settings-conversion.test.ts | 41 ++++ .../server/services/settings-conversion.ts | 20 ++ orchestrator/src/server/services/settings.ts | 13 ++ orchestrator/vite.config.ts | 20 ++ shared/src/settings-schema.ts | 7 + shared/src/types.ts | 4 + 22 files changed, 858 insertions(+), 9 deletions(-) create mode 100644 orchestrator/src/client/hooks/useVersionCheck.ts create mode 100644 orchestrator/src/client/lib/version.ts create mode 100644 orchestrator/src/server/pipeline/steps/score-jobs.test.ts diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 80579e1..6273aa8 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -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 { return fetchApi("/visa-sponsors/status"); diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index d874d8c..b812284 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -187,11 +187,11 @@ export const JobHeader: React.FC = ({ return (
{/* Detail header: lighter weight than list items */} -
-
+
+
{job.title} @@ -199,7 +199,7 @@ export const JobHeader: React.FC = ({ {job.employer}
-
+
= ({ 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 = ({ Open navigation menu - + JobOps @@ -86,6 +94,28 @@ export const PageHeader: React.FC = ({ ))} + diff --git a/orchestrator/src/client/hooks/useVersionCheck.ts b/orchestrator/src/client/hooks/useVersionCheck.ts new file mode 100644 index 0000000..d943652 --- /dev/null +++ b/orchestrator/src/client/hooks/useVersionCheck.ts @@ -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(() => ({ + 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; +} diff --git a/orchestrator/src/client/lib/version.ts b/orchestrator/src/client/lib/version.ts new file mode 100644 index 0000000..673e967 --- /dev/null +++ b/orchestrator/src/client/lib/version.ts @@ -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 { + 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(), + }; + } +} diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index c4e1cf6..f949281 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -131,6 +131,9 @@ const baseSettings: AppSettings = { missingSalaryPenalty: 10, defaultMissingSalaryPenalty: 10, overrideMissingSalaryPenalty: null, + autoSkipScoreThreshold: null, + defaultAutoSkipScoreThreshold: null, + overrideAutoSkipScoreThreshold: null, }; const renderPage = () => { diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 63f2c17..64472ae 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -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} /> diff --git a/orchestrator/src/client/pages/settings/components/DangerZoneSection.tsx b/orchestrator/src/client/pages/settings/components/DangerZoneSection.tsx index 1b5aad9..500dcb5 100644 --- a/orchestrator/src/client/pages/settings/components/DangerZoneSection.tsx +++ b/orchestrator/src/client/pages/settings/components/DangerZoneSection.tsx @@ -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 = ({ toggleStatusToClear, handleClearByStatuses, handleClearDatabase, + handleClearByScore, isLoading, isSaving, }) => { + const [scoreThreshold, setScoreThreshold] = useState(""); + const parsedThreshold = parseInt(scoreThreshold, 10); + const isValidThreshold = + !Number.isNaN(parsedThreshold) && + parsedThreshold >= 0 && + parsedThreshold <= 100; return ( = ({ + {/* Clear Jobs Below Score */} + {handleClearByScore && ( +
+
+
+ Clear Jobs Below Score +
+
+ Remove all jobs with a suitability score below the specified + threshold. Applied jobs will not be deleted. +
+
+ +
+
+ + setScoreThreshold(e.target.value)} + disabled={isLoading || isSaving} + className="w-full" + /> +
+ + + + + + + + Clear jobs below score {parsedThreshold}? + + + This will permanently delete all jobs with a suitability + score below {parsedThreshold}. Applied jobs will be + preserved. This action cannot be undone. + + + + Cancel + { + if (isValidThreshold) { + handleClearByScore(parsedThreshold); + setScoreThreshold(""); + } + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Clear jobs + + + + +
+
+ )} + + +
diff --git a/orchestrator/src/client/pages/settings/components/ScoringSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/ScoringSettingsSection.tsx index 8cc8000..dd42d7d 100644 --- a/orchestrator/src/client/pages/settings/components/ScoringSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ScoringSettingsSection.tsx @@ -22,13 +22,20 @@ export const ScoringSettingsSection: React.FC = ({ isLoading, isSaving, }) => { - const { penalizeMissingSalary, missingSalaryPenalty } = values; + const { + penalizeMissingSalary, + missingSalaryPenalty, + autoSkipScoreThreshold, + } = values; const { control, watch } = useFormContext(); // 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 ( @@ -106,6 +113,47 @@ export const ScoringSettingsSection: React.FC = ({ + {/* Auto-skip threshold input */} +
+ ( + { + 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"}`} + /> + )} + /> +
+ + + {/* Effective/Default values display */}
@@ -126,6 +174,15 @@ export const ScoringSettingsSection: React.FC = ({ {missingSalaryPenalty.default}
+
+
+ Auto-skip Threshold +
+
+ Effective: {autoSkipScoreThreshold.effective ?? "Disabled"} | + Default: {autoSkipScoreThreshold.default ?? "Disabled"} +
+
diff --git a/orchestrator/src/client/pages/settings/types.ts b/orchestrator/src/client/pages/settings/types.ts index 3892143..cbbcf6b 100644 --- a/orchestrator/src/client/pages/settings/types.ts +++ b/orchestrator/src/client/pages/settings/types.ts @@ -52,4 +52,5 @@ export type BackupValues = { export type ScoringValues = { penalizeMissingSalary: EffectiveDefault; missingSalaryPenalty: EffectiveDefault; + autoSkipScoreThreshold: EffectiveDefault; }; diff --git a/orchestrator/src/server/api/routes/jobs.test.ts b/orchestrator/src/server/api/routes/jobs.test.ts index 4070f4f..733e13c 100644 --- a/orchestrator/src/server/api/routes/jobs.test.ts +++ b/orchestrator/src/server/api/routes/jobs.test.ts @@ -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" diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts index dea2e1e..f33e5bb 100644 --- a/orchestrator/src/server/api/routes/jobs.ts +++ b/orchestrator/src/server/api/routes/jobs.ts @@ -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" }, + }); + } +}); diff --git a/orchestrator/src/server/pipeline/steps/score-jobs.test.ts b/orchestrator/src/server/pipeline/steps/score-jobs.test.ts new file mode 100644 index 0000000..5fcc8f7 --- /dev/null +++ b/orchestrator/src/server/pipeline/steps/score-jobs.test.ts @@ -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 { + 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" }), + ); + }); +}); diff --git a/orchestrator/src/server/pipeline/steps/score-jobs.ts b/orchestrator/src/server/pipeline/steps/score-jobs.ts index 5b0d787..c54f9fb 100644 --- a/orchestrator/src/server/pipeline/steps/score-jobs.ts +++ b/orchestrator/src/server/pipeline/steps/score-jobs.ts @@ -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); diff --git a/orchestrator/src/server/repositories/jobs.ts b/orchestrator/src/server/repositories/jobs.ts index 507c58d..a3a69fc 100644 --- a/orchestrator/src/server/repositories/jobs.ts +++ b/orchestrator/src/server/repositories/jobs.ts @@ -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 { return result.changes; } +/** + * Delete jobs with suitability score below threshold (excluding applied jobs). + */ +export async function deleteJobsBelowScore(threshold: number): Promise { + 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 { diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index 4d789d0..ae3e72f 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -42,7 +42,8 @@ export type SettingKey = | "backupHour" | "backupMaxCount" | "penalizeMissingSalary" - | "missingSalaryPenalty"; + | "missingSalaryPenalty" + | "autoSkipScoreThreshold"; export async function getSetting(key: SettingKey): Promise { const [row] = await db.select().from(settings).where(eq(settings.key, key)); diff --git a/orchestrator/src/server/services/settings-conversion.test.ts b/orchestrator/src/server/services/settings-conversion.test.ts index 8df47ef..ba661c7 100644 --- a/orchestrator/src/server/services/settings-conversion.test.ts +++ b/orchestrator/src/server/services/settings-conversion.test.ts @@ -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"; diff --git a/orchestrator/src/server/services/settings-conversion.ts b/orchestrator/src/server/services/settings-conversion.ts index f248fe5..ebdbb3e 100644 --- a/orchestrator/src/server/services/settings-conversion.ts +++ b/orchestrator/src/server/services/settings-conversion.ts @@ -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( diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts index c34732c..901e7e9 100644 --- a/orchestrator/src/server/services/settings.ts +++ b/orchestrator/src/server/services/settings.ts @@ -224,6 +224,16 @@ export async function getEffectiveSettings(): Promise { 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 { missingSalaryPenalty, defaultMissingSalaryPenalty, overrideMissingSalaryPenalty, + autoSkipScoreThreshold, + defaultAutoSkipScoreThreshold, + overrideAutoSkipScoreThreshold, } as AppSettings; } diff --git a/orchestrator/vite.config.ts b/orchestrator/vite.config.ts index 99bcab3..9f0773f 100644 --- a/orchestrator/vite.config.ts +++ b/orchestrator/vite.config.ts @@ -1,10 +1,27 @@ /// +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), + }, }); diff --git a/shared/src/settings-schema.ts b/shared/src/settings-schema.ts index 4e84493..bd30b73 100644 --- a/shared/src/settings-schema.ts +++ b/shared/src/settings-schema.ts @@ -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) { diff --git a/shared/src/types.ts b/shared/src/types.ts index ed08f6b..a798744 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -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 {