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:
parent
c4749b4211
commit
6353a23f6f
@ -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");
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
40
orchestrator/src/client/hooks/useVersionCheck.ts
Normal file
40
orchestrator/src/client/hooks/useVersionCheck.ts
Normal 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;
|
||||
}
|
||||
99
orchestrator/src/client/lib/version.ts
Normal file
99
orchestrator/src/client/lib/version.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -131,6 +131,9 @@ const baseSettings: AppSettings = {
|
||||
missingSalaryPenalty: 10,
|
||||
defaultMissingSalaryPenalty: 10,
|
||||
overrideMissingSalaryPenalty: null,
|
||||
autoSkipScoreThreshold: null,
|
||||
defaultAutoSkipScoreThreshold: null,
|
||||
overrideAutoSkipScoreThreshold: null,
|
||||
};
|
||||
|
||||
const renderPage = () => {
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -52,4 +52,5 @@ export type BackupValues = {
|
||||
export type ScoringValues = {
|
||||
penalizeMissingSalary: EffectiveDefault<boolean>;
|
||||
missingSalaryPenalty: EffectiveDefault<number>;
|
||||
autoSkipScoreThreshold: EffectiveDefault<number | null>;
|
||||
};
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
185
orchestrator/src/server/pipeline/steps/score-jobs.test.ts
Normal file
185
orchestrator/src/server/pipeline/steps/score-jobs.test.ts
Normal 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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user