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:
parent
19266fe5eb
commit
b18c2eccbb
41
knip.json
41
knip.json
@ -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"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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"]}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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) {
|
// biome-ignore lint/suspicious/noExplicitAny: explicit partial assignment
|
||||||
privateValues[hintKey] = null;
|
(values as any)[hintKey] = null;
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
const hintLength =
|
||||||
|
rawValue.length > 4 ? 4 : Math.max(rawValue.length - 1, 1);
|
||||||
|
// 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 hintLength =
|
|
||||||
rawValue.length > 4 ? 4 : Math.max(rawValue.length - 1, 1);
|
|
||||||
privateValues[hintKey] = rawValue.slice(0, hintLength);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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";
|
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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";
|
|
||||||
|
|||||||
@ -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 }) => {
|
||||||
}),
|
const resumeProjects = value ?? null;
|
||||||
),
|
if (resumeProjects === null) {
|
||||||
llmProvider: singleAction(({ value }) => {
|
return result({ actions: [persistAction(targetKey, null)] });
|
||||||
const normalized = toNormalizedStringOrNull(value);
|
}
|
||||||
return result({
|
|
||||||
actions: [
|
const profile = await getProfile();
|
||||||
persistAction("llmProvider", normalized, () => {
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
applyEnvValue("LLM_PROVIDER", normalized);
|
const allowed = new Set(catalog.map((project) => project.id));
|
||||||
}),
|
const normalized = normalizeResumeProjectsSettings(
|
||||||
],
|
resumeProjects as Parameters<typeof normalizeResumeProjectsSettings>[0],
|
||||||
});
|
allowed,
|
||||||
}),
|
);
|
||||||
llmBaseUrl: singleAction(({ value }) => {
|
|
||||||
const normalized = toNormalizedStringOrNull(value);
|
return result({
|
||||||
return result({
|
actions: [persistAction(targetKey, JSON.stringify(normalized))],
|
||||||
actions: [
|
});
|
||||||
persistAction("llmBaseUrl", normalized, () => {
|
};
|
||||||
applyEnvValue("LLM_BASE_URL", normalized);
|
continue;
|
||||||
}),
|
}
|
||||||
],
|
|
||||||
});
|
// Generic handler for all others
|
||||||
}),
|
settingsUpdateRegistry[key as keyof UpdateSettingsInput] = ({ value }) => {
|
||||||
pipelineWebhookUrl: singleAction(({ value }) =>
|
let serialized: string | null;
|
||||||
result({ actions: [persistAction("pipelineWebhookUrl", value ?? null)] }),
|
|
||||||
),
|
if ("serialize" in def) {
|
||||||
jobCompleteWebhookUrl: singleAction(({ value }) =>
|
serialized = def.serialize(value as never);
|
||||||
result({
|
} else {
|
||||||
actions: [persistAction("jobCompleteWebhookUrl", value ?? null)],
|
serialized = normalizeEnvInput(value as string);
|
||||||
}),
|
|
||||||
),
|
|
||||||
rxresumeBaseResumeId: singleAction(({ value }) =>
|
|
||||||
result({
|
|
||||||
actions: [
|
|
||||||
persistAction("rxresumeBaseResumeId", toNormalizedStringOrNull(value)),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
resumeProjects: singleAction(async ({ value }) => {
|
|
||||||
const resumeProjects = value ?? null;
|
|
||||||
if (resumeProjects === null) {
|
|
||||||
return result({ actions: [persistAction("resumeProjects", null)] });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await getProfile();
|
const sideEffect = hasEnvKey
|
||||||
const { catalog } = extractProjectsFromProfile(profile);
|
? () => {
|
||||||
const allowed = new Set(catalog.map((project) => project.id));
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
// biome-ignore lint/suspicious/noExplicitAny: def is constrained by kind
|
||||||
|
applyEnvValue((def as any).envKey, serialized);
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return result({
|
return result({
|
||||||
actions: [persistAction("resumeProjects", JSON.stringify(normalized))],
|
actions: [persistAction(targetKey, serialized, sideEffect)],
|
||||||
|
deferred: isBackup ? ["refreshBackupScheduler"] : [],
|
||||||
});
|
});
|
||||||
}),
|
};
|
||||||
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)],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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,
|
|
||||||
modelScorer,
|
|
||||||
overrideModelScorer,
|
|
||||||
modelTailoring,
|
|
||||||
overrideModelTailoring,
|
|
||||||
modelProjectSelection,
|
|
||||||
overrideModelProjectSelection,
|
|
||||||
llmProvider,
|
|
||||||
defaultLlmProvider,
|
|
||||||
overrideLlmProvider,
|
|
||||||
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 rawModel = overrides.model;
|
||||||
const normalized = provider.trim().toLowerCase();
|
const modelDef = settingsRegistry.model;
|
||||||
if (normalized === "ollama") return "http://localhost:11434";
|
const overrideModel = modelDef.parse(rawModel);
|
||||||
if (normalized === "lmstudio") return "http://localhost:1234";
|
const modelValue = overrideModel ?? modelDef.default();
|
||||||
if (normalized === "openai") {
|
|
||||||
return "https://api.openai.com";
|
for (const [key, def] of Object.entries(settingsRegistry)) {
|
||||||
|
if (def.kind === "typed") {
|
||||||
|
let rawOverride = overrides[key as settingsRepo.SettingKey];
|
||||||
|
if (key === "searchCities" && !rawOverride) {
|
||||||
|
rawOverride = overrides.jobspyLocation; // legacy fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const override = def.parse(rawOverride);
|
||||||
|
let defaultValue = def.default();
|
||||||
|
|
||||||
|
if (key === "llmBaseUrl") {
|
||||||
|
const providerOverride = settingsRegistry.llmProvider.parse(
|
||||||
|
overrides.llmProvider,
|
||||||
|
);
|
||||||
|
const provider =
|
||||||
|
providerOverride ?? settingsRegistry.llmProvider.default();
|
||||||
|
defaultValue =
|
||||||
|
process.env.LLM_BASE_URL || resolveDefaultLlmBaseUrl(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (normalized === "gemini") {
|
|
||||||
return "https://generativelanguage.googleapis.com";
|
return result as AppSettings;
|
||||||
}
|
|
||||||
return "https://openrouter.ai";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
105
shared/src/settings-registry.test.ts
Normal file
105
shared/src/settings-registry.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
423
shared/src/settings-registry.ts
Normal file
423
shared/src/settings-registry.ts
Normal 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;
|
||||||
@ -1,112 +1,47 @@
|
|||||||
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(),
|
|
||||||
pipelineWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
|
||||||
jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
|
||||||
resumeProjects: resumeProjectsSchema.nullable().optional(),
|
|
||||||
rxresumeBaseResumeId: z.string().trim().max(200).nullable().optional(),
|
|
||||||
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.basicAuthUser || data.basicAuthUser.trim() === "") {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "Username is required when basic auth is enabled",
|
|
||||||
path: ["basicAuthUser"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
return [key, fieldSchema.nullable().optional()];
|
||||||
|
}),
|
||||||
|
) as unknown as UpdateSchemaShape;
|
||||||
|
|
||||||
|
export const updateSettingsSchema = z.object(shape).superRefine((data, ctx) => {
|
||||||
|
if (data.enableBasicAuth) {
|
||||||
|
if (
|
||||||
|
!data.basicAuthUser ||
|
||||||
|
typeof data.basicAuthUser !== "string" ||
|
||||||
|
data.basicAuthUser.trim() === ""
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Username is required when basic auth is enabled",
|
||||||
|
path: ["basicAuthUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export type UpdateSettingsInput = z.infer<typeof updateSettingsSchema>;
|
export type UpdateSettingsInput = z.infer<typeof updateSettingsSchema>;
|
||||||
export type ResumeProjectsSettingsInput = z.infer<typeof resumeProjectsSchema>;
|
export type ResumeProjectsSettingsInput = z.infer<typeof resumeProjectsSchema>;
|
||||||
|
|||||||
@ -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: {
|
||||||
maxProjects: 3,
|
value: { maxProjects: 3, lockedProjectIds: [], aiSelectableProjectIds: [] },
|
||||||
lockedProjectIds: [],
|
default: {
|
||||||
aiSelectableProjectIds: [],
|
maxProjects: 3,
|
||||||
|
lockedProjectIds: [],
|
||||||
|
aiSelectableProjectIds: [],
|
||||||
|
},
|
||||||
|
override: null,
|
||||||
},
|
},
|
||||||
defaultResumeProjects: {
|
|
||||||
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,
|
||||||
});
|
});
|
||||||
|
|||||||
1124
shared/src/types.ts
1124
shared/src/types.ts
File diff suppressed because it is too large
Load Diff
122
shared/src/types/api.ts
Normal file
122
shared/src/types/api.ts
Normal 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
95
shared/src/types/chat.ts
Normal 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
322
shared/src/types/jobs.ts
Normal 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;
|
||||||
|
}
|
||||||
124
shared/src/types/pipeline.ts
Normal file
124
shared/src/types/pipeline.ts
Normal 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;
|
||||||
|
}
|
||||||
208
shared/src/types/post-application.ts
Normal file
208
shared/src/types/post-application.ts
Normal 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[];
|
||||||
|
}
|
||||||
164
shared/src/types/settings.ts
Normal file
164
shared/src/types/settings.ts
Normal 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[];
|
||||||
|
}
|
||||||
28
shared/src/types/visa-sponsors.ts
Normal file
28
shared/src/types/visa-sponsors.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user