Code cleanup (#218)

* chore: move @types/canvas-confetti to devDependencies, remove unused get-tsconfig direct dep

* chore: configure knip with workspace entry points for all packages

* refactor(shared): split 1119-line types.ts into domain modules under types/

* refactor: remove llm-service.ts shim, migrate all import sites to llm/service directly

* refactor(settings): migrate 4 manually-resolved settings into conversion registry

* refactor: split gmail-sync.ts into gmail-api, email-router, and thin orchestrator

* refactor(orchestrator): extract useKeyboardShortcuts and usePipelineControls from OrchestratorPage

Splits the 840-line OrchestratorPage into a thin orchestration shell (~480 lines) by
extracting keyboard shortcut handling into useKeyboardShortcuts.ts and pipeline
control logic into usePipelineControls.ts. Net negative line count across all files.

* feat: create settings registry (Step 1)

Introduces a single source of truth for all settings, combining schema definitions, default logic, parsing, and serialization into a single configuration object.

* feat: derive schema, keys, and types from settings registry (Step 2)

Derives AppSettings nested shape, SettingKey DB union, and updateSettingsSchema Zod shape automatically from the settings registry.

* refactor: gut envSettings and remove settings-conversion (Step 3)

Replaces manual env arrays with registry-driven maps in envSettings.ts.
Deletes settings-conversion.ts since all parsing/defaults now live in the registry.

* refactor: simplify getEffectiveSettings with generic loop (Step 4)

Replaces ~334 lines of manual key-by-key unpacking with a generic registry-driven iteration loop (~40 lines). Models, typed, string, and virtual kinds are automatically derived.

* refactor: simplify settingsUpdateRegistry (Step 5)

Replaces ~350 lines of explicit per-key update handlers with a dynamic generic loop over the settings registry, properly routing persistence and side effects.

* refactor(settings): implement nested settings registry and clean up tests

- Migrate settings system to use a centralized nested registry (`settings-schema.ts`, `registry.ts`)
- Remove obsolete flat-to-nested conversion logic (`settings-conversion.ts`)
- Address Biome warnings by explicitly ignoring intentional `any` usage in generic runtime schema builder and registry logic
- Clean up unused variables in test files (`SettingsPage.test.tsx`) to achieve a 100% green CI pipeline

* refactor(settings): address PR comments on env data and registry parsing

- Narrow `getEnvSettingsData` return type to `Partial<AppSettings>` to satisfy strict typing and omit 'typed' registry entries
- Introduce `parseNonEmptyStringOrNull` for typed string settings so empty-string overrides cleanly fall back to defaults (matching original `||` logic)
- Add missing unit tests for registry parse/serialize helpers (JSON, bools, numeric clamping)
This commit is contained in:
Shaheer Sarfaraz 2026-02-21 03:07:51 +00:00 committed by GitHub
parent 19266fe5eb
commit b18c2eccbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 3437 additions and 3810 deletions

View File

@ -2,6 +2,45 @@
"$schema": "https://unpkg.com/knip@5/schema.json", "$schema": "https://unpkg.com/knip@5/schema.json",
"tags": ["-lintignore"], "tags": ["-lintignore"],
"workspaces": { "workspaces": {
".": {} ".": {
"entry": [],
"project": []
},
"orchestrator": {
"entry": [
"src/server/index.ts",
"src/server/db/migrate.ts",
"src/server/db/clear.ts",
"src/server/pipeline/run.ts",
"src/client/main.tsx",
"vite.config.ts",
"src/setupTests.ts",
"src/**/*.test.ts",
"src/**/*.test.tsx"
],
"project": ["src/**/*.{ts,tsx}"],
"ignore": ["dist/**", "src/components/ui/**"]
},
"shared": {
"entry": ["src/index.ts"],
"project": ["src/**/*.ts"],
"ignore": ["src/**/*.test.ts"]
},
"extractors/adzuna": {
"entry": ["src/main.ts"],
"project": ["src/**/*.ts"]
},
"extractors/gradcracker": {
"entry": ["src/main.ts"],
"project": ["src/**/*.ts"]
},
"extractors/hiringcafe": {
"entry": ["src/main.ts"],
"project": ["src/**/*.ts"]
},
"extractors/ukvisajobs": {
"entry": ["src/main.ts"],
"project": ["src/**/*.ts"]
}
} }
} }

View File

@ -46,7 +46,6 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@types/canvas-confetti": "^1.9.0",
"better-sqlite3": "^11.6.0", "better-sqlite3": "^11.6.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -57,7 +56,6 @@
"drizzle-orm": "^0.38.2", "drizzle-orm": "^0.38.2",
"express": "^4.18.2", "express": "^4.18.2",
"framer-motion": "^12.34.3", "framer-motion": "^12.34.3",
"get-tsconfig": "^4.10.0",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
@ -81,6 +79,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@types/better-sqlite3": "^7.6.8", "@types/better-sqlite3": "^7.6.8",
"@types/canvas-confetti": "^1.9.0",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/html-to-text": "^9.0.4", "@types/html-to-text": "^9.0.4",

View File

@ -92,7 +92,7 @@ vi.mock("sonner", () => ({
const settingsResponse = { const settingsResponse = {
settings: { settings: {
llmProvider: "openrouter", llmProvider: { value: "openrouter", default: "openrouter", override: null },
llmApiKeyHint: null, llmApiKeyHint: null,
rxresumeEmail: "", rxresumeEmail: "",
rxresumePasswordHint: null, rxresumePasswordHint: null,
@ -163,7 +163,7 @@ describe("OnboardingGate", () => {
...settingsResponse, ...settingsResponse,
settings: { settings: {
...settingsResponse.settings, ...settingsResponse.settings,
llmProvider: "ollama", llmProvider: { value: "ollama", default: "ollama", override: null },
}, },
} as any); } as any);
vi.mocked(api.validateRxresume).mockResolvedValue({ vi.mocked(api.validateRxresume).mockResolvedValue({

View File

@ -120,7 +120,7 @@ export const OnboardingGate: React.FC = () => {
const validateLlm = useCallback(async () => { const validateLlm = useCallback(async () => {
const values = getValues(); const values = getValues();
const selectedProvider = normalizeLlmProvider( const selectedProvider = normalizeLlmProvider(
values.llmProvider || settings?.llmProvider || "openrouter", values.llmProvider || settings?.llmProvider?.value || "openrouter",
); );
const providerConfig = getLlmProviderConfig(selectedProvider); const providerConfig = getLlmProviderConfig(selectedProvider);
const { requiresApiKey, showBaseUrl } = providerConfig; const { requiresApiKey, showBaseUrl } = providerConfig;
@ -191,7 +191,7 @@ export const OnboardingGate: React.FC = () => {
}, []); }, []);
const selectedProvider = normalizeLlmProvider( const selectedProvider = normalizeLlmProvider(
llmProvider || settings?.llmProvider || "openrouter", llmProvider || settings?.llmProvider?.value || "openrouter",
); );
const providerConfig = getLlmProviderConfig(selectedProvider); const providerConfig = getLlmProviderConfig(selectedProvider);
const { const {
@ -227,8 +227,8 @@ export const OnboardingGate: React.FC = () => {
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
reset({ reset({
llmProvider: settings.llmProvider || "", llmProvider: settings.llmProvider?.value || "",
llmBaseUrl: settings.llmBaseUrl || "", llmBaseUrl: settings.llmBaseUrl?.value || "",
llmApiKey: "", llmApiKey: "",
rxresumeEmail: "", rxresumeEmail: "",
rxresumePassword: "", rxresumePassword: "",
@ -637,7 +637,7 @@ export const OnboardingGate: React.FC = () => {
}} }}
placeholder={providerConfig.baseUrlPlaceholder} placeholder={providerConfig.baseUrlPlaceholder}
helper={providerConfig.baseUrlHelper} helper={providerConfig.baseUrlHelper}
current={settings?.llmBaseUrl || "—"} current={settings?.llmBaseUrl?.value || "—"}
disabled={isSavingEnv} disabled={isSavingEnv}
/> />
)} )}

View File

@ -1,33 +1,12 @@
import {
useMarkAsAppliedMutation,
useSkipJobMutation,
} from "@client/hooks/queries/useJobMutations";
import { useHotkeys } from "@client/hooks/useHotkeys";
import { useProfile } from "@client/hooks/useProfile";
import { useSettings } from "@client/hooks/useSettings"; import { useSettings } from "@client/hooks/useSettings";
import { SHORTCUTS } from "@client/lib/shortcut-map";
import {
formatCountryLabel,
getCompatibleSourcesForCountry,
} from "@shared/location-support.js";
import type { JobSource } from "@shared/types.js";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"; import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import { safeFilenamePart } from "@/lib/utils";
import * as api from "../api";
import { KeyboardShortcutBar } from "../components/KeyboardShortcutBar"; import { KeyboardShortcutBar } from "../components/KeyboardShortcutBar";
import { KeyboardShortcutDialog } from "../components/KeyboardShortcutDialog"; import { KeyboardShortcutDialog } from "../components/KeyboardShortcutDialog";
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
import {
deriveExtractorLimits,
serializeCityLocationsSetting,
} from "./orchestrator/automatic-run";
import type { FilterTab } from "./orchestrator/constants"; import type { FilterTab } from "./orchestrator/constants";
import { tabs } from "./orchestrator/constants";
import { FloatingJobActionsBar } from "./orchestrator/FloatingJobActionsBar"; import { FloatingJobActionsBar } from "./orchestrator/FloatingJobActionsBar";
import { JobCommandBar } from "./orchestrator/JobCommandBar"; import { JobCommandBar } from "./orchestrator/JobCommandBar";
import { JobDetailPanel } from "./orchestrator/JobDetailPanel"; import { JobDetailPanel } from "./orchestrator/JobDetailPanel";
@ -36,11 +15,12 @@ import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader"; import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader";
import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary"; import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary";
import { RunModeModal } from "./orchestrator/RunModeModal"; import { RunModeModal } from "./orchestrator/RunModeModal";
import type { RunMode } from "./orchestrator/run-mode";
import { useFilteredJobs } from "./orchestrator/useFilteredJobs"; import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
import { useJobSelectionActions } from "./orchestrator/useJobSelectionActions"; import { useJobSelectionActions } from "./orchestrator/useJobSelectionActions";
import { useKeyboardShortcuts } from "./orchestrator/useKeyboardShortcuts";
import { useOrchestratorData } from "./orchestrator/useOrchestratorData"; import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
import { useOrchestratorFilters } from "./orchestrator/useOrchestratorFilters"; import { useOrchestratorFilters } from "./orchestrator/useOrchestratorFilters";
import { usePipelineControls } from "./orchestrator/usePipelineControls";
import { usePipelineSources } from "./orchestrator/usePipelineSources"; import { usePipelineSources } from "./orchestrator/usePipelineSources";
import { useScrollToJobItem } from "./orchestrator/useScrollToJobItem"; import { useScrollToJobItem } from "./orchestrator/useScrollToJobItem";
import { import {
@ -101,36 +81,10 @@ export const OrchestratorPage: React.FC = () => {
}, [tab, navigate, navigateWithContext]); }, [tab, navigate, navigateWithContext]);
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [isRunModeModalOpen, setIsRunModeModalOpen] = useState(false);
const [runMode, setRunMode] = useState<RunMode>("automatic");
const [isCommandBarOpen, setIsCommandBarOpen] = useState(false); const [isCommandBarOpen, setIsCommandBarOpen] = useState(false);
const [isFiltersOpen, setIsFiltersOpen] = useState(false); const [isFiltersOpen, setIsFiltersOpen] = useState(false);
const [isHelpDialogOpen, setIsHelpDialogOpen] = useState(false); const [isHelpDialogOpen, setIsHelpDialogOpen] = useState(false);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false); const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const shortcutActionInFlight = useRef(false);
const isAnyModalOpen =
isRunModeModalOpen ||
isCommandBarOpen ||
isFiltersOpen ||
isHelpDialogOpen ||
isDetailDrawerOpen ||
navOpen;
const isAnyModalOpenExcludingCommandBar =
isRunModeModalOpen ||
isFiltersOpen ||
isHelpDialogOpen ||
isDetailDrawerOpen ||
navOpen;
const isAnyModalOpenExcludingHelp =
isRunModeModalOpen ||
isCommandBarOpen ||
isFiltersOpen ||
isDetailDrawerOpen ||
navOpen;
const [isDesktop, setIsDesktop] = useState(() => const [isDesktop, setIsDesktop] = useState(() =>
typeof window !== "undefined" typeof window !== "undefined"
@ -152,9 +106,7 @@ export const OrchestratorPage: React.FC = () => {
[navigateWithContext, activeTab], [navigateWithContext, activeTab],
); );
const { settings, refreshSettings } = useSettings(); const { settings } = useSettings();
const markAsAppliedMutation = useMarkAsAppliedMutation();
const skipJobMutation = useSkipJobMutation();
const { const {
jobs, jobs,
selectedJob, selectedJob,
@ -173,6 +125,25 @@ export const OrchestratorPage: React.FC = () => {
const { pipelineSources, setPipelineSources, toggleSource } = const { pipelineSources, setPipelineSources, toggleSource } =
usePipelineSources(enabledSources); usePipelineSources(enabledSources);
const {
isRunModeModalOpen,
setIsRunModeModalOpen,
runMode,
setRunMode,
isCancelling,
openRunMode,
handleCancelPipeline,
handleSaveAndRunAutomatic,
handleManualImported,
} = usePipelineControls({
isPipelineRunning,
setIsPipelineRunning,
pipelineTerminalEvent,
pipelineSources,
loadJobs,
navigateWithContext,
});
const activeJobs = useFilteredJobs( const activeJobs = useFilteredJobs(
jobs, jobs,
activeTab, activeTab,
@ -206,129 +177,6 @@ export const OrchestratorPage: React.FC = () => {
} }
}, [isLoading, sourceFilter, setSourceFilter, sourcesWithJobs]); }, [isLoading, sourceFilter, setSourceFilter, sourcesWithJobs]);
const openRunMode = useCallback((mode: RunMode) => {
setRunMode(mode);
setIsRunModeModalOpen(true);
}, []);
const handleManualImported = useCallback(
async (importedJobId: string) => {
await loadJobs();
navigateWithContext("ready", importedJobId);
},
[loadJobs, navigateWithContext],
);
const startPipelineRun = useCallback(
async (config: {
topN: number;
minSuitabilityScore: number;
sources: JobSource[];
}) => {
try {
setIsPipelineRunning(true);
setIsCancelling(false);
await api.runPipeline(config);
toast.message("Pipeline started", {
description: `Sources: ${config.sources.join(", ")}. This may take a few minutes.`,
});
} catch (error) {
setIsPipelineRunning(false);
setIsCancelling(false);
const message =
error instanceof Error ? error.message : "Failed to start pipeline";
toast.error(message);
}
},
[setIsPipelineRunning],
);
useEffect(() => {
if (!pipelineTerminalEvent) return;
setIsPipelineRunning(false);
setIsCancelling(false);
if (pipelineTerminalEvent.status === "cancelled") {
toast.message("Pipeline cancelled");
return;
}
if (pipelineTerminalEvent.status === "failed") {
toast.error(pipelineTerminalEvent.errorMessage || "Pipeline failed");
return;
}
toast.success("Pipeline completed");
}, [pipelineTerminalEvent, setIsPipelineRunning]);
const handleCancelPipeline = useCallback(async () => {
if (isCancelling || !isPipelineRunning) return;
try {
setIsCancelling(true);
const result = await api.cancelPipeline();
toast.message(result.message);
} catch (error) {
setIsCancelling(false);
const message =
error instanceof Error ? error.message : "Failed to cancel pipeline";
toast.error(message);
}
}, [isCancelling, isPipelineRunning]);
const handleSaveAndRunAutomatic = useCallback(
async (values: AutomaticRunValues) => {
const compatibleSources = getCompatibleSourcesForCountry(
pipelineSources,
values.country,
);
if (compatibleSources.length === 0) {
toast.error(
"No compatible sources for the selected country. Choose another country or source.",
);
return;
}
const limits = deriveExtractorLimits({
budget: values.runBudget,
searchTerms: values.searchTerms,
sources: compatibleSources,
});
const hasJobSpySite = compatibleSources.some(
(source) =>
source === "indeed" ||
source === "linkedin" ||
source === "glassdoor",
);
const hasAdzuna = compatibleSources.includes("adzuna");
const hasHiringCafe = compatibleSources.includes("hiringcafe");
const serializedCities = serializeCityLocationsSetting(
values.cityLocations,
);
const searchCities =
(hasJobSpySite || hasAdzuna || hasHiringCafe) && serializedCities
? serializedCities
: formatCountryLabel(values.country);
await api.updateSettings({
searchTerms: values.searchTerms,
jobspyResultsWanted: limits.jobspyResultsWanted,
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
adzunaMaxJobsPerTerm: limits.adzunaMaxJobsPerTerm,
jobspyCountryIndeed: values.country,
searchCities,
});
await refreshSettings();
await startPipelineRun({
topN: values.topN,
minSuitabilityScore: values.minSuitabilityScore,
sources: compatibleSources,
});
setIsRunModeModalOpen(false);
},
[pipelineSources, refreshSettings, startPipelineRun],
);
const handleSelectJob = (id: string) => { const handleSelectJob = (id: string) => {
handleSelectJobId(id); handleSelectJobId(id);
if (!isDesktop) { if (!isDesktop) {
@ -343,234 +191,48 @@ export const OrchestratorPage: React.FC = () => {
onEnsureJobSelected: (id) => navigateWithContext(activeTab, id, true), onEnsureJobSelected: (id) => navigateWithContext(activeTab, id, true),
}); });
// ── Keyboard shortcuts ────────────────────────────────────────────────── const isAnyModalOpen =
const { personName } = useProfile(); isRunModeModalOpen ||
isCommandBarOpen ||
isFiltersOpen ||
isHelpDialogOpen ||
isDetailDrawerOpen ||
navOpen;
const navigateJobList = useCallback( const isAnyModalOpenExcludingCommandBar =
(direction: 1 | -1) => { isRunModeModalOpen ||
if (activeJobs.length === 0) return; isFiltersOpen ||
const currentIndex = selectedJobId isHelpDialogOpen ||
? activeJobs.findIndex((j) => j.id === selectedJobId) isDetailDrawerOpen ||
: -1; navOpen;
const nextIndex = Math.max(
0,
Math.min(activeJobs.length - 1, currentIndex + direction),
);
const nextJob = activeJobs[nextIndex];
if (nextJob && nextJob.id !== selectedJobId) {
handleSelectJobId(nextJob.id);
requestScrollToJob(nextJob.id);
}
},
[activeJobs, selectedJobId, handleSelectJobId, requestScrollToJob],
);
const navigateTab = useCallback( const isAnyModalOpenExcludingHelp =
(direction: 1 | -1) => { isRunModeModalOpen ||
const currentIndex = tabs.findIndex((t) => t.id === activeTab); isCommandBarOpen ||
const nextIndex = (currentIndex + direction + tabs.length) % tabs.length; isFiltersOpen ||
setActiveTab(tabs[nextIndex].id); isDetailDrawerOpen ||
}, navOpen;
[activeTab, setActiveTab],
);
/** useKeyboardShortcuts({
* After a destructive/moving action (skip, mark-applied), auto-advance to isAnyModalOpen,
* the next job in the list -- mirroring handleJobMoved in JobDetailPanel. isAnyModalOpenExcludingCommandBar,
*/ isAnyModalOpenExcludingHelp,
const selectNextAfterAction = useCallback( activeTab,
(movedJobId: string) => { activeJobs,
const idx = activeJobs.findIndex((j) => j.id === movedJobId); selectedJobId,
const next = activeJobs[idx + 1] || activeJobs[idx - 1]; selectedJob,
handleSelectJobId(next?.id ?? null); selectedJobIds,
}, isDesktop,
[activeJobs, handleSelectJobId], handleSelectJobId,
); requestScrollToJob,
setActiveTab,
useHotkeys( setIsCommandBarOpen,
{ setIsHelpDialogOpen,
// ── Navigation ────────────────────────────────────────────────────── clearSelection,
[SHORTCUTS.nextJob.key]: (e) => { toggleSelectJob,
e.preventDefault(); runJobAction,
navigateJobList(1); loadJobs,
},
[SHORTCUTS.nextJobArrow.key]: (e) => {
e.preventDefault();
navigateJobList(1);
},
[SHORTCUTS.prevJob.key]: (e) => {
e.preventDefault();
navigateJobList(-1);
},
[SHORTCUTS.prevJobArrow.key]: (e) => {
e.preventDefault();
navigateJobList(-1);
},
// ── Tab switching ───────────────────────────────────────────────────
[SHORTCUTS.tabReady.key]: () => setActiveTab("ready"),
[SHORTCUTS.tabDiscovered.key]: () => setActiveTab("discovered"),
[SHORTCUTS.tabApplied.key]: () => setActiveTab("applied"),
[SHORTCUTS.tabAll.key]: () => setActiveTab("all"),
[SHORTCUTS.prevTabArrow.key]: (e) => {
e.preventDefault();
navigateTab(-1);
},
[SHORTCUTS.nextTabArrow.key]: (e) => {
e.preventDefault();
navigateTab(1);
},
// ── Context actions ─────────────────────────────────────────────────
[SHORTCUTS.skip.key]: () => {
if (!["discovered", "ready"].includes(activeTab)) return;
if (shortcutActionInFlight.current) return;
// Selection action takes precedence if selection exists
if (selectedJobIds.size > 0) {
void runJobAction("skip");
return;
}
if (!selectedJob) return;
shortcutActionInFlight.current = true;
const jobId = selectedJob.id;
skipJobMutation
.mutateAsync(jobId)
.then(async () => {
toast.message("Job skipped");
selectNextAfterAction(jobId);
await loadJobs();
})
.catch((err: unknown) => {
const msg =
err instanceof Error ? err.message : "Failed to skip job";
toast.error(msg);
})
.finally(() => {
shortcutActionInFlight.current = false;
}); });
},
[SHORTCUTS.markApplied.key]: () => {
if (!selectedJob) return;
if (activeTab !== "ready") return;
if (shortcutActionInFlight.current) return;
shortcutActionInFlight.current = true;
const jobId = selectedJob.id;
markAsAppliedMutation
.mutateAsync(jobId)
.then(async () => {
toast.success("Marked as applied", {
description: `${selectedJob.title} at ${selectedJob.employer}`,
});
selectNextAfterAction(jobId);
await loadJobs();
})
.catch((err: unknown) => {
const msg =
err instanceof Error ? err.message : "Failed to mark as applied";
toast.error(msg);
})
.finally(() => {
shortcutActionInFlight.current = false;
});
},
[SHORTCUTS.moveToReady.key]: () => {
if (activeTab !== "discovered") return;
if (shortcutActionInFlight.current) return;
// Selection action takes precedence if selection exists
if (selectedJobIds.size > 0) {
void runJobAction("move_to_ready");
return;
}
// Single action
if (!selectedJob) return;
shortcutActionInFlight.current = true;
const jobId = selectedJob.id;
toast.message("Moving job to Ready...");
api
.processJob(jobId)
.then(async () => {
toast.success("Job moved to Ready", {
description: "Your tailored PDF has been generated.",
});
selectNextAfterAction(jobId);
await loadJobs();
})
.catch((err: unknown) => {
const msg =
err instanceof Error
? err.message
: "Failed to move job to ready";
toast.error(msg);
})
.finally(() => {
shortcutActionInFlight.current = false;
});
},
[SHORTCUTS.viewPdf.key]: () => {
if (!selectedJob) return;
if (activeTab !== "ready") return;
const href = `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`;
window.open(href, "_blank", "noopener,noreferrer");
},
[SHORTCUTS.downloadPdf.key]: () => {
if (!selectedJob) return;
if (activeTab !== "ready") return;
const href = `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`;
const a = document.createElement("a");
a.href = href;
a.download = `${safeFilenamePart(personName || "Unknown")}_${safeFilenamePart(selectedJob.employer)}.pdf`;
a.click();
},
[SHORTCUTS.openListing.key]: () => {
if (!selectedJob) return;
const link = selectedJob.applicationLink || selectedJob.jobUrl;
if (link) window.open(link, "_blank", "noopener,noreferrer");
},
[SHORTCUTS.toggleSelect.key]: () => {
if (!selectedJobId) return;
toggleSelectJob(selectedJobId);
},
[SHORTCUTS.clearSelection.key]: () => {
if (selectedJobIds.size > 0) clearSelection();
},
},
{ enabled: !isAnyModalOpen },
);
useHotkeys(
{
// ── Search ──────────────────────────────────────────────────────────
[SHORTCUTS.searchSlash.key]: (e) => {
e.preventDefault();
setIsCommandBarOpen(true);
},
},
{ enabled: !isAnyModalOpenExcludingCommandBar },
);
useHotkeys(
{
// ── Help ────────────────────────────────────────────────────────────
[SHORTCUTS.help.key]: (e) => {
e.preventDefault();
setIsHelpDialogOpen((prev) => !prev);
},
},
{ enabled: !isAnyModalOpenExcludingHelp },
);
const handleCommandSelectJob = useCallback( const handleCommandSelectJob = useCallback(
(targetTab: FilterTab, id: string) => { (targetTab: FilterTab, id: string) => {

View File

@ -31,15 +31,6 @@ vi.mock("sonner", () => ({
})); }));
const baseSettings = createAppSettings({ const baseSettings = createAppSettings({
model: "google/gemini-3-flash-preview",
defaultModel: "google/gemini-3-flash-preview",
modelScorer: "google/gemini-3-flash-preview",
modelTailoring: "google/gemini-3-flash-preview",
modelProjectSelection: "google/gemini-3-flash-preview",
llmProvider: "openrouter",
defaultLlmProvider: "openrouter",
llmBaseUrl: "https://openrouter.ai",
defaultLlmBaseUrl: "https://openrouter.ai",
profileProjects: [ profileProjects: [
{ {
id: "proj-1", id: "proj-1",
@ -56,24 +47,6 @@ const baseSettings = createAppSettings({
isVisibleInBase: false, isVisibleInBase: false,
}, },
], ],
resumeProjects: {
maxProjects: 2,
lockedProjectIds: [],
aiSelectableProjectIds: ["proj-1", "proj-2"],
},
defaultResumeProjects: {
maxProjects: 2,
lockedProjectIds: [],
aiSelectableProjectIds: ["proj-1", "proj-2"],
},
jobspyResultsWanted: 200,
defaultJobspyResultsWanted: 200,
jobspyCountryIndeed: "UK",
defaultJobspyCountryIndeed: "UK",
searchCities: "London",
defaultSearchCities: "London",
searchTerms: ["engineer"],
defaultSearchTerms: ["engineer"],
}); });
const renderPage = () => { const renderPage = () => {
@ -103,16 +76,17 @@ describe("SettingsPage", () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings); vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.updateSettings).mockResolvedValue({ vi.mocked(api.updateSettings).mockResolvedValue({
...baseSettings, ...baseSettings,
overrideModel: "gpt-4", model: {
model: "gpt-4", value: "gpt-4",
default: baseSettings.model.default,
override: "gpt-4",
},
}); });
renderPage(); renderPage();
const modelTrigger = await screen.findByRole("button", { name: /model/i });
fireEvent.click(modelTrigger);
const modelInput = screen.getByLabelText(/default model/i); const modelInput = screen.getByLabelText(/default model/i);
await waitFor(() => expect(modelInput).toBeEnabled());
fireEvent.change(modelInput, { target: { value: " gpt-4 " } }); fireEvent.change(modelInput, { target: { value: " gpt-4 " } });
const saveButton = screen.getByRole("button", { name: /^save$/i }); const saveButton = screen.getByRole("button", { name: /^save$/i });
@ -134,10 +108,8 @@ describe("SettingsPage", () => {
renderPage(); renderPage();
const modelTrigger = await screen.findByRole("button", { name: /model/i });
fireEvent.click(modelTrigger);
const modelInput = screen.getByLabelText(/default model/i); const modelInput = screen.getByLabelText(/default model/i);
await waitFor(() => expect(modelInput).toBeEnabled());
// Change to > 200 chars // Change to > 200 chars
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } }); fireEvent.change(modelInput, { target: { value: "a".repeat(201) } });
@ -195,11 +167,12 @@ describe("SettingsPage", () => {
const saveButton = screen.getByRole("button", { name: /^save$/i }); const saveButton = screen.getByRole("button", { name: /^save$/i });
expect(saveButton).toBeDisabled(); expect(saveButton).toBeDisabled();
const modelTrigger = await screen.findByRole("button", { name: /model/i });
fireEvent.click(modelTrigger);
const modelInput = screen.getByLabelText(/default model/i); const modelInput = screen.getByLabelText(/default model/i);
// Wait for the query to resolve and input to be enabled
await waitFor(() => expect(modelInput).toBeEnabled());
fireEvent.change(modelInput, { target: { value: "new-model" } }); fireEvent.change(modelInput, { target: { value: "new-model" } });
expect(saveButton).toBeEnabled(); await waitFor(() => expect(saveButton).toBeEnabled());
}); });
it("hides pipeline tuning sections that moved to run modal", async () => { it("hides pipeline tuning sections that moved to run modal", async () => {

View File

@ -117,22 +117,22 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
}; };
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
model: data.overrideModel ?? "", model: data.model.override ?? "",
modelScorer: data.overrideModelScorer ?? "", modelScorer: data.modelScorer.override ?? "",
modelTailoring: data.overrideModelTailoring ?? "", modelTailoring: data.modelTailoring.override ?? "",
modelProjectSelection: data.overrideModelProjectSelection ?? "", modelProjectSelection: data.modelProjectSelection.override ?? "",
llmProvider: normalizeLlmProviderValue(data.overrideLlmProvider), llmProvider: normalizeLlmProviderValue(data.llmProvider.override),
llmBaseUrl: data.overrideLlmBaseUrl ?? "", llmBaseUrl: data.llmBaseUrl.override ?? "",
llmApiKey: "", llmApiKey: "",
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "", pipelineWebhookUrl: data.pipelineWebhookUrl.override ?? "",
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "", jobCompleteWebhookUrl: data.jobCompleteWebhookUrl.override ?? "",
resumeProjects: data.resumeProjects, resumeProjects: data.resumeProjects.override,
rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null, rxresumeBaseResumeId: data.rxresumeBaseResumeId,
showSponsorInfo: data.overrideShowSponsorInfo, showSponsorInfo: data.showSponsorInfo.override,
chatStyleTone: data.overrideChatStyleTone ?? "", chatStyleTone: data.chatStyleTone.override ?? "",
chatStyleFormality: data.overrideChatStyleFormality ?? "", chatStyleFormality: data.chatStyleFormality.override ?? "",
chatStyleConstraints: data.overrideChatStyleConstraints ?? "", chatStyleConstraints: data.chatStyleConstraints.override ?? "",
chatStyleDoNotUse: data.overrideChatStyleDoNotUse ?? "", chatStyleDoNotUse: data.chatStyleDoNotUse.override ?? "",
rxresumeEmail: data.rxresumeEmail ?? "", rxresumeEmail: data.rxresumeEmail ?? "",
rxresumePassword: "", rxresumePassword: "",
basicAuthUser: data.basicAuthUser ?? "", basicAuthUser: data.basicAuthUser ?? "",
@ -143,12 +143,12 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
adzunaAppKey: "", adzunaAppKey: "",
webhookSecret: "", webhookSecret: "",
enableBasicAuth: data.basicAuthActive, enableBasicAuth: data.basicAuthActive,
backupEnabled: data.overrideBackupEnabled, backupEnabled: data.backupEnabled.override,
backupHour: data.overrideBackupHour, backupHour: data.backupHour.override,
backupMaxCount: data.overrideBackupMaxCount, backupMaxCount: data.backupMaxCount.override,
penalizeMissingSalary: data.overridePenalizeMissingSalary, penalizeMissingSalary: data.penalizeMissingSalary.override,
missingSalaryPenalty: data.overrideMissingSalaryPenalty, missingSalaryPenalty: data.missingSalaryPenalty.override,
autoSkipScoreThreshold: data.overrideAutoSkipScoreThreshold, autoSkipScoreThreshold: data.autoSkipScoreThreshold.override,
}); });
const normalizeString = (value: string | null | undefined) => { const normalizeString = (value: string | null | undefined) => {
@ -204,43 +204,43 @@ const getDerivedSettings = (settings: AppSettings | null) => {
return { return {
model: { model: {
effective: settings?.model ?? "", effective: settings?.model?.value ?? "",
default: settings?.defaultModel ?? "", default: settings?.model?.default ?? "",
scorer: settings?.modelScorer ?? "", scorer: settings?.modelScorer?.value ?? "",
tailoring: settings?.modelTailoring ?? "", tailoring: settings?.modelTailoring?.value ?? "",
projectSelection: settings?.modelProjectSelection ?? "", projectSelection: settings?.modelProjectSelection?.value ?? "",
llmProvider: settings?.llmProvider ?? "", llmProvider: settings?.llmProvider?.value ?? "",
llmBaseUrl: settings?.llmBaseUrl ?? "", llmBaseUrl: settings?.llmBaseUrl?.value ?? "",
llmApiKeyHint: settings?.llmApiKeyHint ?? null, llmApiKeyHint: settings?.llmApiKeyHint ?? null,
}, },
pipelineWebhook: { pipelineWebhook: {
effective: settings?.pipelineWebhookUrl ?? "", effective: settings?.pipelineWebhookUrl?.value ?? "",
default: settings?.defaultPipelineWebhookUrl ?? "", default: settings?.pipelineWebhookUrl?.default ?? "",
}, },
jobCompleteWebhook: { jobCompleteWebhook: {
effective: settings?.jobCompleteWebhookUrl ?? "", effective: settings?.jobCompleteWebhookUrl?.value ?? "",
default: settings?.defaultJobCompleteWebhookUrl ?? "", default: settings?.jobCompleteWebhookUrl?.default ?? "",
}, },
display: { display: {
effective: settings?.showSponsorInfo ?? true, effective: settings?.showSponsorInfo?.value ?? true,
default: settings?.defaultShowSponsorInfo ?? true, default: settings?.showSponsorInfo?.default ?? true,
}, },
chat: { chat: {
tone: { tone: {
effective: settings?.chatStyleTone ?? "professional", effective: settings?.chatStyleTone?.value ?? "professional",
default: settings?.defaultChatStyleTone ?? "professional", default: settings?.chatStyleTone?.default ?? "professional",
}, },
formality: { formality: {
effective: settings?.chatStyleFormality ?? "medium", effective: settings?.chatStyleFormality?.value ?? "medium",
default: settings?.defaultChatStyleFormality ?? "medium", default: settings?.chatStyleFormality?.default ?? "medium",
}, },
constraints: { constraints: {
effective: settings?.chatStyleConstraints ?? "", effective: settings?.chatStyleConstraints?.value ?? "",
default: settings?.defaultChatStyleConstraints ?? "", default: settings?.chatStyleConstraints?.default ?? "",
}, },
doNotUse: { doNotUse: {
effective: settings?.chatStyleDoNotUse ?? "", effective: settings?.chatStyleDoNotUse?.value ?? "",
default: settings?.defaultChatStyleDoNotUse ?? "", default: settings?.chatStyleDoNotUse?.default ?? "",
}, },
}, },
envSettings: { envSettings: {
@ -259,37 +259,37 @@ const getDerivedSettings = (settings: AppSettings | null) => {
}, },
basicAuthActive: settings?.basicAuthActive ?? false, basicAuthActive: settings?.basicAuthActive ?? false,
}, },
defaultResumeProjects: settings?.defaultResumeProjects ?? null, defaultResumeProjects: settings?.resumeProjects?.default ?? null,
profileProjects, profileProjects,
maxProjectsTotal: profileProjects.length, maxProjectsTotal: profileProjects.length,
backup: { backup: {
backupEnabled: { backupEnabled: {
effective: settings?.backupEnabled ?? false, effective: settings?.backupEnabled?.value ?? false,
default: settings?.defaultBackupEnabled ?? false, default: settings?.backupEnabled?.default ?? false,
}, },
backupHour: { backupHour: {
effective: settings?.backupHour ?? 2, effective: settings?.backupHour?.value ?? 2,
default: settings?.defaultBackupHour ?? 2, default: settings?.backupHour?.default ?? 2,
}, },
backupMaxCount: { backupMaxCount: {
effective: settings?.backupMaxCount ?? 5, effective: settings?.backupMaxCount?.value ?? 5,
default: settings?.defaultBackupMaxCount ?? 5, default: settings?.backupMaxCount?.default ?? 5,
}, },
}, },
scoring: { scoring: {
penalizeMissingSalary: { penalizeMissingSalary: {
effective: settings?.penalizeMissingSalary ?? false, effective: settings?.penalizeMissingSalary?.value ?? false,
default: settings?.defaultPenalizeMissingSalary ?? false, default: settings?.penalizeMissingSalary?.default ?? false,
}, },
missingSalaryPenalty: { missingSalaryPenalty: {
effective: settings?.missingSalaryPenalty ?? 10, effective: settings?.missingSalaryPenalty?.value ?? 10,
default: settings?.defaultMissingSalaryPenalty ?? 10, default: settings?.missingSalaryPenalty?.default ?? 10,
}, },
autoSkipScoreThreshold: { autoSkipScoreThreshold: {
effective: settings?.autoSkipScoreThreshold ?? null, effective: settings?.autoSkipScoreThreshold?.value ?? null,
default: settings?.defaultAutoSkipScoreThreshold ?? null, default: settings?.autoSkipScoreThreshold?.default ?? null,
}, },
}, },
}; };
@ -759,7 +759,11 @@ export const SettingsPage: React.FC = () => {
/> />
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12"> <main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
<Accordion type="multiple" className="w-full space-y-4"> <Accordion
type="multiple"
className="w-full space-y-4"
defaultValue={["model", "feature", "webhooks", "chat"]}
>
<ModelSettingsSection <ModelSettingsSection
values={model} values={model}
isLoading={isLoading} isLoading={isLoading}

View File

@ -23,9 +23,13 @@ describe("AutomaticRunTab", () => {
<AutomaticRunTab <AutomaticRunTab
open open
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer"], searchTerms: {
jobspyCountryIndeed: "us", value: ["backend engineer"],
searchCities: "", default: ["backend engineer"],
override: null,
},
jobspyCountryIndeed: { value: "us", default: "us", override: null },
searchCities: { value: "", default: "", override: null },
})} })}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]} enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]} pipelineSources={["linkedin"]}
@ -46,9 +50,17 @@ describe("AutomaticRunTab", () => {
<AutomaticRunTab <AutomaticRunTab
open open
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer"], searchTerms: {
jobspyCountryIndeed: "usa/ca", value: ["backend engineer"],
searchCities: "", default: ["backend engineer"],
override: null,
},
jobspyCountryIndeed: {
value: "usa/ca",
default: "usa/ca",
override: null,
},
searchCities: { value: "", default: "", override: null },
})} })}
enabledSources={["linkedin"]} enabledSources={["linkedin"]}
pipelineSources={["linkedin"]} pipelineSources={["linkedin"]}
@ -71,9 +83,17 @@ describe("AutomaticRunTab", () => {
<AutomaticRunTab <AutomaticRunTab
open open
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer"], searchTerms: {
jobspyCountryIndeed: "united states", value: ["backend engineer"],
searchCities: "", default: ["backend engineer"],
override: null,
},
jobspyCountryIndeed: {
value: "united states",
default: "united states",
override: null,
},
searchCities: { value: "", default: "", override: null },
})} })}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]} enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]} pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]}
@ -97,9 +117,17 @@ describe("AutomaticRunTab", () => {
<AutomaticRunTab <AutomaticRunTab
open open
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer"], searchTerms: {
jobspyCountryIndeed: "united states", value: ["backend engineer"],
searchCities: "", default: ["backend engineer"],
override: null,
},
jobspyCountryIndeed: {
value: "united states",
default: "united states",
override: null,
},
searchCities: { value: "", default: "", override: null },
})} })}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]} enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]} pipelineSources={["linkedin"]}
@ -124,9 +152,17 @@ describe("AutomaticRunTab", () => {
<AutomaticRunTab <AutomaticRunTab
open open
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer"], searchTerms: {
jobspyCountryIndeed: "japan", value: ["backend engineer"],
searchCities: "", default: ["backend engineer"],
override: null,
},
jobspyCountryIndeed: {
value: "japan",
default: "japan",
override: null,
},
searchCities: { value: "", default: "", override: null },
})} })}
enabledSources={["linkedin", "glassdoor"]} enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]} pipelineSources={["linkedin", "glassdoor"]}
@ -155,9 +191,21 @@ describe("AutomaticRunTab", () => {
<AutomaticRunTab <AutomaticRunTab
open open
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer"], searchTerms: {
jobspyCountryIndeed: "united kingdom", value: ["backend engineer"],
searchCities: "United Kingdom", default: ["backend engineer"],
override: null,
},
jobspyCountryIndeed: {
value: "united kingdom",
default: "united kingdom",
override: null,
},
searchCities: {
value: "United Kingdom",
default: "United Kingdom",
override: null,
},
})} })}
enabledSources={["linkedin", "glassdoor"]} enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]} pipelineSources={["linkedin", "glassdoor"]}
@ -184,9 +232,17 @@ describe("AutomaticRunTab", () => {
<AutomaticRunTab <AutomaticRunTab
open open
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer", "frontend engineer"], searchTerms: {
jobspyCountryIndeed: "united kingdom", value: ["backend engineer", "frontend engineer"],
searchCities: "", default: ["backend engineer", "frontend engineer"],
override: null,
},
jobspyCountryIndeed: {
value: "united kingdom",
default: "united kingdom",
override: null,
},
searchCities: { value: "", default: "", override: null },
})} })}
enabledSources={["linkedin"]} enabledSources={["linkedin"]}
pipelineSources={["linkedin"]} pipelineSources={["linkedin"]}
@ -214,9 +270,21 @@ describe("AutomaticRunTab", () => {
<AutomaticRunTab <AutomaticRunTab
open open
settings={createAppSettings({ settings={createAppSettings({
searchTerms: ["backend engineer"], searchTerms: {
jobspyCountryIndeed: "united kingdom", value: ["backend engineer"],
searchCities: "London|Manchester", default: ["backend engineer"],
override: null,
},
jobspyCountryIndeed: {
value: "united kingdom",
default: "united kingdom",
override: null,
},
searchCities: {
value: "London|Manchester",
default: "London|Manchester",
override: null,
},
})} })}
enabledSources={["linkedin", "glassdoor"]} enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]} pipelineSources={["linkedin", "glassdoor"]}

View File

@ -180,19 +180,19 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
memory?.minSuitabilityScore ?? DEFAULT_VALUES.minSuitabilityScore; memory?.minSuitabilityScore ?? DEFAULT_VALUES.minSuitabilityScore;
const rememberedRunBudget = const rememberedRunBudget =
settings?.jobspyResultsWanted ?? settings?.jobspyResultsWanted?.value ??
settings?.adzunaMaxJobsPerTerm ?? settings?.adzunaMaxJobsPerTerm?.value ??
settings?.gradcrackerMaxJobsPerTerm ?? settings?.gradcrackerMaxJobsPerTerm?.value ??
settings?.ukvisajobsMaxJobs ?? settings?.ukvisajobsMaxJobs?.value ??
DEFAULT_VALUES.runBudget; DEFAULT_VALUES.runBudget;
const rememberedCountry = normalizeUiCountryKey( const rememberedCountry = normalizeUiCountryKey(
settings?.jobspyCountryIndeed ?? settings?.jobspyCountryIndeed?.value ??
settings?.searchCities ?? settings?.searchCities?.value ??
DEFAULT_VALUES.country, DEFAULT_VALUES.country,
); );
const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country; const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country;
const rememberedLocations = parseCityLocationsSetting( const rememberedLocations = parseCityLocationsSetting(
settings?.searchCities, settings?.searchCities?.value,
).filter( ).filter(
(location) => (location) =>
normalizeCountryKey(location) !== normalizeCountryKey(location) !==
@ -206,7 +206,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
country: rememberedCountry || DEFAULT_VALUES.country, country: rememberedCountry || DEFAULT_VALUES.country,
cityLocations: rememberedLocations, cityLocations: rememberedLocations,
cityLocationDraft: "", cityLocationDraft: "",
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms, searchTerms: settings?.searchTerms?.value ?? DEFAULT_VALUES.searchTerms,
searchTermDraft: "", searchTermDraft: "",
}); });
setAdvancedOpen(false); setAdvancedOpen(false);

View File

@ -0,0 +1,282 @@
import * as api from "@client/api";
import {
useMarkAsAppliedMutation,
useSkipJobMutation,
} from "@client/hooks/queries/useJobMutations";
import { useHotkeys } from "@client/hooks/useHotkeys";
import { useProfile } from "@client/hooks/useProfile";
import { SHORTCUTS } from "@client/lib/shortcut-map";
import type { JobAction, JobListItem } from "@shared/types.js";
import { useCallback, useRef } from "react";
import { toast } from "sonner";
import { safeFilenamePart } from "@/lib/utils";
import type { FilterTab } from "./constants";
import { tabs } from "./constants";
type UseKeyboardShortcutsArgs = {
isAnyModalOpen: boolean;
isAnyModalOpenExcludingCommandBar: boolean;
isAnyModalOpenExcludingHelp: boolean;
activeTab: FilterTab;
activeJobs: JobListItem[];
selectedJobId: string | null;
selectedJob: JobListItem | null;
selectedJobIds: Set<string>;
isDesktop: boolean;
handleSelectJobId: (id: string | null) => void;
requestScrollToJob: (id: string, opts?: { ensureSelected?: boolean }) => void;
setActiveTab: (tab: FilterTab) => void;
setIsCommandBarOpen: (open: boolean) => void;
setIsHelpDialogOpen: (updater: (prev: boolean) => boolean) => void;
clearSelection: () => void;
toggleSelectJob: (id: string) => void;
runJobAction: (action: JobAction) => Promise<void>;
loadJobs: () => Promise<void>;
};
export function useKeyboardShortcuts(args: UseKeyboardShortcutsArgs): void {
const {
isAnyModalOpen,
isAnyModalOpenExcludingCommandBar,
isAnyModalOpenExcludingHelp,
activeTab,
activeJobs,
selectedJobId,
selectedJob,
selectedJobIds,
isDesktop: _isDesktop,
handleSelectJobId,
requestScrollToJob,
setActiveTab,
setIsCommandBarOpen,
setIsHelpDialogOpen,
clearSelection,
toggleSelectJob,
runJobAction,
loadJobs,
} = args;
const shortcutActionInFlight = useRef(false);
const markAsAppliedMutation = useMarkAsAppliedMutation();
const skipJobMutation = useSkipJobMutation();
const { personName } = useProfile();
const navigateJobList = useCallback(
(direction: 1 | -1) => {
if (activeJobs.length === 0) return;
const currentIndex = selectedJobId
? activeJobs.findIndex((j) => j.id === selectedJobId)
: -1;
const nextIndex = Math.max(
0,
Math.min(activeJobs.length - 1, currentIndex + direction),
);
const nextJob = activeJobs[nextIndex];
if (nextJob && nextJob.id !== selectedJobId) {
handleSelectJobId(nextJob.id);
requestScrollToJob(nextJob.id);
}
},
[activeJobs, selectedJobId, handleSelectJobId, requestScrollToJob],
);
const navigateTab = useCallback(
(direction: 1 | -1) => {
const currentIndex = tabs.findIndex((t) => t.id === activeTab);
const nextIndex = (currentIndex + direction + tabs.length) % tabs.length;
setActiveTab(tabs[nextIndex].id);
},
[activeTab, setActiveTab],
);
const selectNextAfterAction = useCallback(
(movedJobId: string) => {
const idx = activeJobs.findIndex((j) => j.id === movedJobId);
const next = activeJobs[idx + 1] || activeJobs[idx - 1];
handleSelectJobId(next?.id ?? null);
},
[activeJobs, handleSelectJobId],
);
useHotkeys(
{
// ── Navigation ──────────────────────────────────────────────────────
[SHORTCUTS.nextJob.key]: (e) => {
e.preventDefault();
navigateJobList(1);
},
[SHORTCUTS.nextJobArrow.key]: (e) => {
e.preventDefault();
navigateJobList(1);
},
[SHORTCUTS.prevJob.key]: (e) => {
e.preventDefault();
navigateJobList(-1);
},
[SHORTCUTS.prevJobArrow.key]: (e) => {
e.preventDefault();
navigateJobList(-1);
},
// ── Tab switching ───────────────────────────────────────────────────
[SHORTCUTS.tabReady.key]: () => setActiveTab("ready"),
[SHORTCUTS.tabDiscovered.key]: () => setActiveTab("discovered"),
[SHORTCUTS.tabApplied.key]: () => setActiveTab("applied"),
[SHORTCUTS.tabAll.key]: () => setActiveTab("all"),
[SHORTCUTS.prevTabArrow.key]: (e) => {
e.preventDefault();
navigateTab(-1);
},
[SHORTCUTS.nextTabArrow.key]: (e) => {
e.preventDefault();
navigateTab(1);
},
// ── Context actions ─────────────────────────────────────────────────
[SHORTCUTS.skip.key]: () => {
if (!["discovered", "ready"].includes(activeTab)) return;
if (shortcutActionInFlight.current) return;
if (selectedJobIds.size > 0) {
void runJobAction("skip");
return;
}
if (!selectedJob) return;
shortcutActionInFlight.current = true;
const jobId = selectedJob.id;
skipJobMutation
.mutateAsync(jobId)
.then(async () => {
toast.message("Job skipped");
selectNextAfterAction(jobId);
await loadJobs();
})
.catch((err: unknown) => {
const msg =
err instanceof Error ? err.message : "Failed to skip job";
toast.error(msg);
})
.finally(() => {
shortcutActionInFlight.current = false;
});
},
[SHORTCUTS.markApplied.key]: () => {
if (!selectedJob) return;
if (activeTab !== "ready") return;
if (shortcutActionInFlight.current) return;
shortcutActionInFlight.current = true;
const jobId = selectedJob.id;
markAsAppliedMutation
.mutateAsync(jobId)
.then(async () => {
toast.success("Marked as applied", {
description: `${selectedJob.title} at ${selectedJob.employer}`,
});
selectNextAfterAction(jobId);
await loadJobs();
})
.catch((err: unknown) => {
const msg =
err instanceof Error ? err.message : "Failed to mark as applied";
toast.error(msg);
})
.finally(() => {
shortcutActionInFlight.current = false;
});
},
[SHORTCUTS.moveToReady.key]: () => {
if (activeTab !== "discovered") return;
if (shortcutActionInFlight.current) return;
if (selectedJobIds.size > 0) {
void runJobAction("move_to_ready");
return;
}
if (!selectedJob) return;
shortcutActionInFlight.current = true;
const jobId = selectedJob.id;
toast.message("Moving job to Ready...");
api
.processJob(jobId)
.then(async () => {
toast.success("Job moved to Ready", {
description: "Your tailored PDF has been generated.",
});
selectNextAfterAction(jobId);
await loadJobs();
})
.catch((err: unknown) => {
const msg =
err instanceof Error
? err.message
: "Failed to move job to ready";
toast.error(msg);
})
.finally(() => {
shortcutActionInFlight.current = false;
});
},
[SHORTCUTS.viewPdf.key]: () => {
if (!selectedJob) return;
if (activeTab !== "ready") return;
const href = `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`;
window.open(href, "_blank", "noopener,noreferrer");
},
[SHORTCUTS.downloadPdf.key]: () => {
if (!selectedJob) return;
if (activeTab !== "ready") return;
const href = `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`;
const a = document.createElement("a");
a.href = href;
a.download = `${safeFilenamePart(personName || "Unknown")}_${safeFilenamePart(selectedJob.employer)}.pdf`;
a.click();
},
[SHORTCUTS.openListing.key]: () => {
if (!selectedJob) return;
const link = selectedJob.applicationLink || selectedJob.jobUrl;
if (link) window.open(link, "_blank", "noopener,noreferrer");
},
[SHORTCUTS.toggleSelect.key]: () => {
if (!selectedJobId) return;
toggleSelectJob(selectedJobId);
},
[SHORTCUTS.clearSelection.key]: () => {
if (selectedJobIds.size > 0) clearSelection();
},
},
{ enabled: !isAnyModalOpen },
);
useHotkeys(
{
// ── Search ──────────────────────────────────────────────────────────
[SHORTCUTS.searchSlash.key]: (e) => {
e.preventDefault();
setIsCommandBarOpen(true);
},
},
{ enabled: !isAnyModalOpenExcludingCommandBar },
);
useHotkeys(
{
// ── Help ────────────────────────────────────────────────────────────
[SHORTCUTS.help.key]: (e) => {
e.preventDefault();
setIsHelpDialogOpen((prev) => !prev);
},
},
{ enabled: !isAnyModalOpenExcludingHelp },
);
}

View File

@ -0,0 +1,196 @@
import * as api from "@client/api";
import { useSettings } from "@client/hooks/useSettings";
import {
formatCountryLabel,
getCompatibleSourcesForCountry,
} from "@shared/location-support.js";
import type { AppSettings, JobSource } from "@shared/types.js";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import type { AutomaticRunValues } from "./automatic-run";
import {
deriveExtractorLimits,
serializeCityLocationsSetting,
} from "./automatic-run";
import type { RunMode } from "./run-mode";
type UsePipelineControlsArgs = {
isPipelineRunning: boolean;
setIsPipelineRunning: (value: boolean) => void;
pipelineTerminalEvent: { status: string; errorMessage: string | null } | null;
pipelineSources: JobSource[];
loadJobs: () => Promise<void>;
navigateWithContext: (
newTab: string,
newJobId?: string | null,
isReplace?: boolean,
) => void;
};
export type UsePipelineControlsResult = {
isRunModeModalOpen: boolean;
setIsRunModeModalOpen: (open: boolean) => void;
runMode: RunMode;
setRunMode: (mode: RunMode) => void;
isCancelling: boolean;
openRunMode: (mode: RunMode) => void;
handleCancelPipeline: () => Promise<void>;
handleSaveAndRunAutomatic: (values: AutomaticRunValues) => Promise<void>;
handleManualImported: (importedJobId: string) => Promise<void>;
refreshSettings: () => Promise<AppSettings | null>;
};
export function usePipelineControls(
args: UsePipelineControlsArgs,
): UsePipelineControlsResult {
const {
isPipelineRunning,
setIsPipelineRunning,
pipelineTerminalEvent,
pipelineSources,
loadJobs,
navigateWithContext,
} = args;
const [isRunModeModalOpen, setIsRunModeModalOpen] = useState(false);
const [runMode, setRunMode] = useState<RunMode>("automatic");
const [isCancelling, setIsCancelling] = useState(false);
const { refreshSettings } = useSettings();
useEffect(() => {
if (!pipelineTerminalEvent) return;
setIsPipelineRunning(false);
setIsCancelling(false);
if (pipelineTerminalEvent.status === "cancelled") {
toast.message("Pipeline cancelled");
return;
}
if (pipelineTerminalEvent.status === "failed") {
toast.error(pipelineTerminalEvent.errorMessage || "Pipeline failed");
return;
}
toast.success("Pipeline completed");
}, [pipelineTerminalEvent, setIsPipelineRunning]);
const openRunMode = useCallback((mode: RunMode) => {
setRunMode(mode);
setIsRunModeModalOpen(true);
}, []);
const startPipelineRun = useCallback(
async (config: {
topN: number;
minSuitabilityScore: number;
sources: JobSource[];
}) => {
try {
setIsPipelineRunning(true);
setIsCancelling(false);
await api.runPipeline(config);
toast.message("Pipeline started", {
description: `Sources: ${config.sources.join(", ")}. This may take a few minutes.`,
});
} catch (error) {
setIsPipelineRunning(false);
setIsCancelling(false);
const message =
error instanceof Error ? error.message : "Failed to start pipeline";
toast.error(message);
}
},
[setIsPipelineRunning],
);
const handleCancelPipeline = useCallback(async () => {
if (isCancelling || !isPipelineRunning) return;
try {
setIsCancelling(true);
const result = await api.cancelPipeline();
toast.message(result.message);
} catch (error) {
setIsCancelling(false);
const message =
error instanceof Error ? error.message : "Failed to cancel pipeline";
toast.error(message);
}
}, [isCancelling, isPipelineRunning]);
const handleSaveAndRunAutomatic = useCallback(
async (values: AutomaticRunValues) => {
const compatibleSources = getCompatibleSourcesForCountry(
pipelineSources,
values.country,
);
if (compatibleSources.length === 0) {
toast.error(
"No compatible sources for the selected country. Choose another country or source.",
);
return;
}
const limits = deriveExtractorLimits({
budget: values.runBudget,
searchTerms: values.searchTerms,
sources: compatibleSources,
});
const hasJobSpySite = compatibleSources.some(
(source) =>
source === "indeed" ||
source === "linkedin" ||
source === "glassdoor",
);
const hasAdzuna = compatibleSources.includes("adzuna");
const hasHiringCafe = compatibleSources.includes("hiringcafe");
const serializedCities = serializeCityLocationsSetting(
values.cityLocations,
);
const searchCities =
(hasJobSpySite || hasAdzuna || hasHiringCafe) && serializedCities
? serializedCities
: formatCountryLabel(values.country);
await api.updateSettings({
searchTerms: values.searchTerms,
jobspyResultsWanted: limits.jobspyResultsWanted,
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
adzunaMaxJobsPerTerm: limits.adzunaMaxJobsPerTerm,
jobspyCountryIndeed: values.country,
searchCities,
});
await refreshSettings();
await startPipelineRun({
topN: values.topN,
minSuitabilityScore: values.minSuitabilityScore,
sources: compatibleSources,
});
setIsRunModeModalOpen(false);
},
[pipelineSources, refreshSettings, startPipelineRun],
);
const handleManualImported = useCallback(
async (importedJobId: string) => {
await loadJobs();
navigateWithContext("ready", importedJobId);
},
[loadJobs, navigateWithContext],
);
return {
isRunModeModalOpen,
setIsRunModeModalOpen,
runMode,
setRunMode,
isCancelling,
openRunMode,
handleCancelPipeline,
handleSaveAndRunAutomatic,
handleManualImported,
refreshSettings,
};
}

View File

@ -1,7 +1,7 @@
import { okWithMeta } from "@infra/http"; import { okWithMeta } from "@infra/http";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { getSetting } from "@server/repositories/settings"; import { getSetting } from "@server/repositories/settings";
import { LlmService } from "@server/services/llm-service"; import { LlmService } from "@server/services/llm/service";
import { RxResumeClient } from "@server/services/rxresume-client"; import { RxResumeClient } from "@server/services/rxresume-client";
import { import {
getResume, getResume,

View File

@ -25,8 +25,8 @@ describe.sequential("Settings API routes", () => {
const res = await fetch(`${baseUrl}/api/settings`); const res = await fetch(`${baseUrl}/api/settings`);
const body = await res.json(); const body = await res.json();
expect(body.ok).toBe(true); expect(body.ok).toBe(true);
expect(body.data.defaultModel).toBe("test-model"); expect(body.data.model.default).toBe("test-model");
expect(Array.isArray(body.data.searchTerms)).toBe(true); expect(Array.isArray(body.data.searchTerms.value)).toBe(true);
expect(body.data.rxresumeEmail).toBe("resume@example.com"); expect(body.data.rxresumeEmail).toBe("resume@example.com");
expect(body.data.llmApiKeyHint).toBe("secr"); expect(body.data.llmApiKeyHint).toBe("secr");
expect(body.data.basicAuthActive).toBe(false); expect(body.data.basicAuthActive).toBe(false);
@ -51,8 +51,8 @@ describe.sequential("Settings API routes", () => {
}); });
const patchBody = await patchRes.json(); const patchBody = await patchRes.json();
expect(patchBody.ok).toBe(true); expect(patchBody.ok).toBe(true);
expect(patchBody.data.searchTerms).toEqual(["engineer"]); expect(patchBody.data.searchTerms.value).toEqual(["engineer"]);
expect(patchBody.data.overrideSearchTerms).toEqual(["engineer"]); expect(patchBody.data.searchTerms.override).toEqual(["engineer"]);
expect(patchBody.data.rxresumeEmail).toBe("updated@example.com"); expect(patchBody.data.rxresumeEmail).toBe("updated@example.com");
expect(patchBody.data.llmApiKeyHint).toBe("upda"); expect(patchBody.data.llmApiKeyHint).toBe("upda");
}); });
@ -77,8 +77,8 @@ describe.sequential("Settings API routes", () => {
const initialRes = await fetch(`${baseUrl}/api/settings`); const initialRes = await fetch(`${baseUrl}/api/settings`);
const initialBody = await initialRes.json(); const initialBody = await initialRes.json();
expect(initialBody.ok).toBe(true); expect(initialBody.ok).toBe(true);
expect(initialBody.data.penalizeMissingSalary).toBe(false); expect(initialBody.data.penalizeMissingSalary.value).toBe(false);
expect(initialBody.data.missingSalaryPenalty).toBe(10); expect(initialBody.data.missingSalaryPenalty.value).toBe(10);
// Test invalid penalty values // Test invalid penalty values
const invalidRes = await fetch(`${baseUrl}/api/settings`, { const invalidRes = await fetch(`${baseUrl}/api/settings`, {
@ -106,16 +106,16 @@ describe.sequential("Settings API routes", () => {
}); });
const validBody = await validRes.json(); const validBody = await validRes.json();
expect(validBody.ok).toBe(true); expect(validBody.ok).toBe(true);
expect(validBody.data.penalizeMissingSalary).toBe(true); expect(validBody.data.penalizeMissingSalary.value).toBe(true);
expect(validBody.data.overridePenalizeMissingSalary).toBe(true); expect(validBody.data.penalizeMissingSalary.override).toBe(true);
expect(validBody.data.missingSalaryPenalty).toBe(20); expect(validBody.data.missingSalaryPenalty.value).toBe(20);
expect(validBody.data.overrideMissingSalaryPenalty).toBe(20); expect(validBody.data.missingSalaryPenalty.override).toBe(20);
// Verify persistence // Verify persistence
const getRes = await fetch(`${baseUrl}/api/settings`); const getRes = await fetch(`${baseUrl}/api/settings`);
const getBody = await getRes.json(); const getBody = await getRes.json();
expect(getBody.ok).toBe(true); expect(getBody.ok).toBe(true);
expect(getBody.data.penalizeMissingSalary).toBe(true); expect(getBody.data.penalizeMissingSalary.value).toBe(true);
expect(getBody.data.missingSalaryPenalty).toBe(20); expect(getBody.data.missingSalaryPenalty.value).toBe(20);
}); });
}); });

View File

@ -47,9 +47,9 @@ settingsRouter.patch("/", async (req: Request, res: Response) => {
if (plan.shouldRefreshBackupScheduler) { if (plan.shouldRefreshBackupScheduler) {
setBackupSettings({ setBackupSettings({
enabled: data.backupEnabled, enabled: data.backupEnabled.value,
hour: data.backupHour, hour: data.backupHour.value,
maxCount: data.backupMaxCount, maxCount: data.backupMaxCount.value,
}); });
} }
res.json({ success: true, data }); res.json({ success: true, data });

View File

@ -2,51 +2,20 @@
* Settings repository - key/value storage for runtime configuration. * Settings repository - key/value storage for runtime configuration.
*/ */
import type { settingsRegistry } from "@shared/settings-registry";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db, schema } from "../db/index"; import { db, schema } from "../db/index";
const { settings } = schema; const { settings } = schema;
export type SettingKey = export type SettingKey = Exclude<
| "model" {
| "modelScorer" [K in keyof typeof settingsRegistry]: (typeof settingsRegistry)[K]["kind"] extends "virtual"
| "modelTailoring" ? never
| "modelProjectSelection" : K;
| "llmProvider" }[keyof typeof settingsRegistry],
| "llmBaseUrl" undefined
| "llmApiKey" >;
| "pipelineWebhookUrl"
| "jobCompleteWebhookUrl"
| "resumeProjects"
| "rxresumeBaseResumeId"
| "ukvisajobsMaxJobs"
| "adzunaMaxJobsPerTerm"
| "gradcrackerMaxJobsPerTerm"
| "searchTerms"
| "searchCities"
| "jobspyLocation"
| "jobspyResultsWanted"
| "jobspyCountryIndeed"
| "showSponsorInfo"
| "chatStyleTone"
| "chatStyleFormality"
| "chatStyleConstraints"
| "chatStyleDoNotUse"
| "rxresumeEmail"
| "rxresumePassword"
| "basicAuthUser"
| "basicAuthPassword"
| "ukvisajobsEmail"
| "ukvisajobsPassword"
| "adzunaAppId"
| "adzunaAppKey"
| "webhookSecret"
| "backupEnabled"
| "backupHour"
| "backupMaxCount"
| "penalizeMissingSalary"
| "missingSalaryPenalty"
| "autoSkipScoreThreshold";
export async function getSetting(key: SettingKey): Promise<string | null> { export async function getSetting(key: SettingKey): Promise<string | null> {
const [row] = await db.select().from(settings).where(eq(settings.key, key)); const [row] = await db.select().from(settings).where(eq(settings.key, key));

View File

@ -1,60 +1,10 @@
import type { SettingKey } from "@server/repositories/settings"; import type { SettingKey } from "@server/repositories/settings";
import * as settingsRepo from "@server/repositories/settings"; import * as settingsRepo from "@server/repositories/settings";
import { settingsRegistry } from "@shared/settings-registry";
import type { AppSettings } from "@shared/types";
const envDefaults: Record<string, string | undefined> = { ...process.env }; const envDefaults: Record<string, string | undefined> = { ...process.env };
const readableStringConfig: { settingKey: SettingKey; envKey: string }[] = [
{ settingKey: "llmProvider", envKey: "LLM_PROVIDER" },
{ settingKey: "llmBaseUrl", envKey: "LLM_BASE_URL" },
{ settingKey: "rxresumeEmail", envKey: "RXRESUME_EMAIL" },
{ settingKey: "ukvisajobsEmail", envKey: "UKVISAJOBS_EMAIL" },
{ settingKey: "adzunaAppId", envKey: "ADZUNA_APP_ID" },
{ settingKey: "basicAuthUser", envKey: "BASIC_AUTH_USER" },
];
const readableBooleanConfig: {
settingKey: SettingKey;
envKey: string;
defaultValue: boolean;
}[] = [];
const privateStringConfig: {
settingKey: SettingKey;
envKey: string;
hintKey: string;
}[] = [
{
settingKey: "llmApiKey",
envKey: "LLM_API_KEY",
hintKey: "llmApiKeyHint",
},
{
settingKey: "rxresumePassword",
envKey: "RXRESUME_PASSWORD",
hintKey: "rxresumePasswordHint",
},
{
settingKey: "ukvisajobsPassword",
envKey: "UKVISAJOBS_PASSWORD",
hintKey: "ukvisajobsPasswordHint",
},
{
settingKey: "adzunaAppKey",
envKey: "ADZUNA_APP_KEY",
hintKey: "adzunaAppKeyHint",
},
{
settingKey: "basicAuthPassword",
envKey: "BASIC_AUTH_PASSWORD",
hintKey: "basicAuthPasswordHint",
},
{
settingKey: "webhookSecret",
envKey: "WEBHOOK_SECRET",
hintKey: "webhookSecretHint",
},
];
export function normalizeEnvInput( export function normalizeEnvInput(
value: string | null | undefined, value: string | null | undefined,
): string | null { ): string | null {
@ -62,15 +12,6 @@ export function normalizeEnvInput(
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
} }
function parseEnvBoolean(
raw: string | null | undefined,
defaultValue: boolean,
): boolean {
if (raw === undefined || raw === null || raw === "") return defaultValue;
if (raw === "false" || raw === "0") return false;
return true;
}
export function applyEnvValue(envKey: string, value: string | null): void { export function applyEnvValue(envKey: string, value: string | null): void {
if (value === null) { if (value === null) {
const fallback = envDefaults[envKey]; const fallback = envDefaults[envKey];
@ -85,77 +26,57 @@ export function applyEnvValue(envKey: string, value: string | null): void {
process.env[envKey] = value; process.env[envKey] = value;
} }
export function serializeEnvBoolean(value: boolean | null): string | null {
if (value === null) return null;
return value ? "true" : "false";
}
export async function applyStoredEnvOverrides(): Promise<void> { export async function applyStoredEnvOverrides(): Promise<void> {
const safeGetSetting = async (key: SettingKey): Promise<string | null> => { const safeGetSetting = async (key: SettingKey): Promise<string | null> => {
try { try {
return await settingsRepo.getSetting(key); return await settingsRepo.getSetting(key);
} catch (error) { } catch (error) {
// In some test harnesses or first-boot scenarios, the DB may exist but not yet
// have the settings table. Treat this as "no overrides".
const msg = String((error as Error)?.message ?? error); const msg = String((error as Error)?.message ?? error);
if (msg.includes("no such table") && msg.includes("settings")) if (msg.includes("no such table") && msg.includes("settings")) {
return null; return null;
}
throw error; throw error;
} }
}; };
await Promise.all([ const tasks = Object.entries(settingsRegistry).map(async ([key, def]) => {
...readableStringConfig.map(async ({ settingKey, envKey }) => { if (!("envKey" in def) || !def.envKey) return;
const override = await safeGetSetting(settingKey); const override = await safeGetSetting(key as SettingKey);
if (override === null) return; if (override === null) return;
applyEnvValue(envKey, normalizeEnvInput(override)); applyEnvValue(def.envKey, normalizeEnvInput(override));
}), });
...readableBooleanConfig.map(
async ({ settingKey, envKey, defaultValue }) => { await Promise.all(tasks);
const override = await safeGetSetting(settingKey);
if (override === null) return;
const parsed = parseEnvBoolean(override, defaultValue);
applyEnvValue(envKey, serializeEnvBoolean(parsed));
},
),
...privateStringConfig.map(async ({ settingKey, envKey }) => {
const override = await safeGetSetting(settingKey);
if (override === null) return;
applyEnvValue(envKey, normalizeEnvInput(override));
}),
]);
} }
export async function getEnvSettingsData( export async function getEnvSettingsData(
overrides?: Partial<Record<SettingKey, string>>, overrides?: Partial<Record<SettingKey, string>>,
): Promise<Record<string, string | boolean | number | null>> { ): Promise<Partial<AppSettings>> {
const activeOverrides = overrides || (await settingsRepo.getAllSettings()); const activeOverrides = overrides || (await settingsRepo.getAllSettings());
const readableValues: Record<string, string | boolean | null> = {}; const values: Partial<AppSettings> = {};
const privateValues: Record<string, string | null> = {};
for (const { settingKey, envKey } of readableStringConfig) { for (const [key, def] of Object.entries(settingsRegistry)) {
const override = activeOverrides[settingKey] ?? null; if (def.kind === "typed") continue;
const rawValue = override ?? process.env[envKey]; if (!("envKey" in def) || !def.envKey) continue;
readableValues[settingKey] = normalizeEnvInput(rawValue);
}
for (const { settingKey, envKey, defaultValue } of readableBooleanConfig) { const override = activeOverrides[key as SettingKey] ?? null;
const override = activeOverrides[settingKey] ?? null; const rawValue = override ?? process.env[def.envKey];
const rawValue = override ?? process.env[envKey];
readableValues[settingKey] = parseEnvBoolean(rawValue, defaultValue);
}
for (const { settingKey, envKey, hintKey } of privateStringConfig) { if (def.kind === "secret") {
const override = activeOverrides[settingKey] ?? null; const hintKey = `${key}Hint` as keyof AppSettings;
const rawValue = override ?? process.env[envKey];
if (!rawValue) { if (!rawValue) {
privateValues[hintKey] = null; // biome-ignore lint/suspicious/noExplicitAny: explicit partial assignment
(values as any)[hintKey] = null;
continue; continue;
} }
const hintLength = const hintLength =
rawValue.length > 4 ? 4 : Math.max(rawValue.length - 1, 1); rawValue.length > 4 ? 4 : Math.max(rawValue.length - 1, 1);
privateValues[hintKey] = rawValue.slice(0, hintLength); // biome-ignore lint/suspicious/noExplicitAny: explicit partial assignment
(values as any)[hintKey] = rawValue.slice(0, hintLength);
} else {
// biome-ignore lint/suspicious/noExplicitAny: explicit partial assignment
(values as any)[key] = normalizeEnvInput(rawValue);
}
} }
const basicAuthUser = const basicAuthUser =
@ -163,15 +84,7 @@ export async function getEnvSettingsData(
const basicAuthPassword = const basicAuthPassword =
activeOverrides.basicAuthPassword ?? process.env.BASIC_AUTH_PASSWORD; activeOverrides.basicAuthPassword ?? process.env.BASIC_AUTH_PASSWORD;
return { values.basicAuthActive = Boolean(basicAuthUser && basicAuthPassword);
...readableValues,
...privateValues,
basicAuthActive: Boolean(basicAuthUser && basicAuthPassword),
};
}
export const envSettingConfig = { return values;
readableStringConfig, }
readableBooleanConfig,
privateStringConfig,
};

View File

@ -7,36 +7,35 @@ vi.mock("../repositories/jobs", () => ({
getJobById: vi.fn(), getJobById: vi.fn(),
})); }));
vi.mock("../repositories/settings", () => ({ vi.mock("./settings", () => ({
getAllSettings: vi.fn(), getEffectiveSettings: vi.fn(),
})); }));
vi.mock("./profile", () => ({ vi.mock("./profile", () => ({
getProfile: vi.fn(), getProfile: vi.fn(),
})); }));
vi.mock("./settings-conversion", () => ({
resolveSettingValue: vi.fn(),
}));
import { getJobById } from "../repositories/jobs"; import { getJobById } from "../repositories/jobs";
import { getAllSettings } from "../repositories/settings";
import { getProfile } from "./profile"; import { getProfile } from "./profile";
import { resolveSettingValue } from "./settings-conversion"; import { getEffectiveSettings } from "./settings";
describe("buildJobChatPromptContext", () => { describe("buildJobChatPromptContext", () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(getAllSettings).mockResolvedValue({}); vi.mocked(getEffectiveSettings).mockResolvedValue({
vi.mocked(resolveSettingValue).mockImplementation((key, override) => { chatStyleTone: {
const fallback: Record<string, string> = { value: "professional",
chatStyleTone: "professional", default: "professional",
chatStyleFormality: "medium", override: null,
chatStyleConstraints: "", },
chatStyleDoNotUse: "", chatStyleFormality: {
}; value: "medium",
return { value: override ?? fallback[key as string] ?? "" } as any; default: "medium",
}); override: null,
},
chatStyleConstraints: { value: "", default: "", override: null },
chatStyleDoNotUse: { value: "", default: "", override: null },
} as any);
}); });
it("builds context with style directives and snapshots", async () => { it("builds context with style directives and snapshots", async () => {
@ -48,12 +47,28 @@ describe("buildJobChatPromptContext", () => {
}); });
vi.mocked(getJobById).mockResolvedValue(job); vi.mocked(getJobById).mockResolvedValue(job);
vi.mocked(getAllSettings).mockResolvedValue({ vi.mocked(getEffectiveSettings).mockResolvedValue({
chatStyleTone: "direct", chatStyleTone: {
chatStyleFormality: "high", value: "direct",
chatStyleConstraints: "Keep responses under 120 words", default: "professional",
chatStyleDoNotUse: "synergy, leverage", override: "direct",
}); },
chatStyleFormality: {
value: "high",
default: "medium",
override: "high",
},
chatStyleConstraints: {
value: "Keep responses under 120 words",
default: "",
override: "Keep responses under 120 words",
},
chatStyleDoNotUse: {
value: "synergy, leverage",
default: "",
override: "synergy, leverage",
},
} as any);
vi.mocked(getProfile).mockResolvedValue({ vi.mocked(getProfile).mockResolvedValue({
basics: { basics: {
name: "Test User", name: "Test User",

View File

@ -3,9 +3,8 @@ import { sanitizeUnknown } from "@infra/sanitize";
import type { Job, ResumeProfile } from "@shared/types"; import type { Job, ResumeProfile } from "@shared/types";
import { badRequest, notFound } from "../infra/errors"; import { badRequest, notFound } from "../infra/errors";
import * as jobsRepo from "../repositories/jobs"; import * as jobsRepo from "../repositories/jobs";
import * as settingsRepo from "../repositories/settings";
import { getProfile } from "./profile"; import { getProfile } from "./profile";
import { resolveSettingValue } from "./settings-conversion"; import { getEffectiveSettings } from "./settings";
type JobChatStyle = { type JobChatStyle = {
tone: string; tone: string;
@ -119,29 +118,13 @@ function buildSystemPrompt(style: JobChatStyle): string {
} }
async function resolveStyle(): Promise<JobChatStyle> { async function resolveStyle(): Promise<JobChatStyle> {
const overrides = await settingsRepo.getAllSettings(); const settings = await getEffectiveSettings();
const tone = resolveSettingValue(
"chatStyleTone",
overrides.chatStyleTone,
).value;
const formality = resolveSettingValue(
"chatStyleFormality",
overrides.chatStyleFormality,
).value;
const constraints = resolveSettingValue(
"chatStyleConstraints",
overrides.chatStyleConstraints,
).value;
const doNotUse = resolveSettingValue(
"chatStyleDoNotUse",
overrides.chatStyleDoNotUse,
).value;
return { return {
tone, tone: settings.chatStyleTone.value,
formality, formality: settings.chatStyleFormality.value,
constraints, constraints: settings.chatStyleConstraints.value,
doNotUse, doNotUse: settings.chatStyleDoNotUse.value,
}; };
} }

View File

@ -3,11 +3,9 @@
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import { LlmService } from "./llm/service";
type JsonSchemaDefinition, import type { JsonSchemaDefinition } from "./llm/types";
LlmService, import { parseJsonContent } from "./llm/utils/json";
parseJsonContent,
} from "./llm-service";
const originalFetch = global.fetch; const originalFetch = global.fetch;

View File

@ -1,14 +0,0 @@
/**
* Compatibility facade for legacy imports.
* New implementation lives under ./llm/*
*/
export { LlmService } from "./llm/service";
export type {
JsonSchemaDefinition,
LlmProvider,
LlmRequestOptions,
LlmResponse,
LlmValidationResult,
} from "./llm/types";
export { parseJsonContent } from "./llm/utils/json";

View File

@ -5,7 +5,8 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import type { ManualJobDraft } from "@shared/types"; import type { ManualJobDraft } from "@shared/types";
import { getSetting } from "../repositories/settings"; import { getSetting } from "../repositories/settings";
import { type JsonSchemaDefinition, LlmService } from "./llm-service"; import { LlmService } from "./llm/service";
import type { JsonSchemaDefinition } from "./llm/types";
export interface ManualJobInferenceResult { export interface ManualJobInferenceResult {
job: ManualJobDraft; job: ManualJobDraft;

View File

@ -0,0 +1,213 @@
import { getSetting } from "@server/repositories/settings";
import { LlmService } from "@server/services/llm/service";
import type { JsonSchemaDefinition } from "@server/services/llm/types";
import {
messageTypeFromStageTarget,
normalizeStageTarget,
} from "@server/services/post-application/stage-target";
import type {
Job,
PostApplicationMessageType,
PostApplicationRouterStageTarget,
} from "@shared/types";
import { POST_APPLICATION_ROUTER_STAGE_TARGETS } from "@shared/types";
export const ROUTER_EMAIL_CHAR_LIMIT = 12_000;
const SMART_ROUTER_SCHEMA: JsonSchemaDefinition = {
name: "post_application_email_router",
schema: {
type: "object",
properties: {
bestMatchIndex: {
type: ["integer", "null"],
description:
"Best matching active-job index from provided list (1-based), or null.",
},
confidence: {
type: "integer",
description: "Confidence score 0-100 for routing decision.",
},
stageTarget: {
type: "string",
enum: [...POST_APPLICATION_ROUTER_STAGE_TARGETS],
description:
"Normalized stage target for this message, matching Log Event options.",
},
isRelevant: {
type: "boolean",
description:
"Whether this is a relevant recruitment/application email.",
},
stageEventPayload: {
type: ["object", "null"],
description: "Structured metadata for a potential stage event.",
additionalProperties: true,
},
reason: {
type: "string",
description: "One sentence reason for the routing decision.",
},
},
required: [
"bestMatchIndex",
"confidence",
"stageTarget",
"isRelevant",
"stageEventPayload",
"reason",
],
additionalProperties: false,
},
};
export type IndexedActiveJob = {
index: number;
id: string;
company: string;
title: string;
};
export type SmartRouterResult = {
bestMatchId: string | null;
confidence: number;
stageTarget: PostApplicationRouterStageTarget;
messageType: PostApplicationMessageType;
isRelevant: boolean;
stageEventPayload: Record<string, unknown> | null;
reason: string;
};
export function minifyActiveJobs(jobs: Job[]): Array<{
id: string;
company: string;
title: string;
}> {
return jobs.map((job) => ({
id: job.id,
company: job.employer,
title: job.title,
}));
}
function sanitizeJobPromptValue(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
export function buildIndexedActiveJobs(
jobs: Array<{ id: string; company: string; title: string }>,
): IndexedActiveJob[] {
return jobs.map((job, offset) => ({
index: offset + 1,
id: job.id,
company: sanitizeJobPromptValue(job.company || "Unknown company"),
title: sanitizeJobPromptValue(job.title || "Unknown title"),
}));
}
export function buildCompactActiveJobsList(jobs: IndexedActiveJob[]): string {
return jobs
.map((job) => `${job.index}. ${job.company}: ${job.title}`)
.join("\n");
}
export function normalizeBestMatchIndex(
value: unknown,
max: number,
): number | null {
if (value === null || value === undefined || max <= 0) return null;
const numeric =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseInt(value, 10)
: Number.NaN;
if (!Number.isFinite(numeric)) return null;
const rounded = Math.round(numeric);
if (rounded < 1 || rounded > max) return null;
return rounded;
}
export async function classifyWithSmartRouter(args: {
emailText: string;
activeJobs: Array<{ id: string; company: string; title: string }>;
}): Promise<SmartRouterResult> {
const overrideModel = await getSetting("model");
const model =
overrideModel || process.env.MODEL || "google/gemini-3-flash-preview";
const llmEmailText = args.emailText.slice(0, ROUTER_EMAIL_CHAR_LIMIT);
const indexedActiveJobs = buildIndexedActiveJobs(args.activeJobs);
const compactActiveJobsList = buildCompactActiveJobsList(indexedActiveJobs);
const messages = [
{
role: "system" as const,
content:
"You are a smart router for post-application emails. Return only strict JSON. Ignore sensitive data and include only routing fields.",
},
{
role: "user" as const,
content: `Route this email to one active job if possible.
- Choose bestMatchIndex only from listed job numbers (1-based), or null.
- confidence is 0..100.
- stageTarget must be one of: ${POST_APPLICATION_ROUTER_STAGE_TARGETS.join("|")}.
- isRelevant should be true for recruitment/application lifecycle emails.
- stageEventPayload should be minimal structured data or null.
Active jobs (index. company: title):
${compactActiveJobsList}
Email:
${llmEmailText}`,
},
];
const llm = new LlmService();
const result = await llm.callJson<{
bestMatchIndex: number | null;
confidence: number;
stageTarget: string;
isRelevant: boolean;
stageEventPayload: Record<string, unknown> | null;
reason: string;
}>({
model,
messages,
jsonSchema: SMART_ROUTER_SCHEMA,
maxRetries: 1,
retryDelayMs: 400,
});
if (!result.success) {
throw new Error(`LLM classification failed: ${result.error}`);
}
const confidence = Math.max(
0,
Math.min(100, Math.round(Number(result.data.confidence) || 0)),
);
const bestMatchIndex = normalizeBestMatchIndex(
result.data.bestMatchIndex,
indexedActiveJobs.length,
);
const bestMatchId =
bestMatchIndex !== null
? (indexedActiveJobs[bestMatchIndex - 1]?.id ?? null)
: null;
const stageTarget =
normalizeStageTarget(result.data.stageTarget) ?? "no_change";
const messageType = messageTypeFromStageTarget(stageTarget);
return {
bestMatchId,
confidence,
stageTarget,
messageType,
isRelevant: Boolean(result.data.isRelevant),
stageEventPayload:
result.data.stageEventPayload &&
typeof result.data.stageEventPayload === "object"
? result.data.stageEventPayload
: null,
reason: String(result.data.reason ?? "").trim(),
};
}

View File

@ -0,0 +1,417 @@
import { requestTimeout } from "@infra/errors";
import { convert } from "html-to-text";
export const GMAIL_HTTP_TIMEOUT_MS = 15_000;
export type GmailCredentials = {
refreshToken: string;
accessToken?: string;
expiryDate?: number;
scope?: string;
tokenType?: string;
email?: string;
};
export type GmailListMessage = {
id: string;
threadId: string;
};
export type GmailHeader = { name?: string; value?: string };
export type GmailMetadataMessage = {
id: string;
threadId: string;
snippet: string;
headers: GmailHeader[];
};
export type GmailFullMessage = GmailMetadataMessage & {
payload?: {
mimeType?: string;
body?: { data?: string };
parts?: Array<{
mimeType?: string;
body?: { data?: string };
parts?: unknown[];
}>;
};
};
export async function fetchWithTimeout(
url: string,
args: { timeoutMs: number; init: RequestInit },
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), args.timeoutMs);
try {
return await fetch(url, {
...args.init,
signal: controller.signal,
});
} catch (error) {
if (
typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "AbortError"
) {
throw requestTimeout(
`Gmail request timed out after ${args.timeoutMs}ms for ${url}.`,
);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
export async function resolveGmailAccessToken(
credentials: GmailCredentials,
): Promise<GmailCredentials> {
const now = Date.now();
if (
credentials.accessToken &&
credentials.expiryDate &&
credentials.expiryDate > now + 60_000
) {
return credentials;
}
const clientId = asString(process.env.GMAIL_OAUTH_CLIENT_ID);
const clientSecret = asString(process.env.GMAIL_OAUTH_CLIENT_SECRET);
if (!clientId || !clientSecret) {
throw new Error(
"Missing GMAIL_OAUTH_CLIENT_ID or GMAIL_OAUTH_CLIENT_SECRET for Gmail token refresh.",
);
}
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token",
refresh_token: credentials.refreshToken,
});
const response = await fetchWithTimeout(
"https://oauth2.googleapis.com/token",
{
timeoutMs: GMAIL_HTTP_TIMEOUT_MS,
init: {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
},
},
);
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(`Gmail token refresh failed with HTTP ${response.status}.`);
}
const accessToken = asString(data?.access_token);
const expiresIn =
typeof data?.expires_in === "number" && Number.isFinite(data.expires_in)
? data.expires_in
: 3600;
if (!accessToken) {
throw new Error(
"Gmail token refresh response did not include access_token.",
);
}
return {
...credentials,
accessToken,
expiryDate: Date.now() + expiresIn * 1000,
};
}
export async function gmailApi<T>(token: string, url: string): Promise<T> {
const response = await fetchWithTimeout(url, {
timeoutMs: GMAIL_HTTP_TIMEOUT_MS,
init: {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
},
});
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(`Gmail API request failed (${response.status}).`);
}
return data as T;
}
export function buildGmailQuery(searchDays: number): string {
const subjectTerms = [
"application",
"thank you for applying",
"thanks for applying",
"application received",
"application submitted",
"your application",
"interview",
"assessment",
"coding challenge",
"take-home",
"availability",
"offer",
"offer letter",
"referral",
"recruiter",
"hiring team",
"regret to inform",
"not moving forward",
"not selected",
"application unsuccessful",
"moving forward with other candidates",
"unable to proceed",
"position has been filled",
"hiring freeze",
"position on hold",
"withdrawn",
];
const fromTerms = [
"careers@",
"jobs@",
"recruiting@",
"talent@",
"no-reply@greenhouse.io",
"no-reply@us.greenhouse-mail.io",
"no-reply@ashbyhq.com",
"notification@smartrecruiters.com",
"@smartrecruiters.com",
"@workablemail.com",
"@hire.lever.co",
"@myworkday.com",
"@workdaymail.com",
"@greenhouse.io",
"@ashbyhq.com",
];
const excludeSubjectTerms = [
"newsletter",
"webinar",
"course",
"discount",
"event invitation",
"job search council",
"matched new opportunities",
];
const quoteTerm = (value: string) => `"${value.replace(/"/g, '\\"')}"`;
const subjectBlock = subjectTerms
.map((term) => `subject:${quoteTerm(term)}`)
.join(" OR ");
const fromBlock = fromTerms
.map((term) => `from:${quoteTerm(term)}`)
.join(" OR ");
const excludeClauses = excludeSubjectTerms
.map((term) => `-subject:${quoteTerm(term)}`)
.join(" ");
return `newer_than:${searchDays}d ((${subjectBlock}) OR (${fromBlock})) ${excludeClauses}`.trim();
}
export async function listMessageIds(
token: string,
searchDays: number,
maxMessages: number,
): Promise<GmailListMessage[]> {
const messages: GmailListMessage[] = [];
let pageToken: string | undefined;
do {
const q = encodeURIComponent(buildGmailQuery(searchDays));
const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${q}&maxResults=${Math.min(
100,
maxMessages,
)}${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`;
const page = await gmailApi<{
messages?: Array<{ id?: string; threadId?: string }>;
nextPageToken?: string;
}>(token, listUrl);
for (const message of page.messages ?? []) {
if (!message.id || !message.threadId) continue;
messages.push({ id: message.id, threadId: message.threadId });
if (messages.length >= maxMessages) {
return messages;
}
}
pageToken = page.nextPageToken;
} while (pageToken && messages.length < maxMessages);
return messages;
}
export async function getMessageMetadata(
token: string,
messageId: string,
): Promise<GmailMetadataMessage> {
const message = await gmailApi<{
id?: string;
threadId?: string;
snippet?: string;
payload?: { headers?: GmailHeader[] };
}>(
token,
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${encodeURIComponent(
messageId,
)}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date`,
);
return {
id: message.id ?? messageId,
threadId: message.threadId ?? "",
snippet: message.snippet ?? "",
headers: message.payload?.headers ?? [],
};
}
export async function getMessageFull(
token: string,
messageId: string,
): Promise<GmailFullMessage> {
const message = await gmailApi<{
id?: string;
threadId?: string;
snippet?: string;
payload?: GmailFullMessage["payload"];
}>(
token,
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${encodeURIComponent(
messageId,
)}?format=full`,
);
return {
id: message.id ?? messageId,
threadId: message.threadId ?? "",
snippet: message.snippet ?? "",
headers: [],
payload: message.payload,
};
}
function cleanEmailHtmlForLlm(htmlContent: string): string {
return convert(htmlContent, {
wordwrap: 130,
selectors: [
{ selector: "img", format: "skip" },
{ selector: "a", options: { ignoreHref: true } },
{ selector: "style", format: "skip" },
{ selector: "script", format: "skip" },
],
});
}
function normalizeChunkForDedup(value: string): string {
return value.replace(/\s+/g, " ").trim().toLowerCase();
}
function decodeBase64Url(value: string): string {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return Buffer.from(padded, "base64").toString("utf8");
}
function decodeTextPart(
part: NonNullable<GmailFullMessage["payload"]>,
): string {
const data = part.body?.data;
if (!data) return "";
const decoded = decodeBase64Url(data);
const mimeType = String(part.mimeType ?? "").toLowerCase();
if (mimeType.includes("text/html")) {
return cleanEmailHtmlForLlm(decoded);
}
if (mimeType.startsWith("text/")) {
return decoded;
}
return "";
}
export function extractBodyText(payload: GmailFullMessage["payload"]): string {
if (!payload) return "";
const chunks: string[] = [];
const seen = new Set<string>();
const addChunk = (value: string): void => {
const chunk = value.trim();
if (!chunk) return;
const normalized = normalizeChunkForDedup(chunk);
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
chunks.push(chunk);
};
const walk = (part: NonNullable<GmailFullMessage["payload"]>): void => {
const mimeType = String(part.mimeType ?? "").toLowerCase();
if (mimeType === "multipart/alternative") {
const children = (part.parts ?? []) as Array<
NonNullable<GmailFullMessage["payload"]>
>;
const plainChild = children.find(
(child) => String(child.mimeType ?? "").toLowerCase() === "text/plain",
);
const plainText = plainChild ? decodeTextPart(plainChild).trim() : "";
if (plainText.length > 50) {
addChunk(plainText);
return;
}
if (plainText) {
addChunk(plainText);
return;
}
const htmlChild = children.find((child) =>
String(child.mimeType ?? "")
.toLowerCase()
.includes("text/html"),
);
if (htmlChild) {
addChunk(decodeTextPart(htmlChild));
return;
}
}
const chunk = decodeTextPart(part);
if (chunk) {
addChunk(chunk);
}
for (const child of part.parts ?? []) {
walk(child as NonNullable<GmailFullMessage["payload"]>);
}
};
walk(payload);
return chunks.join("\n\n").trim();
}
export function buildEmailText(input: {
from: string;
subject: string;
date: string;
body: string;
}): string {
return `From: ${input.from}
Subject: ${input.subject}
Date: ${input.date}
Body:
${input.body}`.trim();
}
function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}

View File

@ -68,7 +68,7 @@ const llmCallJson = vi.fn().mockResolvedValue({
}, },
}); });
vi.mock("@server/services/llm-service", () => ({ vi.mock("@server/services/llm/service", () => ({
LlmService: class { LlmService: class {
callJson() { callJson() {
return llmCallJson(); return llmCallJson();

View File

@ -1,6 +1,7 @@
import type { AppError } from "@infra/errors"; import type { AppError } from "@infra/errors";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __test__, gmailApi, resolveGmailAccessToken } from "./gmail-sync"; import { gmailApi, resolveGmailAccessToken } from "./gmail-api";
import { __test__ } from "./gmail-sync";
describe("gmail sync http behavior", () => { describe("gmail sync http behavior", () => {
const originalClientId = process.env.GMAIL_OAUTH_CLIENT_ID; const originalClientId = process.env.GMAIL_OAUTH_CLIENT_ID;

View File

@ -1,4 +1,3 @@
import { requestTimeout } from "@infra/errors";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { getAllJobs } from "@server/repositories/jobs"; import { getAllJobs } from "@server/repositories/jobs";
import { import {
@ -14,138 +13,22 @@ import {
completePostApplicationSyncRun, completePostApplicationSyncRun,
startPostApplicationSyncRun, startPostApplicationSyncRun,
} from "@server/repositories/post-application-sync-runs"; } from "@server/repositories/post-application-sync-runs";
import { getSetting } from "@server/repositories/settings";
import { transitionStage } from "@server/services/applicationTracking"; import { transitionStage } from "@server/services/applicationTracking";
import { resolveStageTransitionForTarget } from "@server/services/post-application/stage-target";
import type { PostApplicationRouterStageTarget } from "@shared/types";
import { classifyWithSmartRouter, minifyActiveJobs } from "./email-router";
import type { GmailCredentials } from "./gmail-api";
import { import {
type JsonSchemaDefinition, buildEmailText,
LlmService, extractBodyText,
} from "@server/services/llm-service"; getMessageFull,
import { getMessageMetadata,
messageTypeFromStageTarget, listMessageIds,
normalizeStageTarget, resolveGmailAccessToken,
resolveStageTransitionForTarget, } from "./gmail-api";
} from "@server/services/post-application/stage-target";
import {
type Job,
POST_APPLICATION_ROUTER_STAGE_TARGETS,
type PostApplicationMessageType,
type PostApplicationRouterStageTarget,
} from "@shared/types";
import { convert } from "html-to-text";
const DEFAULT_SEARCH_DAYS = 90; const DEFAULT_SEARCH_DAYS = 90;
const DEFAULT_MAX_MESSAGES = 100; const DEFAULT_MAX_MESSAGES = 100;
const GMAIL_HTTP_TIMEOUT_MS = 15_000;
const ROUTER_EMAIL_CHAR_LIMIT = 12_000;
const SMART_ROUTER_SCHEMA: JsonSchemaDefinition = {
name: "post_application_email_router",
schema: {
type: "object",
properties: {
bestMatchIndex: {
type: ["integer", "null"],
description:
"Best matching active-job index from provided list (1-based), or null.",
},
confidence: {
type: "integer",
description: "Confidence score 0-100 for routing decision.",
},
stageTarget: {
type: "string",
enum: [...POST_APPLICATION_ROUTER_STAGE_TARGETS],
description:
"Normalized stage target for this message, matching Log Event options.",
},
isRelevant: {
type: "boolean",
description:
"Whether this is a relevant recruitment/application email.",
},
stageEventPayload: {
type: ["object", "null"],
description: "Structured metadata for a potential stage event.",
additionalProperties: true,
},
reason: {
type: "string",
description: "One sentence reason for the routing decision.",
},
},
required: [
"bestMatchIndex",
"confidence",
"stageTarget",
"isRelevant",
"stageEventPayload",
"reason",
],
additionalProperties: false,
},
};
type GmailCredentials = {
refreshToken: string;
accessToken?: string;
expiryDate?: number;
scope?: string;
tokenType?: string;
email?: string;
};
type GmailListMessage = {
id: string;
threadId: string;
};
type GmailHeader = { name?: string; value?: string };
type GmailMetadataMessage = {
id: string;
threadId: string;
snippet: string;
headers: GmailHeader[];
};
type GmailFullMessage = GmailMetadataMessage & {
payload?: {
mimeType?: string;
body?: { data?: string };
parts?: Array<{
mimeType?: string;
body?: { data?: string };
parts?: unknown[];
}>;
};
};
type SmartRouterResult = {
bestMatchId: string | null;
confidence: number;
stageTarget: PostApplicationRouterStageTarget;
messageType: PostApplicationMessageType;
isRelevant: boolean;
stageEventPayload: Record<string, unknown> | null;
reason: string;
};
function resolveProcessingStatus(input: {
isAutoLinked: boolean;
isPendingMatch: boolean;
isRelevantOrphan: boolean;
}): "auto_linked" | "pending_user" | "ignored" {
if (input.isAutoLinked) return "auto_linked";
if (input.isPendingMatch || input.isRelevantOrphan) return "pending_user";
return "ignored";
}
type IndexedActiveJob = {
index: number;
id: string;
company: string;
title: string;
};
export type GmailSyncSummary = { export type GmailSyncSummary = {
discovered: number; discovered: number;
@ -154,6 +37,11 @@ export type GmailSyncSummary = {
errored: number; errored: number;
}; };
export const __test__ = {
extractBodyText,
buildEmailText,
};
function asString(value: unknown): string | null { function asString(value: unknown): string | null {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
const trimmed = value.trim(); const trimmed = value.trim();
@ -184,220 +72,10 @@ function parseGmailCredentials(
}; };
} }
export async function resolveGmailAccessToken( function headerValue(
credentials: GmailCredentials, headers: Array<{ name?: string; value?: string }>,
): Promise<GmailCredentials> { name: string,
const now = Date.now(); ): string {
if (
credentials.accessToken &&
credentials.expiryDate &&
credentials.expiryDate > now + 60_000
) {
return credentials;
}
const clientId = asString(process.env.GMAIL_OAUTH_CLIENT_ID);
const clientSecret = asString(process.env.GMAIL_OAUTH_CLIENT_SECRET);
if (!clientId || !clientSecret) {
throw new Error(
"Missing GMAIL_OAUTH_CLIENT_ID or GMAIL_OAUTH_CLIENT_SECRET for Gmail token refresh.",
);
}
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token",
refresh_token: credentials.refreshToken,
});
const response = await fetchWithTimeout(
"https://oauth2.googleapis.com/token",
{
timeoutMs: GMAIL_HTTP_TIMEOUT_MS,
init: {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
},
},
);
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(`Gmail token refresh failed with HTTP ${response.status}.`);
}
const accessToken = asString(data?.access_token);
const expiresIn =
typeof data?.expires_in === "number" && Number.isFinite(data.expires_in)
? data.expires_in
: 3600;
if (!accessToken) {
throw new Error(
"Gmail token refresh response did not include access_token.",
);
}
return {
...credentials,
accessToken,
expiryDate: Date.now() + expiresIn * 1000,
};
}
export async function gmailApi<T>(token: string, url: string): Promise<T> {
const response = await fetchWithTimeout(url, {
timeoutMs: GMAIL_HTTP_TIMEOUT_MS,
init: {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
},
});
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(`Gmail API request failed (${response.status}).`);
}
return data as T;
}
async function fetchWithTimeout(
url: string,
args: { timeoutMs: number; init: RequestInit },
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), args.timeoutMs);
try {
return await fetch(url, {
...args.init,
signal: controller.signal,
});
} catch (error) {
if (
typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "AbortError"
) {
throw requestTimeout(
`Gmail request timed out after ${args.timeoutMs}ms for ${url}.`,
);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
function buildGmailQuery(searchDays: number): string {
const subjectTerms = [
"application",
"thank you for applying",
"thanks for applying",
"application received",
"application submitted",
"your application",
"interview",
"assessment",
"coding challenge",
"take-home",
"availability",
"offer",
"offer letter",
"referral",
"recruiter",
"hiring team",
"regret to inform",
"not moving forward",
"not selected",
"application unsuccessful",
"moving forward with other candidates",
"unable to proceed",
"position has been filled",
"hiring freeze",
"position on hold",
"withdrawn",
];
const fromTerms = [
"careers@",
"jobs@",
"recruiting@",
"talent@",
"no-reply@greenhouse.io",
"no-reply@us.greenhouse-mail.io",
"no-reply@ashbyhq.com",
"notification@smartrecruiters.com",
"@smartrecruiters.com",
"@workablemail.com",
"@hire.lever.co",
"@myworkday.com",
"@workdaymail.com",
"@greenhouse.io",
"@ashbyhq.com",
];
const excludeSubjectTerms = [
"newsletter",
"webinar",
"course",
"discount",
"event invitation",
"job search council",
"matched new opportunities",
];
const quoteTerm = (value: string) => `"${value.replace(/"/g, '\\"')}"`;
const subjectBlock = subjectTerms
.map((term) => `subject:${quoteTerm(term)}`)
.join(" OR ");
const fromBlock = fromTerms
.map((term) => `from:${quoteTerm(term)}`)
.join(" OR ");
const excludeClauses = excludeSubjectTerms
.map((term) => `-subject:${quoteTerm(term)}`)
.join(" ");
return `newer_than:${searchDays}d ((${subjectBlock}) OR (${fromBlock})) ${excludeClauses}`.trim();
}
async function listMessageIds(
token: string,
searchDays: number,
maxMessages: number,
): Promise<GmailListMessage[]> {
const messages: GmailListMessage[] = [];
let pageToken: string | undefined;
do {
const q = encodeURIComponent(buildGmailQuery(searchDays));
const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${q}&maxResults=${Math.min(
100,
maxMessages,
)}${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`;
const page = await gmailApi<{
messages?: Array<{ id?: string; threadId?: string }>;
nextPageToken?: string;
}>(token, listUrl);
for (const message of page.messages ?? []) {
if (!message.id || !message.threadId) continue;
messages.push({ id: message.id, threadId: message.threadId });
if (messages.length >= maxMessages) {
return messages;
}
}
pageToken = page.nextPageToken;
} while (pageToken && messages.length < maxMessages);
return messages;
}
function headerValue(headers: GmailHeader[], name: string): string {
const found = headers.find( const found = headers.find(
(header) => (header.name ?? "").toLowerCase() === name.toLowerCase(), (header) => (header.name ?? "").toLowerCase() === name.toLowerCase(),
); );
@ -424,299 +102,14 @@ function parseReceivedAt(dateHeader: string): number {
return Number.isFinite(parsed) ? parsed : Date.now(); return Number.isFinite(parsed) ? parsed : Date.now();
} }
function decodeBase64Url(value: string): string { function resolveProcessingStatus(input: {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); isAutoLinked: boolean;
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); isPendingMatch: boolean;
return Buffer.from(padded, "base64").toString("utf8"); isRelevantOrphan: boolean;
} }): "auto_linked" | "pending_user" | "ignored" {
if (input.isAutoLinked) return "auto_linked";
function cleanEmailHtmlForLlm(htmlContent: string): string { if (input.isPendingMatch || input.isRelevantOrphan) return "pending_user";
return convert(htmlContent, { return "ignored";
wordwrap: 130,
selectors: [
{ selector: "img", format: "skip" },
{ selector: "a", options: { ignoreHref: true } },
{ selector: "style", format: "skip" },
{ selector: "script", format: "skip" },
],
});
}
function normalizeChunkForDedup(value: string): string {
return value.replace(/\s+/g, " ").trim().toLowerCase();
}
function decodeTextPart(
part: NonNullable<GmailFullMessage["payload"]>,
): string {
const data = part.body?.data;
if (!data) return "";
const decoded = decodeBase64Url(data);
const mimeType = String(part.mimeType ?? "").toLowerCase();
if (mimeType.includes("text/html")) {
return cleanEmailHtmlForLlm(decoded);
}
if (mimeType.startsWith("text/")) {
return decoded;
}
return "";
}
function extractBodyText(payload: GmailFullMessage["payload"]): string {
if (!payload) return "";
const chunks: string[] = [];
const seen = new Set<string>();
const addChunk = (value: string): void => {
const chunk = value.trim();
if (!chunk) return;
const normalized = normalizeChunkForDedup(chunk);
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
chunks.push(chunk);
};
const walk = (part: NonNullable<GmailFullMessage["payload"]>): void => {
const mimeType = String(part.mimeType ?? "").toLowerCase();
if (mimeType === "multipart/alternative") {
const children = (part.parts ?? []) as Array<
NonNullable<GmailFullMessage["payload"]>
>;
const plainChild = children.find(
(child) => String(child.mimeType ?? "").toLowerCase() === "text/plain",
);
const plainText = plainChild ? decodeTextPart(plainChild).trim() : "";
if (plainText.length > 50) {
addChunk(plainText);
return;
}
if (plainText) {
addChunk(plainText);
return;
}
const htmlChild = children.find((child) =>
String(child.mimeType ?? "")
.toLowerCase()
.includes("text/html"),
);
if (htmlChild) {
addChunk(decodeTextPart(htmlChild));
return;
}
}
const chunk = decodeTextPart(part);
if (chunk) {
addChunk(chunk);
}
for (const child of part.parts ?? []) {
walk(child as NonNullable<GmailFullMessage["payload"]>);
}
};
walk(payload);
return chunks.join("\n\n").trim();
}
export const __test__ = {
extractBodyText,
buildEmailText,
};
function buildEmailText(input: {
from: string;
subject: string;
date: string;
body: string;
}): string {
return `From: ${input.from}
Subject: ${input.subject}
Date: ${input.date}
Body:
${input.body}`.trim();
}
function minifyActiveJobs(jobs: Job[]): Array<{
id: string;
company: string;
title: string;
}> {
return jobs.map((job) => ({
id: job.id,
company: job.employer,
title: job.title,
}));
}
function sanitizeJobPromptValue(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function buildIndexedActiveJobs(
jobs: Array<{ id: string; company: string; title: string }>,
): IndexedActiveJob[] {
return jobs.map((job, offset) => ({
index: offset + 1,
id: job.id,
company: sanitizeJobPromptValue(job.company || "Unknown company"),
title: sanitizeJobPromptValue(job.title || "Unknown title"),
}));
}
function buildCompactActiveJobsList(jobs: IndexedActiveJob[]): string {
return jobs
.map((job) => `${job.index}. ${job.company}: ${job.title}`)
.join("\n");
}
function normalizeBestMatchIndex(value: unknown, max: number): number | null {
if (value === null || value === undefined || max <= 0) return null;
const numeric =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseInt(value, 10)
: Number.NaN;
if (!Number.isFinite(numeric)) return null;
const rounded = Math.round(numeric);
if (rounded < 1 || rounded > max) return null;
return rounded;
}
async function classifyWithSmartRouter(args: {
emailText: string;
activeJobs: Array<{ id: string; company: string; title: string }>;
}): Promise<SmartRouterResult> {
const overrideModel = await getSetting("model");
const model =
overrideModel || process.env.MODEL || "google/gemini-3-flash-preview";
const llmEmailText = args.emailText.slice(0, ROUTER_EMAIL_CHAR_LIMIT);
const indexedActiveJobs = buildIndexedActiveJobs(args.activeJobs);
const compactActiveJobsList = buildCompactActiveJobsList(indexedActiveJobs);
const messages = [
{
role: "system" as const,
content:
"You are a smart router for post-application emails. Return only strict JSON. Ignore sensitive data and include only routing fields.",
},
{
role: "user" as const,
content: `Route this email to one active job if possible.
- Choose bestMatchIndex only from listed job numbers (1-based), or null.
- confidence is 0..100.
- stageTarget must be one of: ${POST_APPLICATION_ROUTER_STAGE_TARGETS.join("|")}.
- isRelevant should be true for recruitment/application lifecycle emails.
- stageEventPayload should be minimal structured data or null.
Active jobs (index. company: title):
${compactActiveJobsList}
Email:
${llmEmailText}`,
},
];
const llm = new LlmService();
const result = await llm.callJson<{
bestMatchIndex: number | null;
confidence: number;
stageTarget: string;
isRelevant: boolean;
stageEventPayload: Record<string, unknown> | null;
reason: string;
}>({
model,
messages,
jsonSchema: SMART_ROUTER_SCHEMA,
maxRetries: 1,
retryDelayMs: 400,
});
if (!result.success) {
throw new Error(`LLM classification failed: ${result.error}`);
}
const confidence = Math.max(
0,
Math.min(100, Math.round(Number(result.data.confidence) || 0)),
);
const bestMatchIndex = normalizeBestMatchIndex(
result.data.bestMatchIndex,
indexedActiveJobs.length,
);
const bestMatchId =
bestMatchIndex !== null
? (indexedActiveJobs[bestMatchIndex - 1]?.id ?? null)
: null;
const stageTarget =
normalizeStageTarget(result.data.stageTarget) ?? "no_change";
const messageType = messageTypeFromStageTarget(stageTarget);
return {
bestMatchId,
confidence,
stageTarget,
messageType,
isRelevant: Boolean(result.data.isRelevant),
stageEventPayload:
result.data.stageEventPayload &&
typeof result.data.stageEventPayload === "object"
? result.data.stageEventPayload
: null,
reason: String(result.data.reason ?? "").trim(),
};
}
async function getMessageMetadata(
token: string,
messageId: string,
): Promise<GmailMetadataMessage> {
const message = await gmailApi<{
id?: string;
threadId?: string;
snippet?: string;
payload?: { headers?: GmailHeader[] };
}>(
token,
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${encodeURIComponent(
messageId,
)}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date`,
);
return {
id: message.id ?? messageId,
threadId: message.threadId ?? "",
snippet: message.snippet ?? "",
headers: message.payload?.headers ?? [],
};
}
async function getMessageFull(
token: string,
messageId: string,
): Promise<GmailFullMessage> {
const message = await gmailApi<{
id?: string;
threadId?: string;
snippet?: string;
payload?: GmailFullMessage["payload"];
}>(
token,
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${encodeURIComponent(
messageId,
)}?format=full`,
);
return {
id: message.id ?? messageId,
threadId: message.threadId ?? "",
snippet: message.snippet ?? "",
headers: [],
payload: message.payload,
};
} }
function normalizeErrorMessage(error: unknown): string { function normalizeErrorMessage(error: unknown): string {

View File

@ -3,7 +3,8 @@
*/ */
import { getSetting } from "../repositories/settings"; import { getSetting } from "../repositories/settings";
import { type JsonSchemaDefinition, LlmService } from "./llm-service"; import { LlmService } from "./llm/service";
import type { JsonSchemaDefinition } from "./llm/types";
import type { ResumeProjectSelectionItem } from "./resumeProjects"; import type { ResumeProjectSelectionItem } from "./resumeProjects";
/** JSON schema for project selection response */ /** JSON schema for project selection response */

View File

@ -287,12 +287,13 @@ describe("salary penalty", () => {
describe("isSalaryMissing detection", () => { describe("isSalaryMissing detection", () => {
it("should detect null salary as missing", async () => { it("should detect null salary as missing", async () => {
const { scoreJobSuitability } = await import("./scorer"); const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service"); const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({ getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true, penalizeMissingSalary: { value: true, default: true, override: null },
missingSalaryPenalty: 10, missingSalaryPenalty: { value: 10, default: 10, override: null },
}); rxresumeBaseResumeId: "base-resume-123",
} as any);
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({ vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true, success: true,
@ -314,12 +315,13 @@ describe("salary penalty", () => {
it("should detect empty string salary as missing", async () => { it("should detect empty string salary as missing", async () => {
const { scoreJobSuitability } = await import("./scorer"); const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service"); const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({ getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true, penalizeMissingSalary: { value: true, default: true, override: null },
missingSalaryPenalty: 10, missingSalaryPenalty: { value: 10, default: 10, override: null },
}); rxresumeBaseResumeId: "base-resume-123",
} as any);
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({ vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true, success: true,
@ -339,12 +341,13 @@ describe("salary penalty", () => {
it("should detect whitespace-only salary as missing", async () => { it("should detect whitespace-only salary as missing", async () => {
const { scoreJobSuitability } = await import("./scorer"); const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service"); const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({ getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true, penalizeMissingSalary: { value: true, default: true, override: null },
missingSalaryPenalty: 10, missingSalaryPenalty: { value: 10, default: 10, override: null },
}); rxresumeBaseResumeId: "base-resume-123",
} as any);
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({ vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true, success: true,
@ -364,12 +367,13 @@ describe("salary penalty", () => {
it("should NOT penalize jobs with non-empty salary", async () => { it("should NOT penalize jobs with non-empty salary", async () => {
const { scoreJobSuitability } = await import("./scorer"); const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service"); const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({ getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true, penalizeMissingSalary: { value: true, default: true, override: null },
missingSalaryPenalty: 10, missingSalaryPenalty: { value: 10, default: 10, override: null },
}); rxresumeBaseResumeId: "base-resume-123",
} as any);
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({ vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true, success: true,
@ -389,12 +393,13 @@ describe("salary penalty", () => {
it("should NOT penalize jobs with actual salary value", async () => { it("should NOT penalize jobs with actual salary value", async () => {
const { scoreJobSuitability } = await import("./scorer"); const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service"); const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({ getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true, penalizeMissingSalary: { value: true, default: true, override: null },
missingSalaryPenalty: 10, missingSalaryPenalty: { value: 10, default: 10, override: null },
}); rxresumeBaseResumeId: "base-resume-123",
} as any);
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({ vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true, success: true,
@ -416,12 +421,13 @@ describe("salary penalty", () => {
describe("penalty application", () => { describe("penalty application", () => {
it("should not apply penalty when disabled", async () => { it("should not apply penalty when disabled", async () => {
const { scoreJobSuitability } = await import("./scorer"); const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service"); const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({ getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: false, penalizeMissingSalary: { value: false, default: false, override: null },
missingSalaryPenalty: 10, missingSalaryPenalty: { value: 10, default: 10, override: null },
}); rxresumeBaseResumeId: "base-resume-123",
} as any);
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({ vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true, success: true,
@ -441,12 +447,13 @@ describe("salary penalty", () => {
it("should clamp score to minimum 0 (high penalty on medium score)", async () => { it("should clamp score to minimum 0 (high penalty on medium score)", async () => {
const { scoreJobSuitability } = await import("./scorer"); const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service"); const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({ getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true, penalizeMissingSalary: { value: true, default: true, override: null },
missingSalaryPenalty: 100, missingSalaryPenalty: { value: 100, default: 100, override: null },
}); rxresumeBaseResumeId: "base-resume-123",
} as any);
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({ vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true, success: true,
@ -466,12 +473,13 @@ describe("salary penalty", () => {
it("should clamp score to minimum 0 (low score with penalty)", async () => { it("should clamp score to minimum 0 (low score with penalty)", async () => {
const { scoreJobSuitability } = await import("./scorer"); const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service"); const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({ getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true, penalizeMissingSalary: { value: true, default: true, override: null },
missingSalaryPenalty: 10, missingSalaryPenalty: { value: 10, default: 10, override: null },
}); rxresumeBaseResumeId: "base-resume-123",
} as any);
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({ vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true, success: true,
@ -491,12 +499,13 @@ describe("salary penalty", () => {
it("should handle penalty of 0", async () => { it("should handle penalty of 0", async () => {
const { scoreJobSuitability } = await import("./scorer"); const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service"); const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({ getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true, penalizeMissingSalary: { value: true, default: true, override: null },
missingSalaryPenalty: 0, missingSalaryPenalty: { value: 0, default: 0, override: null },
}); rxresumeBaseResumeId: "base-resume-123",
} as any);
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({ vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true, success: true,
@ -516,12 +525,13 @@ describe("salary penalty", () => {
it("should apply penalty with correct amount", async () => { it("should apply penalty with correct amount", async () => {
const { scoreJobSuitability } = await import("./scorer"); const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service"); const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({ getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true, penalizeMissingSalary: { value: true, default: true, override: null },
missingSalaryPenalty: 25, missingSalaryPenalty: { value: 25, default: 25, override: null },
}); rxresumeBaseResumeId: "base-resume-123",
} as any);
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({ vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: true, success: true,
@ -545,12 +555,13 @@ describe("salary penalty", () => {
describe("mock scoring with penalty", () => { describe("mock scoring with penalty", () => {
it("should apply penalty in mock scoring fallback", async () => { it("should apply penalty in mock scoring fallback", async () => {
const { scoreJobSuitability } = await import("./scorer"); const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service"); const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({ getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: true, penalizeMissingSalary: { value: true, default: true, override: null },
missingSalaryPenalty: 10, missingSalaryPenalty: { value: 10, default: 10, override: null },
}); rxresumeBaseResumeId: "base-resume-123",
} as any);
// Simulate API key error to trigger mock scoring // Simulate API key error to trigger mock scoring
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({ vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
@ -573,12 +584,13 @@ describe("salary penalty", () => {
it("should not apply penalty in mock scoring when disabled", async () => { it("should not apply penalty in mock scoring when disabled", async () => {
const { scoreJobSuitability } = await import("./scorer"); const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm-service"); const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({ getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: false, penalizeMissingSalary: { value: false, default: false, override: null },
missingSalaryPenalty: 10, missingSalaryPenalty: { value: 10, default: 10, override: null },
}); rxresumeBaseResumeId: "base-resume-123",
} as any);
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({ vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
success: false, success: false,

View File

@ -5,7 +5,8 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import type { Job } from "@shared/types"; import type { Job } from "@shared/types";
import { getSetting } from "../repositories/settings"; import { getSetting } from "../repositories/settings";
import { type JsonSchemaDefinition, LlmService } from "./llm-service"; import { LlmService } from "./llm/service";
import type { JsonSchemaDefinition } from "./llm/types";
import { getEffectiveSettings } from "./settings"; import { getEffectiveSettings } from "./settings";
interface SuitabilityResult { interface SuitabilityResult {
@ -113,7 +114,10 @@ export async function scoreJobSuitability(
jobId: job.id, jobId: job.id,
error: result.error, error: result.error,
}); });
return mockScore(job, settings); return mockScore(job, {
penalizeMissingSalary: settings.penalizeMissingSalary.value,
missingSalaryPenalty: settings.missingSalaryPenalty.value,
});
} }
const { score, reason } = result.data; const { score, reason } = result.data;
@ -123,7 +127,10 @@ export async function scoreJobSuitability(
logger.error("Invalid score in AI response, using mock scoring", { logger.error("Invalid score in AI response, using mock scoring", {
jobId: job.id, jobId: job.id,
}); });
return mockScore(job, settings); return mockScore(job, {
penalizeMissingSalary: settings.penalizeMissingSalary.value,
missingSalaryPenalty: settings.missingSalaryPenalty.value,
});
} }
const clampedScore = Math.min(100, Math.max(0, Math.round(score))); const clampedScore = Math.min(100, Math.max(0, Math.round(score)));
@ -131,8 +138,8 @@ export async function scoreJobSuitability(
// Apply salary penalty if enabled // Apply salary penalty if enabled
const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, { const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, {
penalizeMissingSalary: settings.penalizeMissingSalary, penalizeMissingSalary: settings.penalizeMissingSalary.value,
missingSalaryPenalty: settings.missingSalaryPenalty, missingSalaryPenalty: settings.missingSalaryPenalty.value,
}); });
return { return {

View File

@ -1,188 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
resolveSettingValue,
serializeSettingValue,
} from "./settings-conversion";
const originalEnv = { ...process.env };
describe("settings-conversion", () => {
afterEach(() => {
vi.unstubAllEnvs();
process.env = { ...originalEnv };
});
it("round-trips numeric settings", () => {
const serialized = serializeSettingValue("ukvisajobsMaxJobs", 42);
expect(serialized).toBe("42");
const resolved = resolveSettingValue(
"ukvisajobsMaxJobs",
serialized ?? undefined,
);
expect(resolved.overrideValue).toBe(42);
expect(resolved.value).toBe(42);
expect(resolved.defaultValue).toBe(50);
});
it("round-trips adzuna numeric settings", () => {
process.env.ADZUNA_MAX_JOBS_PER_TERM = "";
const serialized = serializeSettingValue("adzunaMaxJobsPerTerm", 75);
expect(serialized).toBe("75");
const resolved = resolveSettingValue(
"adzunaMaxJobsPerTerm",
serialized ?? undefined,
);
expect(resolved.overrideValue).toBe(75);
expect(resolved.value).toBe(75);
expect(resolved.defaultValue).toBe(50);
});
it("round-trips boolean bit settings", () => {
expect(serializeSettingValue("showSponsorInfo", true)).toBe("1");
expect(serializeSettingValue("showSponsorInfo", false)).toBe("0");
expect(resolveSettingValue("showSponsorInfo", "1").value).toBe(true);
expect(resolveSettingValue("showSponsorInfo", "0").value).toBe(false);
expect(resolveSettingValue("showSponsorInfo", "true").value).toBe(true);
expect(resolveSettingValue("showSponsorInfo", "false").value).toBe(false);
});
it("round-trips JSON array settings", () => {
const serialized = serializeSettingValue("searchTerms", [
"backend",
"platform",
]);
expect(serialized).toBe('["backend","platform"]');
const resolved = resolveSettingValue(
"searchTerms",
serialized ?? undefined,
);
expect(resolved.overrideValue).toEqual(["backend", "platform"]);
expect(resolved.value).toEqual(["backend", "platform"]);
});
it("uses string defaults when override is empty", () => {
process.env.JOBSPY_LOCATION = "Remote";
const resolved = resolveSettingValue("searchCities", "");
expect(resolved.defaultValue).toBe("Remote");
expect(resolved.overrideValue).toBe("");
expect(resolved.value).toBe("Remote");
});
it("applies clamped backup value parsing", () => {
expect(resolveSettingValue("backupHour", "26").value).toBe(23);
expect(resolveSettingValue("backupMaxCount", "0").value).toBe(1);
});
it("falls back to default for invalid numeric overrides", () => {
const resolved = resolveSettingValue("ukvisajobsMaxJobs", "not-a-number");
expect(resolved.overrideValue).toBeNull();
expect(resolved.value).toBe(50);
});
it("falls back to default for invalid JSON array overrides", () => {
const objectOverride = resolveSettingValue("searchTerms", '{"term":"x"}');
expect(objectOverride.overrideValue).toBeNull();
expect(objectOverride.value).toEqual(["web developer"]);
const malformedOverride = resolveSettingValue("searchTerms", "[oops");
expect(malformedOverride.overrideValue).toBeNull();
expect(malformedOverride.value).toEqual(["web developer"]);
});
it("round-trips penalizeMissingSalary boolean setting", () => {
expect(serializeSettingValue("penalizeMissingSalary", true)).toBe("1");
expect(serializeSettingValue("penalizeMissingSalary", false)).toBe("0");
expect(resolveSettingValue("penalizeMissingSalary", "1").value).toBe(true);
expect(resolveSettingValue("penalizeMissingSalary", "0").value).toBe(false);
expect(resolveSettingValue("penalizeMissingSalary", "true").value).toBe(
true,
);
expect(resolveSettingValue("penalizeMissingSalary", undefined).value).toBe(
false,
);
});
it("round-trips missingSalaryPenalty numeric setting with clamping", () => {
const serialized = serializeSettingValue("missingSalaryPenalty", 10);
expect(serialized).toBe("10");
const resolved = resolveSettingValue(
"missingSalaryPenalty",
serialized ?? undefined,
);
expect(resolved.overrideValue).toBe(10);
expect(resolved.value).toBe(10);
expect(resolved.defaultValue).toBe(10);
// Test clamping
expect(resolveSettingValue("missingSalaryPenalty", "150").value).toBe(100);
expect(resolveSettingValue("missingSalaryPenalty", "-5").value).toBe(0);
expect(resolveSettingValue("missingSalaryPenalty", "0").value).toBe(0);
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";
const penalizeResolved = resolveSettingValue(
"penalizeMissingSalary",
undefined,
);
expect(penalizeResolved.defaultValue).toBe(true);
expect(penalizeResolved.value).toBe(true);
const penaltyResolved = resolveSettingValue(
"missingSalaryPenalty",
undefined,
);
expect(penaltyResolved.defaultValue).toBe(25);
expect(penaltyResolved.value).toBe(25);
});
});

View File

@ -1,273 +0,0 @@
type SettingMetadata<T, Input = T | null | undefined> = {
defaultValue: () => T;
parseOverride: (raw: string | undefined) => T | null;
serialize: (value: Input) => string | null;
resolve: (args: { defaultValue: T; overrideValue: T | null }) => T;
};
type SettingsConversionValueMap = {
ukvisajobsMaxJobs: number;
adzunaMaxJobsPerTerm: number;
gradcrackerMaxJobsPerTerm: number;
searchTerms: string[];
searchCities: string;
jobspyResultsWanted: number;
jobspyCountryIndeed: string;
showSponsorInfo: boolean;
chatStyleTone: string;
chatStyleFormality: string;
chatStyleConstraints: string;
chatStyleDoNotUse: string;
backupEnabled: boolean;
backupHour: number;
backupMaxCount: number;
penalizeMissingSalary: boolean;
missingSalaryPenalty: number;
autoSkipScoreThreshold: number | null;
};
type SettingsConversionInputMap = {
[K in keyof SettingsConversionValueMap]:
| SettingsConversionValueMap[K]
| null
| undefined;
};
type SettingsConversionMetadata = {
[K in keyof SettingsConversionValueMap]: SettingMetadata<
SettingsConversionValueMap[K],
SettingsConversionInputMap[K]
>;
};
export type SettingsConversionKey = keyof SettingsConversionValueMap;
function parseIntOrNull(raw: string | undefined): number | null {
if (!raw) return null;
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? null : parsed;
}
function parseJsonArrayOrNull(raw: string | undefined): string[] | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as string[]) : null;
} catch {
return null;
}
}
function parseBitBoolOrNull(raw: string | undefined): boolean | null {
if (!raw) return null;
return raw === "true" || raw === "1";
}
function serializeNullableNumber(
value: number | null | undefined,
): string | null {
return value !== null && value !== undefined ? String(value) : null;
}
function serializeNullableJsonArray(
value: string[] | null | undefined,
): string | null {
return value !== null && value !== undefined ? JSON.stringify(value) : null;
}
function serializeBitBool(value: boolean | null | undefined): string | null {
if (value === null || value === undefined) return null;
return value ? "1" : "0";
}
function resolveWithNullishFallback<T>(args: {
defaultValue: T;
overrideValue: T | null;
}): T {
return args.overrideValue ?? args.defaultValue;
}
function resolveWithEmptyStringFallback(args: {
defaultValue: string;
overrideValue: string | null;
}): string {
return args.overrideValue || args.defaultValue;
}
export const settingsConversionMetadata: SettingsConversionMetadata = {
ukvisajobsMaxJobs: {
defaultValue: () => 50,
parseOverride: parseIntOrNull,
serialize: serializeNullableNumber,
resolve: resolveWithNullishFallback,
},
adzunaMaxJobsPerTerm: {
defaultValue: () =>
parseInt(process.env.ADZUNA_MAX_JOBS_PER_TERM || "50", 10),
parseOverride: parseIntOrNull,
serialize: serializeNullableNumber,
resolve: resolveWithNullishFallback,
},
gradcrackerMaxJobsPerTerm: {
defaultValue: () => 50,
parseOverride: parseIntOrNull,
serialize: serializeNullableNumber,
resolve: resolveWithNullishFallback,
},
searchTerms: {
defaultValue: () =>
(process.env.JOBSPY_SEARCH_TERMS || "web developer")
.split("|")
.map((value) => value.trim())
.filter(Boolean),
parseOverride: parseJsonArrayOrNull,
serialize: serializeNullableJsonArray,
resolve: resolveWithNullishFallback,
},
searchCities: {
defaultValue: () =>
process.env.SEARCH_CITIES || process.env.JOBSPY_LOCATION || "UK",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,
},
jobspyResultsWanted: {
defaultValue: () =>
parseInt(process.env.JOBSPY_RESULTS_WANTED || "200", 10),
parseOverride: parseIntOrNull,
serialize: serializeNullableNumber,
resolve: resolveWithNullishFallback,
},
jobspyCountryIndeed: {
defaultValue: () => process.env.JOBSPY_COUNTRY_INDEED || "UK",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,
},
showSponsorInfo: {
defaultValue: () => true,
parseOverride: parseBitBoolOrNull,
serialize: serializeBitBool,
resolve: resolveWithNullishFallback,
},
chatStyleTone: {
defaultValue: () => process.env.CHAT_STYLE_TONE || "professional",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,
},
chatStyleFormality: {
defaultValue: () => process.env.CHAT_STYLE_FORMALITY || "medium",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,
},
chatStyleConstraints: {
defaultValue: () => process.env.CHAT_STYLE_CONSTRAINTS || "",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,
},
chatStyleDoNotUse: {
defaultValue: () => process.env.CHAT_STYLE_DO_NOT_USE || "",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,
},
backupEnabled: {
defaultValue: () => false,
parseOverride: parseBitBoolOrNull,
serialize: serializeBitBool,
resolve: resolveWithNullishFallback,
},
backupHour: {
defaultValue: () => 2,
parseOverride: (raw) => {
const parsed = raw ? parseInt(raw, 10) : NaN;
if (Number.isNaN(parsed)) return null;
return Math.min(23, Math.max(0, parsed));
},
serialize: serializeNullableNumber,
resolve: resolveWithNullishFallback,
},
backupMaxCount: {
defaultValue: () => 5,
parseOverride: (raw) => {
const parsed = raw ? parseInt(raw, 10) : NaN;
if (Number.isNaN(parsed)) return null;
return Math.min(5, Math.max(1, parsed));
},
serialize: serializeNullableNumber,
resolve: resolveWithNullishFallback,
},
penalizeMissingSalary: {
defaultValue: () =>
(process.env.PENALIZE_MISSING_SALARY || "0") === "1" ||
(process.env.PENALIZE_MISSING_SALARY || "").toLowerCase() === "true",
parseOverride: parseBitBoolOrNull,
serialize: serializeBitBool,
resolve: resolveWithNullishFallback,
},
missingSalaryPenalty: {
defaultValue: () => {
const raw = process.env.MISSING_SALARY_PENALTY;
if (!raw) return 10;
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed)) return 10;
return Math.min(100, Math.max(0, parsed));
},
parseOverride: (raw) => {
const parsed = raw ? parseInt(raw, 10) : NaN;
if (Number.isNaN(parsed)) return null;
return Math.min(100, Math.max(0, parsed));
},
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>(
key: K,
raw: string | undefined,
): {
defaultValue: SettingsConversionValueMap[K];
overrideValue: SettingsConversionValueMap[K] | null;
value: SettingsConversionValueMap[K];
} {
const metadata = settingsConversionMetadata[key];
const defaultValue = metadata.defaultValue();
const overrideValue = metadata.parseOverride(raw);
const value = metadata.resolve({
defaultValue,
overrideValue,
});
return { defaultValue, overrideValue, value };
}
export function serializeSettingValue<K extends SettingsConversionKey>(
key: K,
value: SettingsConversionInputMap[K],
): string | null {
const metadata = settingsConversionMetadata[key];
return metadata.serialize(value);
}

View File

@ -7,9 +7,4 @@ export type {
SettingsUpdateResult, SettingsUpdateResult,
SettingUpdateHandler, SettingUpdateHandler,
} from "./registry"; } from "./registry";
export { export { settingsUpdateRegistry } from "./registry";
settingsUpdateRegistry,
toJsonOrNull,
toNormalizedStringOrNull,
toNumberStringOrNull,
} from "./registry";

View File

@ -6,10 +6,7 @@ import {
extractProjectsFromProfile, extractProjectsFromProfile,
normalizeResumeProjectsSettings, normalizeResumeProjectsSettings,
} from "@server/services/resumeProjects"; } from "@server/services/resumeProjects";
import { import { settingsRegistry } from "@shared/settings-registry";
type SettingsConversionKey,
serializeSettingValue,
} from "@server/services/settings-conversion";
import type { UpdateSettingsInput } from "@shared/settings-schema"; import type { UpdateSettingsInput } from "@shared/settings-schema";
export type DeferredSideEffect = "refreshBackupScheduler"; export type DeferredSideEffect = "refreshBackupScheduler";
@ -39,22 +36,6 @@ export type SettingsUpdatePlan = {
shouldRefreshBackupScheduler: boolean; shouldRefreshBackupScheduler: boolean;
}; };
export function toNormalizedStringOrNull(
value: string | null | undefined,
): string | null {
return normalizeEnvInput(value);
}
export function toNumberStringOrNull(
value: number | null | undefined,
): string | null {
return serializeSettingValue("ukvisajobsMaxJobs", value);
}
export function toJsonOrNull<T>(value: T | null | undefined): string | null {
return value !== null && value !== undefined ? JSON.stringify(value) : null;
}
function result( function result(
args: { args: {
actions?: SettingsUpdateAction[]; actions?: SettingsUpdateAction[];
@ -68,7 +49,7 @@ function result(
} }
function persistAction( function persistAction(
settingKey: Parameters<typeof settingsRepo.setSetting>[0], settingKey: SettingKey,
value: string | null, value: string | null,
sideEffect?: () => void | Promise<void>, sideEffect?: () => void | Promise<void>,
): SettingsUpdateAction { ): SettingsUpdateAction {
@ -79,270 +60,63 @@ function persistAction(
}; };
} }
function singleAction<K extends keyof UpdateSettingsInput>( // eslint-disable-next-line @typescript-eslint/no-explicit-any
fn: SettingUpdateHandler<K>,
): SettingUpdateHandler<K> {
return fn;
}
function metadataPersistAction(
key: SettingsConversionKey,
value: unknown,
): SettingsUpdateAction {
return persistAction(key, serializeSettingValue(key, value as never));
}
export const settingsUpdateRegistry: Partial<{ export const settingsUpdateRegistry: Partial<{
[K in keyof UpdateSettingsInput]: SettingUpdateHandler<K>; [K in keyof UpdateSettingsInput]: SettingUpdateHandler<K>;
}> = { }> = {};
model: singleAction(({ value }) =>
result({ actions: [persistAction("model", value ?? null)] }), for (const [key, def] of Object.entries(settingsRegistry)) {
), if (def.kind === "virtual") continue;
modelScorer: singleAction(({ value }) =>
result({ actions: [persistAction("modelScorer", value ?? null)] }), const targetKey =
), def.kind === "alias" ? (def.target as SettingKey) : (key as SettingKey);
modelTailoring: singleAction(({ value }) => const isBackup = key.startsWith("backup");
result({ actions: [persistAction("modelTailoring", value ?? null)] }), const hasEnvKey = "envKey" in def && !!def.envKey;
),
modelProjectSelection: singleAction(({ value }) => // Special case for resumeProjects
result({ if (key === "resumeProjects") {
actions: [persistAction("modelProjectSelection", value ?? null)], settingsUpdateRegistry.resumeProjects = async ({ value }) => {
}),
),
llmProvider: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("llmProvider", normalized, () => {
applyEnvValue("LLM_PROVIDER", normalized);
}),
],
});
}),
llmBaseUrl: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("llmBaseUrl", normalized, () => {
applyEnvValue("LLM_BASE_URL", normalized);
}),
],
});
}),
pipelineWebhookUrl: singleAction(({ value }) =>
result({ actions: [persistAction("pipelineWebhookUrl", value ?? null)] }),
),
jobCompleteWebhookUrl: singleAction(({ value }) =>
result({
actions: [persistAction("jobCompleteWebhookUrl", value ?? null)],
}),
),
rxresumeBaseResumeId: singleAction(({ value }) =>
result({
actions: [
persistAction("rxresumeBaseResumeId", toNormalizedStringOrNull(value)),
],
}),
),
resumeProjects: singleAction(async ({ value }) => {
const resumeProjects = value ?? null; const resumeProjects = value ?? null;
if (resumeProjects === null) { if (resumeProjects === null) {
return result({ actions: [persistAction("resumeProjects", null)] }); return result({ actions: [persistAction(targetKey, null)] });
} }
const profile = await getProfile(); const profile = await getProfile();
const { catalog } = extractProjectsFromProfile(profile); const { catalog } = extractProjectsFromProfile(profile);
const allowed = new Set(catalog.map((project) => project.id)); const allowed = new Set(catalog.map((project) => project.id));
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed); const normalized = normalizeResumeProjectsSettings(
resumeProjects as Parameters<typeof normalizeResumeProjectsSettings>[0],
allowed,
);
return result({ return result({
actions: [persistAction("resumeProjects", JSON.stringify(normalized))], actions: [persistAction(targetKey, JSON.stringify(normalized))],
}); });
}),
ukvisajobsMaxJobs: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("ukvisajobsMaxJobs", value)],
}),
),
adzunaMaxJobsPerTerm: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("adzunaMaxJobsPerTerm", value)],
}),
),
gradcrackerMaxJobsPerTerm: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("gradcrackerMaxJobsPerTerm", value)],
}),
),
searchTerms: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("searchTerms", value)] }),
),
searchCities: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("searchCities", value)] }),
),
// Deprecated legacy key; persist into canonical searchCities setting.
jobspyLocation: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("searchCities", value)] }),
),
jobspyResultsWanted: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("jobspyResultsWanted", value)],
}),
),
jobspyCountryIndeed: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("jobspyCountryIndeed", value)] }),
),
showSponsorInfo: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("showSponsorInfo", value)],
}),
),
chatStyleTone: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("chatStyleTone", value)],
}),
),
chatStyleFormality: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("chatStyleFormality", value)],
}),
),
chatStyleConstraints: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("chatStyleConstraints", value)],
}),
),
chatStyleDoNotUse: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("chatStyleDoNotUse", value)],
}),
),
llmApiKey: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("llmApiKey", normalized, () => {
applyEnvValue("LLM_API_KEY", normalized);
}),
],
});
}),
rxresumeEmail: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("rxresumeEmail", normalized, () => {
applyEnvValue("RXRESUME_EMAIL", normalized);
}),
],
});
}),
rxresumePassword: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("rxresumePassword", normalized, () => {
applyEnvValue("RXRESUME_PASSWORD", normalized);
}),
],
});
}),
basicAuthUser: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("basicAuthUser", normalized, () => {
applyEnvValue("BASIC_AUTH_USER", normalized);
}),
],
});
}),
basicAuthPassword: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("basicAuthPassword", normalized, () => {
applyEnvValue("BASIC_AUTH_PASSWORD", normalized);
}),
],
});
}),
ukvisajobsEmail: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("ukvisajobsEmail", normalized, () => {
applyEnvValue("UKVISAJOBS_EMAIL", normalized);
}),
],
});
}),
ukvisajobsPassword: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("ukvisajobsPassword", normalized, () => {
applyEnvValue("UKVISAJOBS_PASSWORD", normalized);
}),
],
});
}),
adzunaAppId: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("adzunaAppId", normalized, () => {
applyEnvValue("ADZUNA_APP_ID", normalized);
}),
],
});
}),
adzunaAppKey: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("adzunaAppKey", normalized, () => {
applyEnvValue("ADZUNA_APP_KEY", normalized);
}),
],
});
}),
webhookSecret: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("webhookSecret", normalized, () => {
applyEnvValue("WEBHOOK_SECRET", normalized);
}),
],
});
}),
backupEnabled: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("backupEnabled", value)],
deferred: ["refreshBackupScheduler"],
}),
),
backupHour: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("backupHour", value)],
deferred: ["refreshBackupScheduler"],
}),
),
backupMaxCount: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("backupMaxCount", value)],
deferred: ["refreshBackupScheduler"],
}),
),
penalizeMissingSalary: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("penalizeMissingSalary", value)],
}),
),
missingSalaryPenalty: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("missingSalaryPenalty", value)],
}),
),
}; };
continue;
}
// Generic handler for all others
settingsUpdateRegistry[key as keyof UpdateSettingsInput] = ({ value }) => {
let serialized: string | null;
if ("serialize" in def) {
serialized = def.serialize(value as never);
} else {
serialized = normalizeEnvInput(value as string);
}
const sideEffect = hasEnvKey
? () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: def is constrained by kind
applyEnvValue((def as any).envKey, serialized);
}
: undefined;
return result({
actions: [persistAction(targetKey, serialized, sideEffect)],
deferred: isBackup ? ["refreshBackupScheduler"] : [],
});
};
}

View File

@ -1,4 +1,5 @@
import * as settingsRepo from "@server/repositories/settings"; import * as settingsRepo from "@server/repositories/settings";
import { settingsRegistry } from "@shared/settings-registry";
import type { AppSettings } from "@shared/types"; import type { AppSettings } from "@shared/types";
import { getEnvSettingsData } from "./envSettings"; import { getEnvSettingsData } from "./envSettings";
import { getProfile } from "./profile"; import { getProfile } from "./profile";
@ -7,7 +8,19 @@ import {
resolveResumeProjectsSettings, resolveResumeProjectsSettings,
} from "./resumeProjects"; } from "./resumeProjects";
import { getResume, RxResumeCredentialsError } from "./rxresume-v4"; import { getResume, RxResumeCredentialsError } from "./rxresume-v4";
import { resolveSettingValue } from "./settings-conversion";
function resolveDefaultLlmBaseUrl(provider: string): string {
const normalized = provider.trim().toLowerCase();
if (normalized === "ollama") return "http://localhost:11434";
if (normalized === "lmstudio") return "http://localhost:1234";
if (normalized === "openai") {
return "https://api.openai.com";
}
if (normalized === "gemini") {
return "https://generativelanguage.googleapis.com";
}
return "https://openrouter.ai";
}
/** /**
* Get the effective app settings, combining environment variables and database overrides. * Get the effective app settings, combining environment variables and database overrides.
@ -47,291 +60,73 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
const envSettings = await getEnvSettingsData(overrides); const envSettings = await getEnvSettingsData(overrides);
const defaultModel = process.env.MODEL || "google/gemini-3-flash-preview"; const result: Partial<AppSettings> = {
const overrideModel = overrides.model ?? null;
const model = overrideModel || defaultModel;
const overrideModelScorer = overrides.modelScorer ?? null;
const modelScorer = overrideModelScorer || model;
const overrideModelTailoring = overrides.modelTailoring ?? null;
const modelTailoring = overrideModelTailoring || model;
const overrideModelProjectSelection = overrides.modelProjectSelection ?? null;
const modelProjectSelection = overrideModelProjectSelection || model;
const defaultLlmProvider = process.env.LLM_PROVIDER || "openrouter";
const overrideLlmProvider = overrides.llmProvider ?? null;
const llmProvider = overrideLlmProvider || defaultLlmProvider;
const defaultLlmBaseUrl =
process.env.LLM_BASE_URL || resolveDefaultLlmBaseUrl(llmProvider);
const overrideLlmBaseUrl = overrides.llmBaseUrl ?? null;
const llmBaseUrl = overrideLlmBaseUrl || defaultLlmBaseUrl;
const defaultPipelineWebhookUrl =
process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || "";
const overridePipelineWebhookUrl = overrides.pipelineWebhookUrl ?? null;
const pipelineWebhookUrl =
overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
const defaultJobCompleteWebhookUrl =
process.env.JOB_COMPLETE_WEBHOOK_URL || "";
const overrideJobCompleteWebhookUrl = overrides.jobCompleteWebhookUrl ?? null;
const jobCompleteWebhookUrl =
overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
const { catalog } = extractProjectsFromProfile(profile);
const overrideResumeProjectsRaw = overrides.resumeProjects ?? null;
const resumeProjectsData = resolveResumeProjectsSettings({
catalog,
overrideRaw: overrideResumeProjectsRaw,
});
const ukvisajobsMaxJobsSetting = resolveSettingValue(
"ukvisajobsMaxJobs",
overrides.ukvisajobsMaxJobs,
);
const defaultUkvisajobsMaxJobs = ukvisajobsMaxJobsSetting.defaultValue;
const overrideUkvisajobsMaxJobs = ukvisajobsMaxJobsSetting.overrideValue;
const ukvisajobsMaxJobs = ukvisajobsMaxJobsSetting.value;
const adzunaMaxJobsPerTermSetting = resolveSettingValue(
"adzunaMaxJobsPerTerm",
overrides.adzunaMaxJobsPerTerm,
);
const defaultAdzunaMaxJobsPerTerm = adzunaMaxJobsPerTermSetting.defaultValue;
const overrideAdzunaMaxJobsPerTerm =
adzunaMaxJobsPerTermSetting.overrideValue;
const adzunaMaxJobsPerTerm = adzunaMaxJobsPerTermSetting.value;
const gradcrackerMaxJobsPerTermSetting = resolveSettingValue(
"gradcrackerMaxJobsPerTerm",
overrides.gradcrackerMaxJobsPerTerm,
);
const defaultGradcrackerMaxJobsPerTerm =
gradcrackerMaxJobsPerTermSetting.defaultValue;
const overrideGradcrackerMaxJobsPerTerm =
gradcrackerMaxJobsPerTermSetting.overrideValue;
const gradcrackerMaxJobsPerTerm = gradcrackerMaxJobsPerTermSetting.value;
const searchTermsSetting = resolveSettingValue(
"searchTerms",
overrides.searchTerms,
);
const defaultSearchTerms = searchTermsSetting.defaultValue;
const overrideSearchTerms = searchTermsSetting.overrideValue;
const searchTerms = searchTermsSetting.value;
const searchCitiesSetting = resolveSettingValue(
"searchCities",
overrides.searchCities ?? overrides.jobspyLocation,
);
const defaultSearchCities = searchCitiesSetting.defaultValue;
const overrideSearchCities = searchCitiesSetting.overrideValue;
const searchCities = searchCitiesSetting.value;
const jobspyResultsWantedSetting = resolveSettingValue(
"jobspyResultsWanted",
overrides.jobspyResultsWanted,
);
const defaultJobspyResultsWanted = jobspyResultsWantedSetting.defaultValue;
const overrideJobspyResultsWanted = jobspyResultsWantedSetting.overrideValue;
const jobspyResultsWanted = jobspyResultsWantedSetting.value;
const jobspyCountryIndeedSetting = resolveSettingValue(
"jobspyCountryIndeed",
overrides.jobspyCountryIndeed,
);
const defaultJobspyCountryIndeed = jobspyCountryIndeedSetting.defaultValue;
const overrideJobspyCountryIndeed = jobspyCountryIndeedSetting.overrideValue;
const jobspyCountryIndeed = jobspyCountryIndeedSetting.value;
const showSponsorInfoSetting = resolveSettingValue(
"showSponsorInfo",
overrides.showSponsorInfo,
);
const defaultShowSponsorInfo = showSponsorInfoSetting.defaultValue;
const overrideShowSponsorInfo = showSponsorInfoSetting.overrideValue;
const showSponsorInfo = showSponsorInfoSetting.value;
const chatStyleToneSetting = resolveSettingValue(
"chatStyleTone",
overrides.chatStyleTone,
);
const defaultChatStyleTone = chatStyleToneSetting.defaultValue;
const overrideChatStyleTone = chatStyleToneSetting.overrideValue;
const chatStyleTone = chatStyleToneSetting.value;
const chatStyleFormalitySetting = resolveSettingValue(
"chatStyleFormality",
overrides.chatStyleFormality,
);
const defaultChatStyleFormality = chatStyleFormalitySetting.defaultValue;
const overrideChatStyleFormality = chatStyleFormalitySetting.overrideValue;
const chatStyleFormality = chatStyleFormalitySetting.value;
const chatStyleConstraintsSetting = resolveSettingValue(
"chatStyleConstraints",
overrides.chatStyleConstraints,
);
const defaultChatStyleConstraints = chatStyleConstraintsSetting.defaultValue;
const overrideChatStyleConstraints =
chatStyleConstraintsSetting.overrideValue;
const chatStyleConstraints = chatStyleConstraintsSetting.value;
const chatStyleDoNotUseSetting = resolveSettingValue(
"chatStyleDoNotUse",
overrides.chatStyleDoNotUse,
);
const defaultChatStyleDoNotUse = chatStyleDoNotUseSetting.defaultValue;
const overrideChatStyleDoNotUse = chatStyleDoNotUseSetting.overrideValue;
const chatStyleDoNotUse = chatStyleDoNotUseSetting.value;
const backupEnabledSetting = resolveSettingValue(
"backupEnabled",
overrides.backupEnabled,
);
const defaultBackupEnabled = backupEnabledSetting.defaultValue;
const overrideBackupEnabled = backupEnabledSetting.overrideValue;
const backupEnabled = backupEnabledSetting.value;
const backupHourSetting = resolveSettingValue(
"backupHour",
overrides.backupHour,
);
const defaultBackupHour = backupHourSetting.defaultValue;
const overrideBackupHour = backupHourSetting.overrideValue;
const backupHour = backupHourSetting.value;
const backupMaxCountSetting = resolveSettingValue(
"backupMaxCount",
overrides.backupMaxCount,
);
const defaultBackupMaxCount = backupMaxCountSetting.defaultValue;
const overrideBackupMaxCount = backupMaxCountSetting.overrideValue;
const backupMaxCount = backupMaxCountSetting.value;
const penalizeMissingSalarySetting = resolveSettingValue(
"penalizeMissingSalary",
overrides.penalizeMissingSalary,
);
const defaultPenalizeMissingSalary =
penalizeMissingSalarySetting.defaultValue;
const overridePenalizeMissingSalary =
penalizeMissingSalarySetting.overrideValue;
const penalizeMissingSalary = penalizeMissingSalarySetting.value;
const missingSalaryPenaltySetting = resolveSettingValue(
"missingSalaryPenalty",
overrides.missingSalaryPenalty,
);
const defaultMissingSalaryPenalty = missingSalaryPenaltySetting.defaultValue;
const overrideMissingSalaryPenalty =
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, ...envSettings,
model, };
defaultModel,
overrideModel, const rawModel = overrides.model;
modelScorer, const modelDef = settingsRegistry.model;
overrideModelScorer, const overrideModel = modelDef.parse(rawModel);
modelTailoring, const modelValue = overrideModel ?? modelDef.default();
overrideModelTailoring,
modelProjectSelection, for (const [key, def] of Object.entries(settingsRegistry)) {
overrideModelProjectSelection, if (def.kind === "typed") {
llmProvider, let rawOverride = overrides[key as settingsRepo.SettingKey];
defaultLlmProvider, if (key === "searchCities" && !rawOverride) {
overrideLlmProvider, rawOverride = overrides.jobspyLocation; // legacy fallback
llmBaseUrl,
defaultLlmBaseUrl,
overrideLlmBaseUrl,
pipelineWebhookUrl,
defaultPipelineWebhookUrl,
overridePipelineWebhookUrl,
jobCompleteWebhookUrl,
defaultJobCompleteWebhookUrl,
overrideJobCompleteWebhookUrl,
...resumeProjectsData,
rxresumeBaseResumeId,
ukvisajobsMaxJobs,
defaultUkvisajobsMaxJobs,
overrideUkvisajobsMaxJobs,
adzunaMaxJobsPerTerm,
defaultAdzunaMaxJobsPerTerm,
overrideAdzunaMaxJobsPerTerm,
gradcrackerMaxJobsPerTerm,
defaultGradcrackerMaxJobsPerTerm,
overrideGradcrackerMaxJobsPerTerm,
searchTerms,
defaultSearchTerms,
overrideSearchTerms,
searchCities,
defaultSearchCities,
overrideSearchCities,
jobspyResultsWanted,
defaultJobspyResultsWanted,
overrideJobspyResultsWanted,
jobspyCountryIndeed,
defaultJobspyCountryIndeed,
overrideJobspyCountryIndeed,
showSponsorInfo,
defaultShowSponsorInfo,
overrideShowSponsorInfo,
chatStyleTone,
defaultChatStyleTone,
overrideChatStyleTone,
chatStyleFormality,
defaultChatStyleFormality,
overrideChatStyleFormality,
chatStyleConstraints,
defaultChatStyleConstraints,
overrideChatStyleConstraints,
chatStyleDoNotUse,
defaultChatStyleDoNotUse,
overrideChatStyleDoNotUse,
backupEnabled,
defaultBackupEnabled,
overrideBackupEnabled,
backupHour,
defaultBackupHour,
overrideBackupHour,
backupMaxCount,
defaultBackupMaxCount,
overrideBackupMaxCount,
penalizeMissingSalary,
defaultPenalizeMissingSalary,
overridePenalizeMissingSalary,
missingSalaryPenalty,
defaultMissingSalaryPenalty,
overrideMissingSalaryPenalty,
autoSkipScoreThreshold,
defaultAutoSkipScoreThreshold,
overrideAutoSkipScoreThreshold,
} as AppSettings;
} }
function resolveDefaultLlmBaseUrl(provider: string): string { const override = def.parse(rawOverride);
const normalized = provider.trim().toLowerCase(); let defaultValue = def.default();
if (normalized === "ollama") return "http://localhost:11434";
if (normalized === "lmstudio") return "http://localhost:1234"; if (key === "llmBaseUrl") {
if (normalized === "openai") { const providerOverride = settingsRegistry.llmProvider.parse(
return "https://api.openai.com"; overrides.llmProvider,
);
const provider =
providerOverride ?? settingsRegistry.llmProvider.default();
defaultValue =
process.env.LLM_BASE_URL || resolveDefaultLlmBaseUrl(provider);
} }
if (normalized === "gemini") {
return "https://generativelanguage.googleapis.com"; if (key === "resumeProjects") {
const { catalog } = extractProjectsFromProfile(profile);
const resolved = resolveResumeProjectsSettings({
catalog,
overrideRaw: rawOverride ?? null,
});
result.profileProjects = resolved.profileProjects;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: dynamic assignment for settings building
(result as any).resumeProjects = {
value: resolved.resumeProjects,
default: resolved.defaultResumeProjects,
override: resolved.overrideResumeProjects,
};
continue;
} }
return "https://openrouter.ai";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: dynamic assignment for settings building
(result as any)[key] = {
value: override ?? defaultValue,
default: defaultValue,
override,
};
} else if (def.kind === "model") {
const override = overrides[key as settingsRepo.SettingKey] ?? null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: dynamic assignment for settings building
(result as any)[key] = { value: override || modelValue, override };
} else if (def.kind === "string") {
if (!("envKey" in def) || !def.envKey) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: dynamic assignment for settings building
(result as any)[key] =
overrides[key as settingsRepo.SettingKey] ?? null;
}
}
}
return result as AppSettings;
} }

View File

@ -5,7 +5,8 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import type { ResumeProfile } from "@shared/types"; import type { ResumeProfile } from "@shared/types";
import { getSetting } from "../repositories/settings"; import { getSetting } from "../repositories/settings";
import { type JsonSchemaDefinition, LlmService } from "./llm-service"; import { LlmService } from "./llm/service";
import type { JsonSchemaDefinition } from "./llm/types";
export interface TailoredData { export interface TailoredData {
summary: string; summary: string;

View File

@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { settingsRegistry } from "./settings-registry";
describe("settingsRegistry helpers", () => {
describe("string parsing (parseNonEmptyStringOrNull)", () => {
it("returns null for undefined", () => {
expect(settingsRegistry.model.parse(undefined)).toBeNull();
});
it("returns null for empty string", () => {
expect(settingsRegistry.searchCities.parse("")).toBeNull();
});
it("returns the string for non-empty string", () => {
expect(settingsRegistry.searchCities.parse("London")).toBe("London");
});
});
describe("number parsing and clamping", () => {
it("returns null for empty/invalid values", () => {
expect(settingsRegistry.ukvisajobsMaxJobs.parse("")).toBeNull();
expect(settingsRegistry.ukvisajobsMaxJobs.parse("abc")).toBeNull();
expect(settingsRegistry.ukvisajobsMaxJobs.parse(undefined)).toBeNull();
});
it("parses valid numbers", () => {
expect(settingsRegistry.ukvisajobsMaxJobs.parse("42")).toBe(42);
});
it("clamps backupHour to 0-23", () => {
expect(settingsRegistry.backupHour.parse("25")).toBe(23);
expect(settingsRegistry.backupHour.parse("-1")).toBe(0);
expect(settingsRegistry.backupHour.parse("12")).toBe(12);
});
it("clamps backupMaxCount to 1-5", () => {
expect(settingsRegistry.backupMaxCount.parse("10")).toBe(5);
expect(settingsRegistry.backupMaxCount.parse("0")).toBe(1);
expect(settingsRegistry.backupMaxCount.parse("3")).toBe(3);
});
it("clamps missingSalaryPenalty to 0-100", () => {
expect(settingsRegistry.missingSalaryPenalty.parse("150")).toBe(100);
expect(settingsRegistry.missingSalaryPenalty.parse("-10")).toBe(0);
expect(settingsRegistry.missingSalaryPenalty.parse("50")).toBe(50);
});
});
describe("boolean (bit-bool) parsing and serialization", () => {
it("parses bit bools correctly", () => {
expect(settingsRegistry.showSponsorInfo.parse("1")).toBe(true);
expect(settingsRegistry.showSponsorInfo.parse("true")).toBe(true);
expect(settingsRegistry.showSponsorInfo.parse("0")).toBe(false);
expect(settingsRegistry.showSponsorInfo.parse("false")).toBe(false);
expect(settingsRegistry.showSponsorInfo.parse("")).toBeNull();
expect(settingsRegistry.showSponsorInfo.parse(undefined)).toBeNull();
});
it("serializes bit bools correctly", () => {
expect(settingsRegistry.showSponsorInfo.serialize(true)).toBe("1");
expect(settingsRegistry.showSponsorInfo.serialize(false)).toBe("0");
expect(settingsRegistry.showSponsorInfo.serialize(null)).toBeNull();
expect(settingsRegistry.showSponsorInfo.serialize(undefined)).toBeNull();
});
});
describe("JSON array parsing", () => {
it("parses valid JSON arrays", () => {
expect(settingsRegistry.searchTerms.parse('["dev", "engineer"]')).toEqual(
["dev", "engineer"],
);
});
it("returns null for invalid JSON or non-arrays", () => {
expect(settingsRegistry.searchTerms.parse('{"not": "array"}')).toBeNull();
expect(settingsRegistry.searchTerms.parse("invalid json")).toBeNull();
expect(settingsRegistry.searchTerms.parse("")).toBeNull();
expect(settingsRegistry.searchTerms.parse(undefined)).toBeNull();
});
it("serializes arrays back to JSON", () => {
expect(settingsRegistry.searchTerms.serialize(["dev", "engineer"])).toBe(
'["dev","engineer"]',
);
expect(settingsRegistry.searchTerms.serialize(null)).toBeNull();
});
});
describe("Resume projects settings", () => {
it("parses and serializes resume projects", () => {
const obj = {
maxProjects: 10,
lockedProjectIds: ["1", "2"],
aiSelectableProjectIds: ["3"],
};
const json = JSON.stringify(obj);
expect(settingsRegistry.resumeProjects.parse(json)).toEqual(obj);
expect(settingsRegistry.resumeProjects.parse("invalid")).toBeNull();
expect(settingsRegistry.resumeProjects.serialize(obj)).toBe(json);
expect(settingsRegistry.resumeProjects.serialize(null)).toBeNull();
});
});
});

View File

@ -0,0 +1,423 @@
import { z } from "zod";
import type { ResumeProjectsSettings } from "./types/settings";
function parseNonEmptyStringOrNull(raw: string | undefined): string | null {
return raw === undefined || raw === "" ? null : raw;
}
function parseIntOrNull(raw: string | undefined): number | null {
if (!raw) return null;
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? null : parsed;
}
function parseJsonArrayOrNull(raw: string | undefined): string[] | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as string[]) : null;
} catch {
return null;
}
}
function parseBitBoolOrNull(raw: string | undefined): boolean | null {
if (!raw) return null;
return raw === "true" || raw === "1";
}
function serializeNullableNumber(
value: number | null | undefined,
): string | null {
return value !== null && value !== undefined ? String(value) : null;
}
function serializeNullableJsonArray(
value: string[] | null | undefined,
): string | null {
return value !== null && value !== undefined ? JSON.stringify(value) : null;
}
function serializeBitBool(value: boolean | null | undefined): string | null {
if (value === null || value === undefined) return null;
return value ? "1" : "0";
}
export const resumeProjectsSchema = z.object({
maxProjects: z.number().int().min(0).max(100),
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
});
export const settingsRegistry = {
// --- Typed Settings ---
model: {
kind: "typed" as const,
schema: z.string().trim().max(200),
default: (): string =>
typeof process !== "undefined"
? process.env.MODEL || "google/gemini-3-flash-preview"
: "google/gemini-3-flash-preview",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
llmProvider: {
kind: "typed" as const,
envKey: "LLM_PROVIDER",
schema: z.preprocess(
(v) => (v === "" ? null : v),
z
.enum(["openrouter", "lmstudio", "ollama", "openai", "gemini"])
.nullable(),
),
default: (): string =>
typeof process !== "undefined"
? process.env.LLM_PROVIDER || "openrouter"
: "openrouter",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
llmBaseUrl: {
kind: "typed" as const,
envKey: "LLM_BASE_URL",
schema: z.preprocess(
(v) => (v === "" ? null : v),
z.string().trim().url().max(2000).nullable(),
),
default: (): string =>
typeof process !== "undefined" ? process.env.LLM_BASE_URL || "" : "",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
pipelineWebhookUrl: {
kind: "typed" as const,
schema: z.string().trim().max(2000),
default: (): string =>
typeof process !== "undefined"
? process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || ""
: "",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
jobCompleteWebhookUrl: {
kind: "typed" as const,
schema: z.string().trim().max(2000),
default: (): string =>
typeof process !== "undefined"
? process.env.JOB_COMPLETE_WEBHOOK_URL || ""
: "",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
resumeProjects: {
kind: "typed" as const,
schema: resumeProjectsSchema,
default: (): ResumeProjectsSettings => ({
maxProjects: 20,
lockedProjectIds: [],
aiSelectableProjectIds: [],
}),
parse: (raw: string | undefined): ResumeProjectsSettings | null => {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
},
serialize: (
value: ResumeProjectsSettings | null | undefined,
): string | null => {
return value ? JSON.stringify(value) : null;
},
},
ukvisajobsMaxJobs: {
kind: "typed" as const,
schema: z.number().int().min(1).max(1000),
default: (): number => 50,
parse: parseIntOrNull,
serialize: serializeNullableNumber,
},
adzunaMaxJobsPerTerm: {
kind: "typed" as const,
schema: z.number().int().min(1).max(1000),
default: (): number =>
parseInt(
typeof process !== "undefined"
? process.env.ADZUNA_MAX_JOBS_PER_TERM || "50"
: "50",
10,
),
parse: parseIntOrNull,
serialize: serializeNullableNumber,
},
gradcrackerMaxJobsPerTerm: {
kind: "typed" as const,
schema: z.number().int().min(1).max(1000),
default: (): number => 50,
parse: parseIntOrNull,
serialize: serializeNullableNumber,
},
searchTerms: {
kind: "typed" as const,
schema: z.array(z.string().trim().min(1).max(200)).max(100),
default: (): string[] =>
(typeof process !== "undefined"
? process.env.JOBSPY_SEARCH_TERMS || "web developer"
: "web developer"
)
.split("|")
.map((v) => v.trim())
.filter(Boolean),
parse: parseJsonArrayOrNull,
serialize: serializeNullableJsonArray,
},
searchCities: {
kind: "typed" as const,
schema: z.string().trim().max(100),
default: (): string =>
typeof process !== "undefined"
? process.env.SEARCH_CITIES || process.env.JOBSPY_LOCATION || "UK"
: "UK",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
jobspyResultsWanted: {
kind: "typed" as const,
schema: z.number().int().min(1).max(1000),
default: (): number =>
parseInt(
typeof process !== "undefined"
? process.env.JOBSPY_RESULTS_WANTED || "200"
: "200",
10,
),
parse: parseIntOrNull,
serialize: serializeNullableNumber,
},
jobspyCountryIndeed: {
kind: "typed" as const,
schema: z.string().trim().max(100),
default: (): string =>
typeof process !== "undefined"
? process.env.JOBSPY_COUNTRY_INDEED || "UK"
: "UK",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
showSponsorInfo: {
kind: "typed" as const,
schema: z.boolean(),
default: (): boolean => true,
parse: parseBitBoolOrNull,
serialize: serializeBitBool,
},
chatStyleTone: {
kind: "typed" as const,
schema: z.string().trim().max(100),
default: (): string =>
typeof process !== "undefined"
? process.env.CHAT_STYLE_TONE || "professional"
: "professional",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
chatStyleFormality: {
kind: "typed" as const,
schema: z.string().trim().max(100),
default: (): string =>
typeof process !== "undefined"
? process.env.CHAT_STYLE_FORMALITY || "medium"
: "medium",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
chatStyleConstraints: {
kind: "typed" as const,
schema: z.string().trim().max(4000),
default: (): string =>
typeof process !== "undefined"
? process.env.CHAT_STYLE_CONSTRAINTS || ""
: "",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
chatStyleDoNotUse: {
kind: "typed" as const,
schema: z.string().trim().max(1000),
default: (): string =>
typeof process !== "undefined"
? process.env.CHAT_STYLE_DO_NOT_USE || ""
: "",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
backupEnabled: {
kind: "typed" as const,
schema: z.boolean(),
default: (): boolean => false,
parse: parseBitBoolOrNull,
serialize: serializeBitBool,
},
backupHour: {
kind: "typed" as const,
schema: z.number().int().min(0).max(23),
default: (): number => 2,
parse: (raw: string | undefined): number | null => {
const parsed = raw ? parseInt(raw, 10) : NaN;
if (Number.isNaN(parsed)) return null;
return Math.min(23, Math.max(0, parsed));
},
serialize: serializeNullableNumber,
},
backupMaxCount: {
kind: "typed" as const,
schema: z.number().int().min(1).max(5),
default: (): number => 5,
parse: (raw: string | undefined): number | null => {
const parsed = raw ? parseInt(raw, 10) : NaN;
if (Number.isNaN(parsed)) return null;
return Math.min(5, Math.max(1, parsed));
},
serialize: serializeNullableNumber,
},
penalizeMissingSalary: {
kind: "typed" as const,
schema: z.boolean(),
default: (): boolean => {
if (typeof process === "undefined") return false;
const v = process.env.PENALIZE_MISSING_SALARY || "0";
return v === "1" || v.toLowerCase() === "true";
},
parse: parseBitBoolOrNull,
serialize: serializeBitBool,
},
missingSalaryPenalty: {
kind: "typed" as const,
schema: z.number().int().min(0).max(100),
default: (): number => {
if (typeof process === "undefined") return 10;
const raw = process.env.MISSING_SALARY_PENALTY;
if (!raw) return 10;
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? 10 : Math.min(100, Math.max(0, parsed));
},
parse: (raw: string | undefined): number | null => {
const parsed = raw ? parseInt(raw, 10) : NaN;
return Number.isNaN(parsed) ? null : Math.min(100, Math.max(0, parsed));
},
serialize: serializeNullableNumber,
},
autoSkipScoreThreshold: {
kind: "typed" as const,
schema: z.number().int().min(0).max(100),
default: (): number | null => null,
parse: (raw: string | undefined): number | null => {
if (!raw || raw === "null" || raw === "") return null;
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? null : Math.min(100, Math.max(0, parsed));
},
serialize: (value: number | null | undefined): string | null => {
return value === null || value === undefined ? null : String(value);
},
},
// --- Model Variants ---
modelScorer: {
kind: "model" as const,
schema: z.string().trim().max(200),
},
modelTailoring: {
kind: "model" as const,
schema: z.string().trim().max(200),
},
modelProjectSelection: {
kind: "model" as const,
schema: z.string().trim().max(200),
},
// --- Simple Strings ---
rxresumeBaseResumeId: {
kind: "string" as const,
schema: z.string().trim().max(200),
},
rxresumeEmail: {
kind: "string" as const,
envKey: "RXRESUME_EMAIL",
schema: z.string().trim().max(200),
},
ukvisajobsEmail: {
kind: "string" as const,
envKey: "UKVISAJOBS_EMAIL",
schema: z.string().trim().max(200),
},
adzunaAppId: {
kind: "string" as const,
envKey: "ADZUNA_APP_ID",
schema: z.string().trim().max(200),
},
basicAuthUser: {
kind: "string" as const,
envKey: "BASIC_AUTH_USER",
schema: z.string().trim().max(200),
},
// --- Secrets ---
llmApiKey: {
kind: "secret" as const,
envKey: "LLM_API_KEY",
schema: z.string().trim().max(2000),
},
rxresumePassword: {
kind: "secret" as const,
envKey: "RXRESUME_PASSWORD",
schema: z.string().trim().max(2000),
},
ukvisajobsPassword: {
kind: "secret" as const,
envKey: "UKVISAJOBS_PASSWORD",
schema: z.string().trim().max(2000),
},
adzunaAppKey: {
kind: "secret" as const,
envKey: "ADZUNA_APP_KEY",
schema: z.string().trim().max(2000),
},
basicAuthPassword: {
kind: "secret" as const,
envKey: "BASIC_AUTH_PASSWORD",
schema: z.string().trim().max(2000),
},
webhookSecret: {
kind: "secret" as const,
envKey: "WEBHOOK_SECRET",
schema: z.string().trim().max(2000),
},
// --- Aliases ---
jobspyLocation: {
kind: "alias" as const,
schema: z.string().trim().max(100),
target: "searchCities" as const,
},
// --- Virtual ---
enableBasicAuth: {
kind: "virtual" as const,
schema: z.boolean(),
},
} as const;
export type SettingsRegistry = typeof settingsRegistry;
export type SettingsRegistryKey = keyof SettingsRegistry;

View File

@ -1,104 +1,39 @@
import { z } from "zod"; import { z } from "zod";
import { resumeProjectsSchema, settingsRegistry } from "./settings-registry";
export const resumeProjectsSchema = z.object({ export { resumeProjectsSchema };
maxProjects: z.number().int().min(0).max(100),
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
});
export const updateSettingsSchema = z type RegistryKeys = keyof typeof settingsRegistry;
.object({
model: z.string().trim().max(200).nullable().optional(), type UpdateSchemaShape = {
modelScorer: z.string().trim().max(200).nullable().optional(), [K in RegistryKeys]: (typeof settingsRegistry)[K] extends {
modelTailoring: z.string().trim().max(200).nullable().optional(), schema: z.ZodType<infer U, infer D, infer I>;
modelProjectSelection: z.string().trim().max(200).nullable().optional(), }
llmProvider: z ? K extends "enableBasicAuth"
.preprocess( ? z.ZodOptional<z.ZodType<U, D, I>>
(value) => (value === "" ? null : value), : z.ZodOptional<z.ZodNullable<z.ZodType<U, D, I>>>
z : z.ZodTypeAny;
.enum(["openrouter", "lmstudio", "ollama", "openai", "gemini"]) };
.nullable(),
) const shape = Object.fromEntries(
.optional(), Object.entries(settingsRegistry).map(([key, def]) => {
llmBaseUrl: z // eslint-disable-next-line @typescript-eslint/no-explicit-any
.preprocess( // biome-ignore lint/suspicious/noExplicitAny: def is dynamic
(value) => (value === "" ? null : value), const fieldSchema = (def as any).schema as z.ZodTypeAny;
z.string().trim().url().max(2000).nullable(), if (key === "enableBasicAuth") {
) return [key, fieldSchema.optional()];
.optional(), }
llmApiKey: z.string().trim().max(2000).nullable().optional(), return [key, fieldSchema.nullable().optional()];
pipelineWebhookUrl: z.string().trim().max(2000).nullable().optional(), }),
jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(), ) as unknown as UpdateSchemaShape;
resumeProjects: resumeProjectsSchema.nullable().optional(),
rxresumeBaseResumeId: z.string().trim().max(200).nullable().optional(), export const updateSettingsSchema = z.object(shape).superRefine((data, ctx) => {
ukvisajobsMaxJobs: z.number().int().min(1).max(1000).nullable().optional(),
adzunaMaxJobsPerTerm: z
.number()
.int()
.min(1)
.max(1000)
.nullable()
.optional(),
gradcrackerMaxJobsPerTerm: z
.number()
.int()
.min(1)
.max(1000)
.nullable()
.optional(),
searchTerms: z
.array(z.string().trim().min(1).max(200))
.max(100)
.nullable()
.optional(),
searchCities: z.string().trim().max(100).nullable().optional(),
// Deprecated legacy key; accepted for backward compatibility.
jobspyLocation: z.string().trim().max(100).nullable().optional(),
jobspyResultsWanted: z
.number()
.int()
.min(1)
.max(1000)
.nullable()
.optional(),
jobspyCountryIndeed: z.string().trim().max(100).nullable().optional(),
showSponsorInfo: z.boolean().nullable().optional(),
chatStyleTone: z.string().trim().max(100).nullable().optional(),
chatStyleFormality: z.string().trim().max(100).nullable().optional(),
chatStyleConstraints: z.string().trim().max(4000).nullable().optional(),
chatStyleDoNotUse: z.string().trim().max(1000).nullable().optional(),
rxresumeEmail: z.string().trim().max(200).nullable().optional(),
rxresumePassword: z.string().trim().max(2000).nullable().optional(),
basicAuthUser: z.string().trim().max(200).nullable().optional(),
basicAuthPassword: z.string().trim().max(2000).nullable().optional(),
ukvisajobsEmail: z.string().trim().max(200).nullable().optional(),
ukvisajobsPassword: z.string().trim().max(2000).nullable().optional(),
adzunaAppId: z.string().trim().max(200).nullable().optional(),
adzunaAppKey: z.string().trim().max(2000).nullable().optional(),
webhookSecret: z.string().trim().max(2000).nullable().optional(),
enableBasicAuth: z.boolean().optional(),
backupEnabled: z.boolean().nullable().optional(),
backupHour: z.number().int().min(0).max(23).nullable().optional(),
backupMaxCount: z.number().int().min(1).max(5).nullable().optional(),
penalizeMissingSalary: z.boolean().nullable().optional(),
missingSalaryPenalty: z
.number()
.int()
.min(0)
.max(100)
.nullable()
.optional(),
autoSkipScoreThreshold: z
.number()
.int()
.min(0)
.max(100)
.nullable()
.optional(),
})
.superRefine((data, ctx) => {
if (data.enableBasicAuth) { if (data.enableBasicAuth) {
if (!data.basicAuthUser || data.basicAuthUser.trim() === "") { if (
!data.basicAuthUser ||
typeof data.basicAuthUser !== "string" ||
data.basicAuthUser.trim() === ""
) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: "Username is required when basic auth is enabled", message: "Username is required when basic auth is enabled",

View File

@ -125,76 +125,57 @@ export const createResumeProjectCatalogItem = (
export const createAppSettings = ( export const createAppSettings = (
overrides: Partial<AppSettings> = {}, overrides: Partial<AppSettings> = {},
): AppSettings => ({ ): AppSettings => ({
model: "gpt-4o", model: { value: "gpt-4o", default: "gpt-4o", override: null },
defaultModel: "gpt-4o", modelScorer: { value: "gpt-4o", override: null },
overrideModel: null, modelTailoring: { value: "gpt-4o", override: null },
modelScorer: "gpt-4o", modelProjectSelection: { value: "gpt-4o", override: null },
overrideModelScorer: null, llmProvider: { value: "openai", default: "openai", override: null },
modelTailoring: "gpt-4o", llmBaseUrl: {
overrideModelTailoring: null, value: "https://api.openai.com/v1",
modelProjectSelection: "gpt-4o", default: "https://api.openai.com/v1",
overrideModelProjectSelection: null, override: null,
llmProvider: "openai", },
defaultLlmProvider: "openai", pipelineWebhookUrl: { value: "", default: "", override: null },
overrideLlmProvider: null, jobCompleteWebhookUrl: { value: "", default: "", override: null },
llmBaseUrl: "https://api.openai.com/v1",
defaultLlmBaseUrl: "https://api.openai.com/v1",
overrideLlmBaseUrl: null,
pipelineWebhookUrl: "",
defaultPipelineWebhookUrl: "",
overridePipelineWebhookUrl: null,
jobCompleteWebhookUrl: "",
defaultJobCompleteWebhookUrl: "",
overrideJobCompleteWebhookUrl: null,
profileProjects: [], profileProjects: [],
resumeProjects: { resumeProjects: {
value: { maxProjects: 3, lockedProjectIds: [], aiSelectableProjectIds: [] },
default: {
maxProjects: 3, maxProjects: 3,
lockedProjectIds: [], lockedProjectIds: [],
aiSelectableProjectIds: [], aiSelectableProjectIds: [],
}, },
defaultResumeProjects: { override: null,
maxProjects: 3,
lockedProjectIds: [],
aiSelectableProjectIds: [],
}, },
overrideResumeProjects: null,
rxresumeBaseResumeId: null, rxresumeBaseResumeId: null,
ukvisajobsMaxJobs: 50, ukvisajobsMaxJobs: { value: 50, default: 50, override: null },
defaultUkvisajobsMaxJobs: 50, adzunaMaxJobsPerTerm: { value: 50, default: 50, override: null },
overrideUkvisajobsMaxJobs: null, gradcrackerMaxJobsPerTerm: { value: 50, default: 50, override: null },
adzunaMaxJobsPerTerm: 50, searchTerms: {
defaultAdzunaMaxJobsPerTerm: 50, value: ["Software Engineer"],
overrideAdzunaMaxJobsPerTerm: null, default: ["Software Engineer"],
gradcrackerMaxJobsPerTerm: 50, override: null,
defaultGradcrackerMaxJobsPerTerm: 50, },
overrideGradcrackerMaxJobsPerTerm: null, searchCities: {
searchTerms: ["Software Engineer"], value: "United Kingdom",
defaultSearchTerms: ["Software Engineer"], default: "United Kingdom",
overrideSearchTerms: null, override: null,
searchCities: "United Kingdom", },
defaultSearchCities: "United Kingdom", jobspyResultsWanted: { value: 20, default: 20, override: null },
overrideSearchCities: null, jobspyCountryIndeed: {
jobspyResultsWanted: 20, value: "united kingdom",
defaultJobspyResultsWanted: 20, default: "united kingdom",
overrideJobspyResultsWanted: null, override: null,
jobspyCountryIndeed: "united kingdom", },
defaultJobspyCountryIndeed: "united kingdom", showSponsorInfo: { value: true, default: true, override: null },
overrideJobspyCountryIndeed: null, chatStyleTone: {
showSponsorInfo: true, value: "professional",
defaultShowSponsorInfo: true, default: "professional",
overrideShowSponsorInfo: null, override: null,
chatStyleTone: "professional", },
defaultChatStyleTone: "professional", chatStyleFormality: { value: "medium", default: "medium", override: null },
overrideChatStyleTone: null, chatStyleConstraints: { value: "", default: "", override: null },
chatStyleFormality: "medium", chatStyleDoNotUse: { value: "", default: "", override: null },
defaultChatStyleFormality: "medium",
overrideChatStyleFormality: null,
chatStyleConstraints: "",
defaultChatStyleConstraints: "",
overrideChatStyleConstraints: null,
chatStyleDoNotUse: "",
defaultChatStyleDoNotUse: "",
overrideChatStyleDoNotUse: null,
llmApiKeyHint: null, llmApiKeyHint: null,
rxresumeEmail: null, rxresumeEmail: null,
rxresumePasswordHint: null, rxresumePasswordHint: null,
@ -206,23 +187,11 @@ export const createAppSettings = (
adzunaAppKeyHint: null, adzunaAppKeyHint: null,
webhookSecretHint: null, webhookSecretHint: null,
basicAuthActive: false, basicAuthActive: false,
backupEnabled: false, backupEnabled: { value: false, default: false, override: null },
defaultBackupEnabled: false, backupHour: { value: 3, default: 3, override: null },
overrideBackupEnabled: null, backupMaxCount: { value: 7, default: 7, override: null },
backupHour: 3, penalizeMissingSalary: { value: false, default: false, override: null },
defaultBackupHour: 3, missingSalaryPenalty: { value: 10, default: 10, override: null },
overrideBackupHour: null, autoSkipScoreThreshold: { value: null, default: null, override: null },
backupMaxCount: 7,
defaultBackupMaxCount: 7,
overrideBackupMaxCount: null,
penalizeMissingSalary: false,
defaultPenalizeMissingSalary: false,
overridePenalizeMissingSalary: null,
missingSalaryPenalty: 10,
defaultMissingSalaryPenalty: 10,
overrideMissingSalaryPenalty: null,
autoSkipScoreThreshold: null,
defaultAutoSkipScoreThreshold: null,
overrideAutoSkipScoreThreshold: null,
...overrides, ...overrides,
}); });

File diff suppressed because it is too large Load Diff

122
shared/src/types/api.ts Normal file
View File

@ -0,0 +1,122 @@
export interface ApiMeta {
requestId: string;
simulated?: boolean;
blockedReason?: string;
}
export interface ApiErrorPayload {
code: string;
message: string;
details?: unknown;
}
export type ApiResponse<T> =
| {
ok: true;
data: T;
meta?: ApiMeta;
}
| {
ok: false;
error: ApiErrorPayload;
meta: ApiMeta;
};
export interface TracerAnalyticsTimeseriesPoint {
day: string; // YYYY-MM-DD
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
}
export interface TracerAnalyticsTopJob {
jobId: string;
title: string;
employer: string;
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
lastClickedAt: number | null;
}
export interface TracerAnalyticsTopLink {
tracerLinkId: string;
token: string;
jobId: string;
title: string;
employer: string;
sourcePath: string;
sourceLabel: string;
destinationUrl: string;
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
lastClickedAt: number | null;
}
export interface TracerAnalyticsResponse {
filters: {
jobId: string | null;
from: number | null;
to: number | null;
includeBots: boolean;
limit: number;
};
totals: {
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
};
timeSeries: TracerAnalyticsTimeseriesPoint[];
topJobs: TracerAnalyticsTopJob[];
topLinks: TracerAnalyticsTopLink[];
}
export interface JobTracerLinkAnalyticsItem {
tracerLinkId: string;
token: string;
sourcePath: string;
sourceLabel: string;
destinationUrl: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
lastClickedAt: number | null;
}
export interface JobTracerLinksResponse {
job: {
id: string;
title: string;
employer: string;
tracerLinksEnabled: boolean;
};
totals: {
links: number;
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
};
links: JobTracerLinkAnalyticsItem[];
}
export type TracerReadinessStatus = "ready" | "unconfigured" | "unavailable";
export interface TracerReadinessResponse {
status: TracerReadinessStatus;
canEnable: boolean;
publicBaseUrl: string | null;
healthUrl: string | null;
checkedAt: number;
lastSuccessAt: number | null;
reason: string | null;
}

95
shared/src/types/chat.ts Normal file
View File

@ -0,0 +1,95 @@
export const JOB_CHAT_MESSAGE_ROLES = [
"system",
"user",
"assistant",
"tool",
] as const;
export type JobChatMessageRole = (typeof JOB_CHAT_MESSAGE_ROLES)[number];
export const JOB_CHAT_MESSAGE_STATUSES = [
"complete",
"partial",
"cancelled",
"failed",
] as const;
export type JobChatMessageStatus = (typeof JOB_CHAT_MESSAGE_STATUSES)[number];
export const JOB_CHAT_RUN_STATUSES = [
"running",
"completed",
"cancelled",
"failed",
] as const;
export type JobChatRunStatus = (typeof JOB_CHAT_RUN_STATUSES)[number];
export interface JobChatThread {
id: string;
jobId: string;
title: string | null;
createdAt: string;
updatedAt: string;
lastMessageAt: string | null;
}
export interface JobChatMessage {
id: string;
threadId: string;
jobId: string;
role: JobChatMessageRole;
content: string;
status: JobChatMessageStatus;
tokensIn: number | null;
tokensOut: number | null;
version: number;
replacesMessageId: string | null;
createdAt: string;
updatedAt: string;
}
export interface JobChatRun {
id: string;
threadId: string;
jobId: string;
status: JobChatRunStatus;
model: string | null;
provider: string | null;
errorCode: string | null;
errorMessage: string | null;
startedAt: number;
completedAt: number | null;
requestId: string | null;
createdAt: string;
updatedAt: string;
}
export type JobChatStreamEvent =
| {
type: "ready";
runId: string;
threadId: string;
messageId: string;
requestId: string;
}
| {
type: "delta";
runId: string;
messageId: string;
delta: string;
}
| {
type: "completed";
runId: string;
message: JobChatMessage;
}
| {
type: "cancelled";
runId: string;
message: JobChatMessage;
}
| {
type: "error";
runId: string;
code: string;
message: string;
requestId: string;
};

322
shared/src/types/jobs.ts Normal file
View File

@ -0,0 +1,322 @@
export type JobStatus =
| "discovered" // Crawled but not processed
| "processing" // Currently generating resume
| "ready" // PDF generated, waiting for user to apply
| "applied" // Application sent
| "in_progress" // In process beyond initial application
| "skipped" // User skipped this job
| "expired"; // Deadline passed
export const APPLICATION_STAGES = [
"applied",
"recruiter_screen",
"assessment",
"hiring_manager_screen",
"technical_interview",
"onsite",
"offer",
"closed",
] as const;
export type ApplicationStage = (typeof APPLICATION_STAGES)[number];
export const STAGE_LABELS: Record<ApplicationStage, string> = {
applied: "Applied",
recruiter_screen: "Recruiter Screen",
assessment: "Assessment",
hiring_manager_screen: "Team Match",
technical_interview: "Technical Interview",
onsite: "Final Round",
offer: "Offer",
closed: "Closed",
};
export type StageTransitionTarget = ApplicationStage | "no_change";
export const APPLICATION_OUTCOMES = [
"offer_accepted",
"offer_declined",
"rejected",
"withdrawn",
"no_response",
"ghosted",
] as const;
export type JobOutcome = (typeof APPLICATION_OUTCOMES)[number];
export const APPLICATION_TASK_TYPES = [
"prep",
"todo",
"follow_up",
"check_status",
] as const;
export type ApplicationTaskType = (typeof APPLICATION_TASK_TYPES)[number];
export const INTERVIEW_TYPES = [
"recruiter_screen",
"technical",
"onsite",
"panel",
"behavioral",
"final",
] as const;
export type InterviewType = (typeof INTERVIEW_TYPES)[number];
export const INTERVIEW_OUTCOMES = [
"pass",
"fail",
"pending",
"cancelled",
] as const;
export type InterviewOutcome = (typeof INTERVIEW_OUTCOMES)[number];
export interface StageEventMetadata {
note?: string | null;
actor?: "system" | "user";
groupId?: string | null;
groupLabel?: string | null;
eventLabel?: string | null;
externalUrl?: string | null;
reasonCode?: string | null;
eventType?: "interview_log" | "status_update" | "note" | null;
}
export interface StageEvent {
id: string;
applicationId: string;
title: string;
groupId: string | null;
fromStage: ApplicationStage | null;
toStage: ApplicationStage;
occurredAt: number;
metadata: StageEventMetadata | null;
outcome: JobOutcome | null;
}
export interface ApplicationTask {
id: string;
applicationId: string;
type: ApplicationTaskType;
title: string;
dueDate: number | null;
isCompleted: boolean;
notes: string | null;
}
export interface Interview {
id: string;
applicationId: string;
scheduledAt: number;
durationMins: number | null;
type: InterviewType;
outcome: InterviewOutcome | null;
}
export type JobSource =
| "gradcracker"
| "indeed"
| "linkedin"
| "glassdoor"
| "ukvisajobs"
| "adzuna"
| "hiringcafe"
| "manual";
export interface Job {
id: string;
// Source / provenance
source: JobSource;
sourceJobId: string | null; // External ID (if provided)
jobUrlDirect: string | null; // Source-provided direct URL (if provided)
datePosted: string | null; // Source-provided posting date (if provided)
// From crawler (normalized)
title: string;
employer: string;
employerUrl: string | null;
jobUrl: string; // Gradcracker listing URL
applicationLink: string | null; // Actual application URL
disciplines: string | null;
deadline: string | null;
salary: string | null;
location: string | null;
degreeRequired: string | null;
starting: string | null;
jobDescription: string | null;
// Orchestrator enrichments
status: JobStatus;
outcome: JobOutcome | null;
closedAt: number | null;
suitabilityScore: number | null; // 0-100 AI-generated score
suitabilityReason: string | null; // AI explanation
tailoredSummary: string | null; // Generated resume summary
tailoredHeadline: string | null; // Generated resume headline
tailoredSkills: string | null; // Generated resume skills (JSON)
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
pdfPath: string | null; // Path to generated PDF
tracerLinksEnabled: boolean; // Rewrite outbound resume links to tracer links on next PDF generation
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
// JobSpy fields (nullable for non-JobSpy sources)
jobType: string | null;
salarySource: string | null;
salaryInterval: string | null;
salaryMinAmount: number | null;
salaryMaxAmount: number | null;
salaryCurrency: string | null;
isRemote: boolean | null;
jobLevel: string | null;
jobFunction: string | null;
listingType: string | null;
emails: string | null;
companyIndustry: string | null;
companyLogo: string | null;
companyUrlDirect: string | null;
companyAddresses: string | null;
companyNumEmployees: string | null;
companyRevenue: string | null;
companyDescription: string | null;
skills: string | null;
experienceRange: string | null;
companyRating: number | null;
companyReviewsCount: number | null;
vacancyCount: number | null;
workFromHomeType: string | null;
// Timestamps
discoveredAt: string;
processedAt: string | null;
appliedAt: string | null;
createdAt: string;
updatedAt: string;
}
export type JobListItem = Pick<
Job,
| "id"
| "source"
| "title"
| "employer"
| "jobUrl"
| "applicationLink"
| "datePosted"
| "deadline"
| "salary"
| "location"
| "status"
| "outcome"
| "closedAt"
| "suitabilityScore"
| "sponsorMatchScore"
| "jobType"
| "jobFunction"
| "salaryMinAmount"
| "salaryMaxAmount"
| "salaryCurrency"
| "discoveredAt"
| "appliedAt"
| "updatedAt"
>;
export interface CreateJobInput {
source: JobSource;
title: string;
employer: string;
employerUrl?: string;
jobUrl: string;
applicationLink?: string;
disciplines?: string;
deadline?: string;
salary?: string;
location?: string;
degreeRequired?: string;
starting?: string;
jobDescription?: string;
// JobSpy fields (optional)
sourceJobId?: string;
jobUrlDirect?: string;
datePosted?: string;
jobType?: string;
salarySource?: string;
salaryInterval?: string;
salaryMinAmount?: number;
salaryMaxAmount?: number;
salaryCurrency?: string;
isRemote?: boolean;
jobLevel?: string;
jobFunction?: string;
listingType?: string;
emails?: string;
companyIndustry?: string;
companyLogo?: string;
companyUrlDirect?: string;
companyAddresses?: string;
companyNumEmployees?: string;
companyRevenue?: string;
companyDescription?: string;
skills?: string;
experienceRange?: string;
companyRating?: number;
companyReviewsCount?: number;
vacancyCount?: number;
workFromHomeType?: string;
}
export interface ManualJobDraft {
title?: string;
employer?: string;
jobUrl?: string;
applicationLink?: string;
location?: string;
salary?: string;
deadline?: string;
jobDescription?: string;
jobType?: string;
jobLevel?: string;
jobFunction?: string;
disciplines?: string;
degreeRequired?: string;
starting?: string;
}
export interface ManualJobInferenceResponse {
job: ManualJobDraft;
warning?: string | null;
}
export interface ManualJobFetchResponse {
content: string;
url: string;
}
export interface UpdateJobInput {
title?: string;
employer?: string;
jobUrl?: string;
applicationLink?: string | null;
location?: string | null;
salary?: string | null;
deadline?: string | null;
status?: JobStatus;
outcome?: JobOutcome | null;
closedAt?: number | null;
jobDescription?: string | null;
suitabilityScore?: number;
suitabilityReason?: string;
tailoredSummary?: string;
tailoredHeadline?: string;
tailoredSkills?: string;
selectedProjectIds?: string;
pdfPath?: string;
tracerLinksEnabled?: boolean;
appliedAt?: string;
sponsorMatchScore?: number;
sponsorMatchNames?: string;
}

View File

@ -0,0 +1,124 @@
import type { Job, JobSource, JobStatus } from "./jobs";
export interface PipelineConfig {
topN: number; // Number of top jobs to process
minSuitabilityScore: number; // Minimum score to auto-process
sources: JobSource[]; // Job sources to crawl
outputDir: string; // Directory for generated PDFs
enableCrawling?: boolean;
enableScoring?: boolean;
enableImporting?: boolean;
enableAutoTailoring?: boolean;
}
export interface PipelineRun {
id: string;
startedAt: string;
completedAt: string | null;
status: "running" | "completed" | "failed" | "cancelled";
jobsDiscovered: number;
jobsProcessed: number;
errorMessage: string | null;
}
export interface PipelineStatusResponse {
isRunning: boolean;
lastRun: PipelineRun | null;
nextScheduledRun: string | null;
}
export interface JobsListResponse<TJob = Job> {
jobs: TJob[];
total: number;
byStatus: Record<JobStatus, number>;
revision: string;
}
export interface JobsRevisionResponse {
revision: string;
latestUpdatedAt: string | null;
total: number;
statusFilter: string | null;
}
export type JobAction = "skip" | "move_to_ready" | "rescore";
export type JobActionRequest =
| {
action: "skip" | "rescore";
jobIds: string[];
}
| {
action: "move_to_ready";
jobIds: string[];
options?: {
force?: boolean;
};
};
export type JobActionResult =
| {
jobId: string;
ok: true;
job: Job;
}
| {
jobId: string;
ok: false;
error: {
code: string;
message: string;
};
};
export interface JobActionResponse {
action: JobAction;
requested: number;
succeeded: number;
failed: number;
results: JobActionResult[];
}
export type JobActionStreamEvent =
| {
type: "started";
action: JobAction;
requested: number;
completed: number;
succeeded: number;
failed: number;
requestId: string;
}
| {
type: "progress";
action: JobAction;
requested: number;
completed: number;
succeeded: number;
failed: number;
result: JobActionResult;
requestId: string;
}
| {
type: "completed";
action: JobAction;
requested: number;
completed: number;
succeeded: number;
failed: number;
results: JobActionResult[];
requestId: string;
}
| {
type: "error";
code: string;
message: string;
requestId: string;
};
export interface BackupInfo {
filename: string;
type: "auto" | "manual";
size: number;
createdAt: string;
}

View File

@ -0,0 +1,208 @@
export const POST_APPLICATION_PROVIDERS = ["gmail", "imap"] as const;
export type PostApplicationProvider =
(typeof POST_APPLICATION_PROVIDERS)[number];
export const POST_APPLICATION_PROVIDER_ACTIONS = [
"connect",
"status",
"sync",
"disconnect",
] as const;
export type PostApplicationProviderAction =
(typeof POST_APPLICATION_PROVIDER_ACTIONS)[number];
export const POST_APPLICATION_INTEGRATION_STATUSES = [
"disconnected",
"connected",
"error",
] as const;
export type PostApplicationIntegrationStatus =
(typeof POST_APPLICATION_INTEGRATION_STATUSES)[number];
export const POST_APPLICATION_SYNC_RUN_STATUSES = [
"running",
"completed",
"failed",
"cancelled",
] as const;
export type PostApplicationSyncRunStatus =
(typeof POST_APPLICATION_SYNC_RUN_STATUSES)[number];
export const POST_APPLICATION_RELEVANCE_DECISIONS = [
"relevant",
"not_relevant",
"needs_llm",
] as const;
export type PostApplicationRelevanceDecision =
(typeof POST_APPLICATION_RELEVANCE_DECISIONS)[number];
export const POST_APPLICATION_MESSAGE_TYPES = [
"interview",
"rejection",
"offer",
"update",
"other",
] as const;
export type PostApplicationMessageType =
(typeof POST_APPLICATION_MESSAGE_TYPES)[number];
export const POST_APPLICATION_ROUTER_STAGE_TARGETS = [
"no_change",
"applied",
"recruiter_screen",
"assessment",
"hiring_manager_screen",
"technical_interview",
"onsite",
"offer",
"rejected",
"withdrawn",
"closed",
] as const;
export type PostApplicationRouterStageTarget =
(typeof POST_APPLICATION_ROUTER_STAGE_TARGETS)[number];
export const POST_APPLICATION_PROCESSING_STATUSES = [
"auto_linked",
"pending_user",
"manual_linked",
"ignored",
] as const;
export type PostApplicationProcessingStatus =
(typeof POST_APPLICATION_PROCESSING_STATUSES)[number];
export interface PostApplicationIntegration {
id: string;
provider: PostApplicationProvider;
accountKey: string;
displayName: string | null;
status: PostApplicationIntegrationStatus;
credentials: Record<string, unknown> | null;
lastConnectedAt: number | null;
lastSyncedAt: number | null;
lastError: string | null;
createdAt: string;
updatedAt: string;
}
export interface PostApplicationSyncRun {
id: string;
provider: PostApplicationProvider;
accountKey: string;
integrationId: string | null;
status: PostApplicationSyncRunStatus;
startedAt: number;
completedAt: number | null;
messagesDiscovered: number;
messagesRelevant: number;
messagesClassified: number;
messagesMatched: number;
messagesApproved: number;
messagesDenied: number;
messagesErrored: number;
errorCode: string | null;
errorMessage: string | null;
createdAt: string;
updatedAt: string;
}
export interface PostApplicationMessage {
id: string;
provider: PostApplicationProvider;
accountKey: string;
integrationId: string | null;
syncRunId: string | null;
externalMessageId: string;
externalThreadId: string | null;
fromAddress: string;
fromDomain: string | null;
senderName: string | null;
subject: string;
receivedAt: number;
snippet: string;
classificationLabel: string | null;
classificationConfidence: number | null;
classificationPayload: Record<string, unknown> | null;
relevanceLlmScore: number | null;
relevanceDecision: PostApplicationRelevanceDecision;
matchedJobId: string | null;
matchConfidence: number | null;
stageTarget: PostApplicationRouterStageTarget | null;
messageType: PostApplicationMessageType;
stageEventPayload: Record<string, unknown> | null;
processingStatus: PostApplicationProcessingStatus;
decidedAt: number | null;
decidedBy: string | null;
errorCode: string | null;
errorMessage: string | null;
createdAt: string;
updatedAt: string;
}
export interface PostApplicationProviderActionConnectRequest {
accountKey?: string;
payload?: Record<string, unknown>;
}
export interface PostApplicationProviderActionSyncRequest {
accountKey?: string;
maxMessages?: number;
searchDays?: number;
}
export interface PostApplicationProviderStatus {
provider: PostApplicationProvider;
accountKey: string;
connected: boolean;
integration: PostApplicationIntegration | null;
}
export interface PostApplicationProviderActionResponse {
provider: PostApplicationProvider;
action: PostApplicationProviderAction;
accountKey: string;
status: PostApplicationProviderStatus;
message?: string;
}
export interface PostApplicationInboxItem {
message: PostApplicationMessage;
matchedJob?: {
id: string;
title: string;
employer: string;
} | null;
}
export type PostApplicationAction = "approve" | "deny";
export interface PostApplicationActionRequest {
action: PostApplicationAction;
provider: PostApplicationProvider;
accountKey: string;
}
export type PostApplicationActionResult =
| {
messageId: string;
ok: true;
message: PostApplicationMessage;
stageEventId?: string | null;
}
| {
messageId: string;
ok: false;
error: {
code: string;
message: string;
};
};
export interface PostApplicationActionResponse {
action: PostApplicationAction;
requested: number;
succeeded: number;
failed: number;
skipped: number;
results: PostApplicationActionResult[];
}

View File

@ -0,0 +1,164 @@
export interface ResumeProjectCatalogItem {
id: string;
name: string;
description: string;
date: string;
isVisibleInBase: boolean;
}
export interface ResumeProjectsSettings {
maxProjects: number;
lockedProjectIds: string[];
aiSelectableProjectIds: string[];
}
export interface ResumeProfile {
basics?: {
name?: string;
label?: string;
image?: string;
email?: string;
phone?: string;
url?: string;
summary?: string;
headline?: string;
location?: {
address?: string;
postalCode?: string;
city?: string;
countryCode?: string;
region?: string;
};
profiles?: Array<{
network?: string;
username?: string;
url?: string;
}>;
};
sections?: {
summary?: {
id?: string;
visible?: boolean;
name?: string;
content?: string;
};
skills?: {
id?: string;
visible?: boolean;
name?: string;
items?: Array<{
id: string;
name: string;
description: string;
level: number;
keywords: string[];
visible: boolean;
}>;
};
projects?: {
id?: string;
visible?: boolean;
name?: string;
items?: Array<{
id: string;
name: string;
description: string;
date: string;
summary: string;
visible: boolean;
keywords?: string[];
url?: string;
}>;
};
experience?: {
id?: string;
visible?: boolean;
name?: string;
items?: Array<{
id: string;
company: string;
position: string;
location: string;
date: string;
summary: string;
visible: boolean;
}>;
};
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface ProfileStatusResponse {
exists: boolean;
error: string | null;
}
export interface ValidationResult {
valid: boolean;
message: string | null;
}
export interface DemoInfoResponse {
demoMode: boolean;
resetCadenceHours: number;
lastResetAt: string | null;
nextResetAt: string | null;
baselineVersion: string | null;
baselineName: string | null;
}
export type Resolved<T> = { value: T; default: T; override: T | null };
export type ModelResolved = { value: string; override: string | null };
export interface AppSettings {
// Typed settings (Resolved):
model: Resolved<string>;
llmProvider: Resolved<string>;
llmBaseUrl: Resolved<string>;
pipelineWebhookUrl: Resolved<string>;
jobCompleteWebhookUrl: Resolved<string>;
resumeProjects: Resolved<ResumeProjectsSettings>;
ukvisajobsMaxJobs: Resolved<number>;
adzunaMaxJobsPerTerm: Resolved<number>;
gradcrackerMaxJobsPerTerm: Resolved<number>;
searchTerms: Resolved<string[]>;
searchCities: Resolved<string>;
jobspyResultsWanted: Resolved<number>;
jobspyCountryIndeed: Resolved<string>;
showSponsorInfo: Resolved<boolean>;
chatStyleTone: Resolved<string>;
chatStyleFormality: Resolved<string>;
chatStyleConstraints: Resolved<string>;
chatStyleDoNotUse: Resolved<string>;
backupEnabled: Resolved<boolean>;
backupHour: Resolved<number>;
backupMaxCount: Resolved<number>;
penalizeMissingSalary: Resolved<boolean>;
missingSalaryPenalty: Resolved<number>;
autoSkipScoreThreshold: Resolved<number | null>;
// Model variants (no own default, fallback to model.value):
modelScorer: ModelResolved;
modelTailoring: ModelResolved;
modelProjectSelection: ModelResolved;
// Simple strings:
rxresumeBaseResumeId: string | null;
rxresumeEmail: string | null;
ukvisajobsEmail: string | null;
adzunaAppId: string | null;
basicAuthUser: string | null;
// Secret hints:
llmApiKeyHint: string | null;
rxresumePasswordHint: string | null;
ukvisajobsPasswordHint: string | null;
adzunaAppKeyHint: string | null;
basicAuthPasswordHint: string | null;
webhookSecretHint: string | null;
// Computed:
basicAuthActive: boolean;
profileProjects: ResumeProjectCatalogItem[];
}

View File

@ -0,0 +1,28 @@
export interface VisaSponsor {
organisationName: string;
townCity: string;
county: string;
typeRating: string;
route: string;
}
export interface VisaSponsorSearchResult {
sponsor: VisaSponsor;
score: number;
matchedName: string;
}
export interface VisaSponsorSearchResponse {
results: VisaSponsorSearchResult[];
query: string;
total: number;
}
export interface VisaSponsorStatusResponse {
lastUpdated: string | null;
csvPath: string | null;
totalSponsors: number;
isUpdating: boolean;
nextScheduledUpdate: string | null;
error: string | null;
}