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",
|
||||
"tags": ["-lintignore"],
|
||||
"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",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@ -57,7 +56,6 @@
|
||||
"drizzle-orm": "^0.38.2",
|
||||
"express": "^4.18.2",
|
||||
"framer-motion": "^12.34.3",
|
||||
"get-tsconfig": "^4.10.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"jsdom": "^25.0.1",
|
||||
"lucide-react": "^0.561.0",
|
||||
@ -81,6 +79,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
|
||||
@ -92,7 +92,7 @@ vi.mock("sonner", () => ({
|
||||
|
||||
const settingsResponse = {
|
||||
settings: {
|
||||
llmProvider: "openrouter",
|
||||
llmProvider: { value: "openrouter", default: "openrouter", override: null },
|
||||
llmApiKeyHint: null,
|
||||
rxresumeEmail: "",
|
||||
rxresumePasswordHint: null,
|
||||
@ -163,7 +163,7 @@ describe("OnboardingGate", () => {
|
||||
...settingsResponse,
|
||||
settings: {
|
||||
...settingsResponse.settings,
|
||||
llmProvider: "ollama",
|
||||
llmProvider: { value: "ollama", default: "ollama", override: null },
|
||||
},
|
||||
} as any);
|
||||
vi.mocked(api.validateRxresume).mockResolvedValue({
|
||||
|
||||
@ -120,7 +120,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
const validateLlm = useCallback(async () => {
|
||||
const values = getValues();
|
||||
const selectedProvider = normalizeLlmProvider(
|
||||
values.llmProvider || settings?.llmProvider || "openrouter",
|
||||
values.llmProvider || settings?.llmProvider?.value || "openrouter",
|
||||
);
|
||||
const providerConfig = getLlmProviderConfig(selectedProvider);
|
||||
const { requiresApiKey, showBaseUrl } = providerConfig;
|
||||
@ -191,7 +191,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const selectedProvider = normalizeLlmProvider(
|
||||
llmProvider || settings?.llmProvider || "openrouter",
|
||||
llmProvider || settings?.llmProvider?.value || "openrouter",
|
||||
);
|
||||
const providerConfig = getLlmProviderConfig(selectedProvider);
|
||||
const {
|
||||
@ -227,8 +227,8 @@ export const OnboardingGate: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
reset({
|
||||
llmProvider: settings.llmProvider || "",
|
||||
llmBaseUrl: settings.llmBaseUrl || "",
|
||||
llmProvider: settings.llmProvider?.value || "",
|
||||
llmBaseUrl: settings.llmBaseUrl?.value || "",
|
||||
llmApiKey: "",
|
||||
rxresumeEmail: "",
|
||||
rxresumePassword: "",
|
||||
@ -637,7 +637,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
}}
|
||||
placeholder={providerConfig.baseUrlPlaceholder}
|
||||
helper={providerConfig.baseUrlHelper}
|
||||
current={settings?.llmBaseUrl || "—"}
|
||||
current={settings?.llmBaseUrl?.value || "—"}
|
||||
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 { 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||
import { safeFilenamePart } from "@/lib/utils";
|
||||
import * as api from "../api";
|
||||
import { KeyboardShortcutBar } from "../components/KeyboardShortcutBar";
|
||||
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 { tabs } from "./orchestrator/constants";
|
||||
import { FloatingJobActionsBar } from "./orchestrator/FloatingJobActionsBar";
|
||||
import { JobCommandBar } from "./orchestrator/JobCommandBar";
|
||||
import { JobDetailPanel } from "./orchestrator/JobDetailPanel";
|
||||
@ -36,11 +15,12 @@ import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
|
||||
import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader";
|
||||
import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary";
|
||||
import { RunModeModal } from "./orchestrator/RunModeModal";
|
||||
import type { RunMode } from "./orchestrator/run-mode";
|
||||
import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
|
||||
import { useJobSelectionActions } from "./orchestrator/useJobSelectionActions";
|
||||
import { useKeyboardShortcuts } from "./orchestrator/useKeyboardShortcuts";
|
||||
import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
|
||||
import { useOrchestratorFilters } from "./orchestrator/useOrchestratorFilters";
|
||||
import { usePipelineControls } from "./orchestrator/usePipelineControls";
|
||||
import { usePipelineSources } from "./orchestrator/usePipelineSources";
|
||||
import { useScrollToJobItem } from "./orchestrator/useScrollToJobItem";
|
||||
import {
|
||||
@ -101,36 +81,10 @@ export const OrchestratorPage: React.FC = () => {
|
||||
}, [tab, navigate, navigateWithContext]);
|
||||
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [isRunModeModalOpen, setIsRunModeModalOpen] = useState(false);
|
||||
const [runMode, setRunMode] = useState<RunMode>("automatic");
|
||||
const [isCommandBarOpen, setIsCommandBarOpen] = useState(false);
|
||||
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
||||
const [isHelpDialogOpen, setIsHelpDialogOpen] = 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(() =>
|
||||
typeof window !== "undefined"
|
||||
@ -152,9 +106,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
[navigateWithContext, activeTab],
|
||||
);
|
||||
|
||||
const { settings, refreshSettings } = useSettings();
|
||||
const markAsAppliedMutation = useMarkAsAppliedMutation();
|
||||
const skipJobMutation = useSkipJobMutation();
|
||||
const { settings } = useSettings();
|
||||
const {
|
||||
jobs,
|
||||
selectedJob,
|
||||
@ -173,6 +125,25 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const { pipelineSources, setPipelineSources, toggleSource } =
|
||||
usePipelineSources(enabledSources);
|
||||
|
||||
const {
|
||||
isRunModeModalOpen,
|
||||
setIsRunModeModalOpen,
|
||||
runMode,
|
||||
setRunMode,
|
||||
isCancelling,
|
||||
openRunMode,
|
||||
handleCancelPipeline,
|
||||
handleSaveAndRunAutomatic,
|
||||
handleManualImported,
|
||||
} = usePipelineControls({
|
||||
isPipelineRunning,
|
||||
setIsPipelineRunning,
|
||||
pipelineTerminalEvent,
|
||||
pipelineSources,
|
||||
loadJobs,
|
||||
navigateWithContext,
|
||||
});
|
||||
|
||||
const activeJobs = useFilteredJobs(
|
||||
jobs,
|
||||
activeTab,
|
||||
@ -206,129 +177,6 @@ export const OrchestratorPage: React.FC = () => {
|
||||
}
|
||||
}, [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) => {
|
||||
handleSelectJobId(id);
|
||||
if (!isDesktop) {
|
||||
@ -343,234 +191,48 @@ export const OrchestratorPage: React.FC = () => {
|
||||
onEnsureJobSelected: (id) => navigateWithContext(activeTab, id, true),
|
||||
});
|
||||
|
||||
// ── Keyboard shortcuts ──────────────────────────────────────────────────
|
||||
const { personName } = useProfile();
|
||||
const isAnyModalOpen =
|
||||
isRunModeModalOpen ||
|
||||
isCommandBarOpen ||
|
||||
isFiltersOpen ||
|
||||
isHelpDialogOpen ||
|
||||
isDetailDrawerOpen ||
|
||||
navOpen;
|
||||
|
||||
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 isAnyModalOpenExcludingCommandBar =
|
||||
isRunModeModalOpen ||
|
||||
isFiltersOpen ||
|
||||
isHelpDialogOpen ||
|
||||
isDetailDrawerOpen ||
|
||||
navOpen;
|
||||
|
||||
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 isAnyModalOpenExcludingHelp =
|
||||
isRunModeModalOpen ||
|
||||
isCommandBarOpen ||
|
||||
isFiltersOpen ||
|
||||
isDetailDrawerOpen ||
|
||||
navOpen;
|
||||
|
||||
/**
|
||||
* After a destructive/moving action (skip, mark-applied), auto-advance to
|
||||
* the next job in the list -- mirroring handleJobMoved in JobDetailPanel.
|
||||
*/
|
||||
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;
|
||||
|
||||
// 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 },
|
||||
);
|
||||
useKeyboardShortcuts({
|
||||
isAnyModalOpen,
|
||||
isAnyModalOpenExcludingCommandBar,
|
||||
isAnyModalOpenExcludingHelp,
|
||||
activeTab,
|
||||
activeJobs,
|
||||
selectedJobId,
|
||||
selectedJob,
|
||||
selectedJobIds,
|
||||
isDesktop,
|
||||
handleSelectJobId,
|
||||
requestScrollToJob,
|
||||
setActiveTab,
|
||||
setIsCommandBarOpen,
|
||||
setIsHelpDialogOpen,
|
||||
clearSelection,
|
||||
toggleSelectJob,
|
||||
runJobAction,
|
||||
loadJobs,
|
||||
});
|
||||
|
||||
const handleCommandSelectJob = useCallback(
|
||||
(targetTab: FilterTab, id: string) => {
|
||||
|
||||
@ -31,15 +31,6 @@ vi.mock("sonner", () => ({
|
||||
}));
|
||||
|
||||
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: [
|
||||
{
|
||||
id: "proj-1",
|
||||
@ -56,24 +47,6 @@ const baseSettings = createAppSettings({
|
||||
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 = () => {
|
||||
@ -103,16 +76,17 @@ describe("SettingsPage", () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||
vi.mocked(api.updateSettings).mockResolvedValue({
|
||||
...baseSettings,
|
||||
overrideModel: "gpt-4",
|
||||
model: "gpt-4",
|
||||
model: {
|
||||
value: "gpt-4",
|
||||
default: baseSettings.model.default,
|
||||
override: "gpt-4",
|
||||
},
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
const modelTrigger = await screen.findByRole("button", { name: /model/i });
|
||||
fireEvent.click(modelTrigger);
|
||||
|
||||
const modelInput = screen.getByLabelText(/default model/i);
|
||||
await waitFor(() => expect(modelInput).toBeEnabled());
|
||||
fireEvent.change(modelInput, { target: { value: " gpt-4 " } });
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||
@ -134,10 +108,8 @@ describe("SettingsPage", () => {
|
||||
|
||||
renderPage();
|
||||
|
||||
const modelTrigger = await screen.findByRole("button", { name: /model/i });
|
||||
fireEvent.click(modelTrigger);
|
||||
|
||||
const modelInput = screen.getByLabelText(/default model/i);
|
||||
await waitFor(() => expect(modelInput).toBeEnabled());
|
||||
|
||||
// Change to > 200 chars
|
||||
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } });
|
||||
@ -195,11 +167,12 @@ describe("SettingsPage", () => {
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const modelTrigger = await screen.findByRole("button", { name: /model/i });
|
||||
fireEvent.click(modelTrigger);
|
||||
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" } });
|
||||
expect(saveButton).toBeEnabled();
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
});
|
||||
|
||||
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 => ({
|
||||
model: data.overrideModel ?? "",
|
||||
modelScorer: data.overrideModelScorer ?? "",
|
||||
modelTailoring: data.overrideModelTailoring ?? "",
|
||||
modelProjectSelection: data.overrideModelProjectSelection ?? "",
|
||||
llmProvider: normalizeLlmProviderValue(data.overrideLlmProvider),
|
||||
llmBaseUrl: data.overrideLlmBaseUrl ?? "",
|
||||
model: data.model.override ?? "",
|
||||
modelScorer: data.modelScorer.override ?? "",
|
||||
modelTailoring: data.modelTailoring.override ?? "",
|
||||
modelProjectSelection: data.modelProjectSelection.override ?? "",
|
||||
llmProvider: normalizeLlmProviderValue(data.llmProvider.override),
|
||||
llmBaseUrl: data.llmBaseUrl.override ?? "",
|
||||
llmApiKey: "",
|
||||
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
|
||||
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
|
||||
resumeProjects: data.resumeProjects,
|
||||
rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null,
|
||||
showSponsorInfo: data.overrideShowSponsorInfo,
|
||||
chatStyleTone: data.overrideChatStyleTone ?? "",
|
||||
chatStyleFormality: data.overrideChatStyleFormality ?? "",
|
||||
chatStyleConstraints: data.overrideChatStyleConstraints ?? "",
|
||||
chatStyleDoNotUse: data.overrideChatStyleDoNotUse ?? "",
|
||||
pipelineWebhookUrl: data.pipelineWebhookUrl.override ?? "",
|
||||
jobCompleteWebhookUrl: data.jobCompleteWebhookUrl.override ?? "",
|
||||
resumeProjects: data.resumeProjects.override,
|
||||
rxresumeBaseResumeId: data.rxresumeBaseResumeId,
|
||||
showSponsorInfo: data.showSponsorInfo.override,
|
||||
chatStyleTone: data.chatStyleTone.override ?? "",
|
||||
chatStyleFormality: data.chatStyleFormality.override ?? "",
|
||||
chatStyleConstraints: data.chatStyleConstraints.override ?? "",
|
||||
chatStyleDoNotUse: data.chatStyleDoNotUse.override ?? "",
|
||||
rxresumeEmail: data.rxresumeEmail ?? "",
|
||||
rxresumePassword: "",
|
||||
basicAuthUser: data.basicAuthUser ?? "",
|
||||
@ -143,12 +143,12 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
adzunaAppKey: "",
|
||||
webhookSecret: "",
|
||||
enableBasicAuth: data.basicAuthActive,
|
||||
backupEnabled: data.overrideBackupEnabled,
|
||||
backupHour: data.overrideBackupHour,
|
||||
backupMaxCount: data.overrideBackupMaxCount,
|
||||
penalizeMissingSalary: data.overridePenalizeMissingSalary,
|
||||
missingSalaryPenalty: data.overrideMissingSalaryPenalty,
|
||||
autoSkipScoreThreshold: data.overrideAutoSkipScoreThreshold,
|
||||
backupEnabled: data.backupEnabled.override,
|
||||
backupHour: data.backupHour.override,
|
||||
backupMaxCount: data.backupMaxCount.override,
|
||||
penalizeMissingSalary: data.penalizeMissingSalary.override,
|
||||
missingSalaryPenalty: data.missingSalaryPenalty.override,
|
||||
autoSkipScoreThreshold: data.autoSkipScoreThreshold.override,
|
||||
});
|
||||
|
||||
const normalizeString = (value: string | null | undefined) => {
|
||||
@ -204,43 +204,43 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
|
||||
return {
|
||||
model: {
|
||||
effective: settings?.model ?? "",
|
||||
default: settings?.defaultModel ?? "",
|
||||
scorer: settings?.modelScorer ?? "",
|
||||
tailoring: settings?.modelTailoring ?? "",
|
||||
projectSelection: settings?.modelProjectSelection ?? "",
|
||||
llmProvider: settings?.llmProvider ?? "",
|
||||
llmBaseUrl: settings?.llmBaseUrl ?? "",
|
||||
effective: settings?.model?.value ?? "",
|
||||
default: settings?.model?.default ?? "",
|
||||
scorer: settings?.modelScorer?.value ?? "",
|
||||
tailoring: settings?.modelTailoring?.value ?? "",
|
||||
projectSelection: settings?.modelProjectSelection?.value ?? "",
|
||||
llmProvider: settings?.llmProvider?.value ?? "",
|
||||
llmBaseUrl: settings?.llmBaseUrl?.value ?? "",
|
||||
llmApiKeyHint: settings?.llmApiKeyHint ?? null,
|
||||
},
|
||||
pipelineWebhook: {
|
||||
effective: settings?.pipelineWebhookUrl ?? "",
|
||||
default: settings?.defaultPipelineWebhookUrl ?? "",
|
||||
effective: settings?.pipelineWebhookUrl?.value ?? "",
|
||||
default: settings?.pipelineWebhookUrl?.default ?? "",
|
||||
},
|
||||
jobCompleteWebhook: {
|
||||
effective: settings?.jobCompleteWebhookUrl ?? "",
|
||||
default: settings?.defaultJobCompleteWebhookUrl ?? "",
|
||||
effective: settings?.jobCompleteWebhookUrl?.value ?? "",
|
||||
default: settings?.jobCompleteWebhookUrl?.default ?? "",
|
||||
},
|
||||
display: {
|
||||
effective: settings?.showSponsorInfo ?? true,
|
||||
default: settings?.defaultShowSponsorInfo ?? true,
|
||||
effective: settings?.showSponsorInfo?.value ?? true,
|
||||
default: settings?.showSponsorInfo?.default ?? true,
|
||||
},
|
||||
chat: {
|
||||
tone: {
|
||||
effective: settings?.chatStyleTone ?? "professional",
|
||||
default: settings?.defaultChatStyleTone ?? "professional",
|
||||
effective: settings?.chatStyleTone?.value ?? "professional",
|
||||
default: settings?.chatStyleTone?.default ?? "professional",
|
||||
},
|
||||
formality: {
|
||||
effective: settings?.chatStyleFormality ?? "medium",
|
||||
default: settings?.defaultChatStyleFormality ?? "medium",
|
||||
effective: settings?.chatStyleFormality?.value ?? "medium",
|
||||
default: settings?.chatStyleFormality?.default ?? "medium",
|
||||
},
|
||||
constraints: {
|
||||
effective: settings?.chatStyleConstraints ?? "",
|
||||
default: settings?.defaultChatStyleConstraints ?? "",
|
||||
effective: settings?.chatStyleConstraints?.value ?? "",
|
||||
default: settings?.chatStyleConstraints?.default ?? "",
|
||||
},
|
||||
doNotUse: {
|
||||
effective: settings?.chatStyleDoNotUse ?? "",
|
||||
default: settings?.defaultChatStyleDoNotUse ?? "",
|
||||
effective: settings?.chatStyleDoNotUse?.value ?? "",
|
||||
default: settings?.chatStyleDoNotUse?.default ?? "",
|
||||
},
|
||||
},
|
||||
envSettings: {
|
||||
@ -259,37 +259,37 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
},
|
||||
basicAuthActive: settings?.basicAuthActive ?? false,
|
||||
},
|
||||
defaultResumeProjects: settings?.defaultResumeProjects ?? null,
|
||||
defaultResumeProjects: settings?.resumeProjects?.default ?? null,
|
||||
|
||||
profileProjects,
|
||||
maxProjectsTotal: profileProjects.length,
|
||||
|
||||
backup: {
|
||||
backupEnabled: {
|
||||
effective: settings?.backupEnabled ?? false,
|
||||
default: settings?.defaultBackupEnabled ?? false,
|
||||
effective: settings?.backupEnabled?.value ?? false,
|
||||
default: settings?.backupEnabled?.default ?? false,
|
||||
},
|
||||
backupHour: {
|
||||
effective: settings?.backupHour ?? 2,
|
||||
default: settings?.defaultBackupHour ?? 2,
|
||||
effective: settings?.backupHour?.value ?? 2,
|
||||
default: settings?.backupHour?.default ?? 2,
|
||||
},
|
||||
backupMaxCount: {
|
||||
effective: settings?.backupMaxCount ?? 5,
|
||||
default: settings?.defaultBackupMaxCount ?? 5,
|
||||
effective: settings?.backupMaxCount?.value ?? 5,
|
||||
default: settings?.backupMaxCount?.default ?? 5,
|
||||
},
|
||||
},
|
||||
scoring: {
|
||||
penalizeMissingSalary: {
|
||||
effective: settings?.penalizeMissingSalary ?? false,
|
||||
default: settings?.defaultPenalizeMissingSalary ?? false,
|
||||
effective: settings?.penalizeMissingSalary?.value ?? false,
|
||||
default: settings?.penalizeMissingSalary?.default ?? false,
|
||||
},
|
||||
missingSalaryPenalty: {
|
||||
effective: settings?.missingSalaryPenalty ?? 10,
|
||||
default: settings?.defaultMissingSalaryPenalty ?? 10,
|
||||
effective: settings?.missingSalaryPenalty?.value ?? 10,
|
||||
default: settings?.missingSalaryPenalty?.default ?? 10,
|
||||
},
|
||||
autoSkipScoreThreshold: {
|
||||
effective: settings?.autoSkipScoreThreshold ?? null,
|
||||
default: settings?.defaultAutoSkipScoreThreshold ?? null,
|
||||
effective: settings?.autoSkipScoreThreshold?.value ?? 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">
|
||||
<Accordion type="multiple" className="w-full space-y-4">
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full space-y-4"
|
||||
defaultValue={["model", "feature", "webhooks", "chat"]}
|
||||
>
|
||||
<ModelSettingsSection
|
||||
values={model}
|
||||
isLoading={isLoading}
|
||||
|
||||
@ -23,9 +23,13 @@ describe("AutomaticRunTab", () => {
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "us",
|
||||
searchCities: "",
|
||||
searchTerms: {
|
||||
value: ["backend engineer"],
|
||||
default: ["backend engineer"],
|
||||
override: null,
|
||||
},
|
||||
jobspyCountryIndeed: { value: "us", default: "us", override: null },
|
||||
searchCities: { value: "", default: "", override: null },
|
||||
})}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
@ -46,9 +50,17 @@ describe("AutomaticRunTab", () => {
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "usa/ca",
|
||||
searchCities: "",
|
||||
searchTerms: {
|
||||
value: ["backend engineer"],
|
||||
default: ["backend engineer"],
|
||||
override: null,
|
||||
},
|
||||
jobspyCountryIndeed: {
|
||||
value: "usa/ca",
|
||||
default: "usa/ca",
|
||||
override: null,
|
||||
},
|
||||
searchCities: { value: "", default: "", override: null },
|
||||
})}
|
||||
enabledSources={["linkedin"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
@ -71,9 +83,17 @@ describe("AutomaticRunTab", () => {
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united states",
|
||||
searchCities: "",
|
||||
searchTerms: {
|
||||
value: ["backend engineer"],
|
||||
default: ["backend engineer"],
|
||||
override: null,
|
||||
},
|
||||
jobspyCountryIndeed: {
|
||||
value: "united states",
|
||||
default: "united states",
|
||||
override: null,
|
||||
},
|
||||
searchCities: { value: "", default: "", override: null },
|
||||
})}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
@ -97,9 +117,17 @@ describe("AutomaticRunTab", () => {
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united states",
|
||||
searchCities: "",
|
||||
searchTerms: {
|
||||
value: ["backend engineer"],
|
||||
default: ["backend engineer"],
|
||||
override: null,
|
||||
},
|
||||
jobspyCountryIndeed: {
|
||||
value: "united states",
|
||||
default: "united states",
|
||||
override: null,
|
||||
},
|
||||
searchCities: { value: "", default: "", override: null },
|
||||
})}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
@ -124,9 +152,17 @@ describe("AutomaticRunTab", () => {
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "japan",
|
||||
searchCities: "",
|
||||
searchTerms: {
|
||||
value: ["backend engineer"],
|
||||
default: ["backend engineer"],
|
||||
override: null,
|
||||
},
|
||||
jobspyCountryIndeed: {
|
||||
value: "japan",
|
||||
default: "japan",
|
||||
override: null,
|
||||
},
|
||||
searchCities: { value: "", default: "", override: null },
|
||||
})}
|
||||
enabledSources={["linkedin", "glassdoor"]}
|
||||
pipelineSources={["linkedin", "glassdoor"]}
|
||||
@ -155,9 +191,21 @@ describe("AutomaticRunTab", () => {
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
searchCities: "United Kingdom",
|
||||
searchTerms: {
|
||||
value: ["backend engineer"],
|
||||
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"]}
|
||||
pipelineSources={["linkedin", "glassdoor"]}
|
||||
@ -184,9 +232,17 @@ describe("AutomaticRunTab", () => {
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer", "frontend engineer"],
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
searchCities: "",
|
||||
searchTerms: {
|
||||
value: ["backend engineer", "frontend engineer"],
|
||||
default: ["backend engineer", "frontend engineer"],
|
||||
override: null,
|
||||
},
|
||||
jobspyCountryIndeed: {
|
||||
value: "united kingdom",
|
||||
default: "united kingdom",
|
||||
override: null,
|
||||
},
|
||||
searchCities: { value: "", default: "", override: null },
|
||||
})}
|
||||
enabledSources={["linkedin"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
@ -214,9 +270,21 @@ describe("AutomaticRunTab", () => {
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
searchCities: "London|Manchester",
|
||||
searchTerms: {
|
||||
value: ["backend engineer"],
|
||||
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"]}
|
||||
pipelineSources={["linkedin", "glassdoor"]}
|
||||
|
||||
@ -180,19 +180,19 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
memory?.minSuitabilityScore ?? DEFAULT_VALUES.minSuitabilityScore;
|
||||
|
||||
const rememberedRunBudget =
|
||||
settings?.jobspyResultsWanted ??
|
||||
settings?.adzunaMaxJobsPerTerm ??
|
||||
settings?.gradcrackerMaxJobsPerTerm ??
|
||||
settings?.ukvisajobsMaxJobs ??
|
||||
settings?.jobspyResultsWanted?.value ??
|
||||
settings?.adzunaMaxJobsPerTerm?.value ??
|
||||
settings?.gradcrackerMaxJobsPerTerm?.value ??
|
||||
settings?.ukvisajobsMaxJobs?.value ??
|
||||
DEFAULT_VALUES.runBudget;
|
||||
const rememberedCountry = normalizeUiCountryKey(
|
||||
settings?.jobspyCountryIndeed ??
|
||||
settings?.searchCities ??
|
||||
settings?.jobspyCountryIndeed?.value ??
|
||||
settings?.searchCities?.value ??
|
||||
DEFAULT_VALUES.country,
|
||||
);
|
||||
const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country;
|
||||
const rememberedLocations = parseCityLocationsSetting(
|
||||
settings?.searchCities,
|
||||
settings?.searchCities?.value,
|
||||
).filter(
|
||||
(location) =>
|
||||
normalizeCountryKey(location) !==
|
||||
@ -206,7 +206,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
country: rememberedCountry || DEFAULT_VALUES.country,
|
||||
cityLocations: rememberedLocations,
|
||||
cityLocationDraft: "",
|
||||
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms,
|
||||
searchTerms: settings?.searchTerms?.value ?? DEFAULT_VALUES.searchTerms,
|
||||
searchTermDraft: "",
|
||||
});
|
||||
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 { logger } from "@infra/logger";
|
||||
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 {
|
||||
getResume,
|
||||
|
||||
@ -25,8 +25,8 @@ describe.sequential("Settings API routes", () => {
|
||||
const res = await fetch(`${baseUrl}/api/settings`);
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.defaultModel).toBe("test-model");
|
||||
expect(Array.isArray(body.data.searchTerms)).toBe(true);
|
||||
expect(body.data.model.default).toBe("test-model");
|
||||
expect(Array.isArray(body.data.searchTerms.value)).toBe(true);
|
||||
expect(body.data.rxresumeEmail).toBe("resume@example.com");
|
||||
expect(body.data.llmApiKeyHint).toBe("secr");
|
||||
expect(body.data.basicAuthActive).toBe(false);
|
||||
@ -51,8 +51,8 @@ describe.sequential("Settings API routes", () => {
|
||||
});
|
||||
const patchBody = await patchRes.json();
|
||||
expect(patchBody.ok).toBe(true);
|
||||
expect(patchBody.data.searchTerms).toEqual(["engineer"]);
|
||||
expect(patchBody.data.overrideSearchTerms).toEqual(["engineer"]);
|
||||
expect(patchBody.data.searchTerms.value).toEqual(["engineer"]);
|
||||
expect(patchBody.data.searchTerms.override).toEqual(["engineer"]);
|
||||
expect(patchBody.data.rxresumeEmail).toBe("updated@example.com");
|
||||
expect(patchBody.data.llmApiKeyHint).toBe("upda");
|
||||
});
|
||||
@ -77,8 +77,8 @@ describe.sequential("Settings API routes", () => {
|
||||
const initialRes = await fetch(`${baseUrl}/api/settings`);
|
||||
const initialBody = await initialRes.json();
|
||||
expect(initialBody.ok).toBe(true);
|
||||
expect(initialBody.data.penalizeMissingSalary).toBe(false);
|
||||
expect(initialBody.data.missingSalaryPenalty).toBe(10);
|
||||
expect(initialBody.data.penalizeMissingSalary.value).toBe(false);
|
||||
expect(initialBody.data.missingSalaryPenalty.value).toBe(10);
|
||||
|
||||
// Test invalid penalty values
|
||||
const invalidRes = await fetch(`${baseUrl}/api/settings`, {
|
||||
@ -106,16 +106,16 @@ describe.sequential("Settings API routes", () => {
|
||||
});
|
||||
const validBody = await validRes.json();
|
||||
expect(validBody.ok).toBe(true);
|
||||
expect(validBody.data.penalizeMissingSalary).toBe(true);
|
||||
expect(validBody.data.overridePenalizeMissingSalary).toBe(true);
|
||||
expect(validBody.data.missingSalaryPenalty).toBe(20);
|
||||
expect(validBody.data.overrideMissingSalaryPenalty).toBe(20);
|
||||
expect(validBody.data.penalizeMissingSalary.value).toBe(true);
|
||||
expect(validBody.data.penalizeMissingSalary.override).toBe(true);
|
||||
expect(validBody.data.missingSalaryPenalty.value).toBe(20);
|
||||
expect(validBody.data.missingSalaryPenalty.override).toBe(20);
|
||||
|
||||
// Verify persistence
|
||||
const getRes = await fetch(`${baseUrl}/api/settings`);
|
||||
const getBody = await getRes.json();
|
||||
expect(getBody.ok).toBe(true);
|
||||
expect(getBody.data.penalizeMissingSalary).toBe(true);
|
||||
expect(getBody.data.missingSalaryPenalty).toBe(20);
|
||||
expect(getBody.data.penalizeMissingSalary.value).toBe(true);
|
||||
expect(getBody.data.missingSalaryPenalty.value).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
@ -47,9 +47,9 @@ settingsRouter.patch("/", async (req: Request, res: Response) => {
|
||||
|
||||
if (plan.shouldRefreshBackupScheduler) {
|
||||
setBackupSettings({
|
||||
enabled: data.backupEnabled,
|
||||
hour: data.backupHour,
|
||||
maxCount: data.backupMaxCount,
|
||||
enabled: data.backupEnabled.value,
|
||||
hour: data.backupHour.value,
|
||||
maxCount: data.backupMaxCount.value,
|
||||
});
|
||||
}
|
||||
res.json({ success: true, data });
|
||||
|
||||
@ -2,51 +2,20 @@
|
||||
* Settings repository - key/value storage for runtime configuration.
|
||||
*/
|
||||
|
||||
import type { settingsRegistry } from "@shared/settings-registry";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/index";
|
||||
|
||||
const { settings } = schema;
|
||||
|
||||
export type SettingKey =
|
||||
| "model"
|
||||
| "modelScorer"
|
||||
| "modelTailoring"
|
||||
| "modelProjectSelection"
|
||||
| "llmProvider"
|
||||
| "llmBaseUrl"
|
||||
| "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 type SettingKey = Exclude<
|
||||
{
|
||||
[K in keyof typeof settingsRegistry]: (typeof settingsRegistry)[K]["kind"] extends "virtual"
|
||||
? never
|
||||
: K;
|
||||
}[keyof typeof settingsRegistry],
|
||||
undefined
|
||||
>;
|
||||
|
||||
export async function getSetting(key: SettingKey): Promise<string | null> {
|
||||
const [row] = await db.select().from(settings).where(eq(settings.key, key));
|
||||
|
||||
@ -1,60 +1,10 @@
|
||||
import type { SettingKey } 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 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(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
@ -62,15 +12,6 @@ export function normalizeEnvInput(
|
||||
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 {
|
||||
if (value === null) {
|
||||
const fallback = envDefaults[envKey];
|
||||
@ -85,77 +26,57 @@ export function applyEnvValue(envKey: string, value: string | null): void {
|
||||
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> {
|
||||
const safeGetSetting = async (key: SettingKey): Promise<string | null> => {
|
||||
try {
|
||||
return await settingsRepo.getSetting(key);
|
||||
} 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);
|
||||
if (msg.includes("no such table") && msg.includes("settings"))
|
||||
if (msg.includes("no such table") && msg.includes("settings")) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
...readableStringConfig.map(async ({ settingKey, envKey }) => {
|
||||
const override = await safeGetSetting(settingKey);
|
||||
if (override === null) return;
|
||||
applyEnvValue(envKey, normalizeEnvInput(override));
|
||||
}),
|
||||
...readableBooleanConfig.map(
|
||||
async ({ settingKey, envKey, defaultValue }) => {
|
||||
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));
|
||||
}),
|
||||
]);
|
||||
const tasks = Object.entries(settingsRegistry).map(async ([key, def]) => {
|
||||
if (!("envKey" in def) || !def.envKey) return;
|
||||
const override = await safeGetSetting(key as SettingKey);
|
||||
if (override === null) return;
|
||||
applyEnvValue(def.envKey, normalizeEnvInput(override));
|
||||
});
|
||||
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
export async function getEnvSettingsData(
|
||||
overrides?: Partial<Record<SettingKey, string>>,
|
||||
): Promise<Record<string, string | boolean | number | null>> {
|
||||
): Promise<Partial<AppSettings>> {
|
||||
const activeOverrides = overrides || (await settingsRepo.getAllSettings());
|
||||
const readableValues: Record<string, string | boolean | null> = {};
|
||||
const privateValues: Record<string, string | null> = {};
|
||||
const values: Partial<AppSettings> = {};
|
||||
|
||||
for (const { settingKey, envKey } of readableStringConfig) {
|
||||
const override = activeOverrides[settingKey] ?? null;
|
||||
const rawValue = override ?? process.env[envKey];
|
||||
readableValues[settingKey] = normalizeEnvInput(rawValue);
|
||||
}
|
||||
for (const [key, def] of Object.entries(settingsRegistry)) {
|
||||
if (def.kind === "typed") continue;
|
||||
if (!("envKey" in def) || !def.envKey) continue;
|
||||
|
||||
for (const { settingKey, envKey, defaultValue } of readableBooleanConfig) {
|
||||
const override = activeOverrides[settingKey] ?? null;
|
||||
const rawValue = override ?? process.env[envKey];
|
||||
readableValues[settingKey] = parseEnvBoolean(rawValue, defaultValue);
|
||||
}
|
||||
const override = activeOverrides[key as SettingKey] ?? null;
|
||||
const rawValue = override ?? process.env[def.envKey];
|
||||
|
||||
for (const { settingKey, envKey, hintKey } of privateStringConfig) {
|
||||
const override = activeOverrides[settingKey] ?? null;
|
||||
const rawValue = override ?? process.env[envKey];
|
||||
if (!rawValue) {
|
||||
privateValues[hintKey] = null;
|
||||
continue;
|
||||
if (def.kind === "secret") {
|
||||
const hintKey = `${key}Hint` as keyof AppSettings;
|
||||
if (!rawValue) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicit partial assignment
|
||||
(values as any)[hintKey] = null;
|
||||
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 =
|
||||
@ -163,15 +84,7 @@ export async function getEnvSettingsData(
|
||||
const basicAuthPassword =
|
||||
activeOverrides.basicAuthPassword ?? process.env.BASIC_AUTH_PASSWORD;
|
||||
|
||||
return {
|
||||
...readableValues,
|
||||
...privateValues,
|
||||
basicAuthActive: Boolean(basicAuthUser && basicAuthPassword),
|
||||
};
|
||||
}
|
||||
values.basicAuthActive = Boolean(basicAuthUser && basicAuthPassword);
|
||||
|
||||
export const envSettingConfig = {
|
||||
readableStringConfig,
|
||||
readableBooleanConfig,
|
||||
privateStringConfig,
|
||||
};
|
||||
return values;
|
||||
}
|
||||
|
||||
@ -7,36 +7,35 @@ vi.mock("../repositories/jobs", () => ({
|
||||
getJobById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../repositories/settings", () => ({
|
||||
getAllSettings: vi.fn(),
|
||||
vi.mock("./settings", () => ({
|
||||
getEffectiveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./profile", () => ({
|
||||
getProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./settings-conversion", () => ({
|
||||
resolveSettingValue: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getJobById } from "../repositories/jobs";
|
||||
import { getAllSettings } from "../repositories/settings";
|
||||
import { getProfile } from "./profile";
|
||||
import { resolveSettingValue } from "./settings-conversion";
|
||||
import { getEffectiveSettings } from "./settings";
|
||||
|
||||
describe("buildJobChatPromptContext", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getAllSettings).mockResolvedValue({});
|
||||
vi.mocked(resolveSettingValue).mockImplementation((key, override) => {
|
||||
const fallback: Record<string, string> = {
|
||||
chatStyleTone: "professional",
|
||||
chatStyleFormality: "medium",
|
||||
chatStyleConstraints: "",
|
||||
chatStyleDoNotUse: "",
|
||||
};
|
||||
return { value: override ?? fallback[key as string] ?? "" } as any;
|
||||
});
|
||||
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||
chatStyleTone: {
|
||||
value: "professional",
|
||||
default: "professional",
|
||||
override: null,
|
||||
},
|
||||
chatStyleFormality: {
|
||||
value: "medium",
|
||||
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 () => {
|
||||
@ -48,12 +47,28 @@ describe("buildJobChatPromptContext", () => {
|
||||
});
|
||||
|
||||
vi.mocked(getJobById).mockResolvedValue(job);
|
||||
vi.mocked(getAllSettings).mockResolvedValue({
|
||||
chatStyleTone: "direct",
|
||||
chatStyleFormality: "high",
|
||||
chatStyleConstraints: "Keep responses under 120 words",
|
||||
chatStyleDoNotUse: "synergy, leverage",
|
||||
});
|
||||
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||
chatStyleTone: {
|
||||
value: "direct",
|
||||
default: "professional",
|
||||
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({
|
||||
basics: {
|
||||
name: "Test User",
|
||||
|
||||
@ -3,9 +3,8 @@ import { sanitizeUnknown } from "@infra/sanitize";
|
||||
import type { Job, ResumeProfile } from "@shared/types";
|
||||
import { badRequest, notFound } from "../infra/errors";
|
||||
import * as jobsRepo from "../repositories/jobs";
|
||||
import * as settingsRepo from "../repositories/settings";
|
||||
import { getProfile } from "./profile";
|
||||
import { resolveSettingValue } from "./settings-conversion";
|
||||
import { getEffectiveSettings } from "./settings";
|
||||
|
||||
type JobChatStyle = {
|
||||
tone: string;
|
||||
@ -119,29 +118,13 @@ function buildSystemPrompt(style: JobChatStyle): string {
|
||||
}
|
||||
|
||||
async function resolveStyle(): Promise<JobChatStyle> {
|
||||
const overrides = await settingsRepo.getAllSettings();
|
||||
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;
|
||||
const settings = await getEffectiveSettings();
|
||||
|
||||
return {
|
||||
tone,
|
||||
formality,
|
||||
constraints,
|
||||
doNotUse,
|
||||
tone: settings.chatStyleTone.value,
|
||||
formality: settings.chatStyleFormality.value,
|
||||
constraints: settings.chatStyleConstraints.value,
|
||||
doNotUse: settings.chatStyleDoNotUse.value,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -3,11 +3,9 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type JsonSchemaDefinition,
|
||||
LlmService,
|
||||
parseJsonContent,
|
||||
} from "./llm-service";
|
||||
import { LlmService } from "./llm/service";
|
||||
import type { JsonSchemaDefinition } from "./llm/types";
|
||||
import { parseJsonContent } from "./llm/utils/json";
|
||||
|
||||
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 type { ManualJobDraft } from "@shared/types";
|
||||
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 {
|
||||
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 {
|
||||
callJson() {
|
||||
return llmCallJson();
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { AppError } from "@infra/errors";
|
||||
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", () => {
|
||||
const originalClientId = process.env.GMAIL_OAUTH_CLIENT_ID;
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { requestTimeout } from "@infra/errors";
|
||||
import { logger } from "@infra/logger";
|
||||
import { getAllJobs } from "@server/repositories/jobs";
|
||||
import {
|
||||
@ -14,138 +13,22 @@ import {
|
||||
completePostApplicationSyncRun,
|
||||
startPostApplicationSyncRun,
|
||||
} from "@server/repositories/post-application-sync-runs";
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
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 {
|
||||
type JsonSchemaDefinition,
|
||||
LlmService,
|
||||
} from "@server/services/llm-service";
|
||||
import {
|
||||
messageTypeFromStageTarget,
|
||||
normalizeStageTarget,
|
||||
resolveStageTransitionForTarget,
|
||||
} 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";
|
||||
buildEmailText,
|
||||
extractBodyText,
|
||||
getMessageFull,
|
||||
getMessageMetadata,
|
||||
listMessageIds,
|
||||
resolveGmailAccessToken,
|
||||
} from "./gmail-api";
|
||||
|
||||
const DEFAULT_SEARCH_DAYS = 90;
|
||||
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 = {
|
||||
discovered: number;
|
||||
@ -154,6 +37,11 @@ export type GmailSyncSummary = {
|
||||
errored: number;
|
||||
};
|
||||
|
||||
export const __test__ = {
|
||||
extractBodyText,
|
||||
buildEmailText,
|
||||
};
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
@ -184,220 +72,10 @@ function parseGmailCredentials(
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
function headerValue(
|
||||
headers: Array<{ name?: string; value?: string }>,
|
||||
name: string,
|
||||
): string {
|
||||
const found = headers.find(
|
||||
(header) => (header.name ?? "").toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
@ -424,299 +102,14 @@ function parseReceivedAt(dateHeader: string): number {
|
||||
return Number.isFinite(parsed) ? parsed : Date.now();
|
||||
}
|
||||
|
||||
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 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 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 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";
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error: unknown): string {
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
/** JSON schema for project selection response */
|
||||
|
||||
@ -287,12 +287,13 @@ describe("salary penalty", () => {
|
||||
describe("isSalaryMissing detection", () => {
|
||||
it("should detect null salary as missing", async () => {
|
||||
const { scoreJobSuitability } = await import("./scorer");
|
||||
const { LlmService } = await import("./llm-service");
|
||||
const { LlmService } = await import("./llm/service");
|
||||
|
||||
getEffectiveSettingsMock.mockResolvedValue({
|
||||
penalizeMissingSalary: true,
|
||||
missingSalaryPenalty: 10,
|
||||
});
|
||||
penalizeMissingSalary: { value: true, default: true, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
rxresumeBaseResumeId: "base-resume-123",
|
||||
} as any);
|
||||
|
||||
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
|
||||
success: true,
|
||||
@ -314,12 +315,13 @@ describe("salary penalty", () => {
|
||||
|
||||
it("should detect empty string salary as missing", async () => {
|
||||
const { scoreJobSuitability } = await import("./scorer");
|
||||
const { LlmService } = await import("./llm-service");
|
||||
const { LlmService } = await import("./llm/service");
|
||||
|
||||
getEffectiveSettingsMock.mockResolvedValue({
|
||||
penalizeMissingSalary: true,
|
||||
missingSalaryPenalty: 10,
|
||||
});
|
||||
penalizeMissingSalary: { value: true, default: true, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
rxresumeBaseResumeId: "base-resume-123",
|
||||
} as any);
|
||||
|
||||
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
|
||||
success: true,
|
||||
@ -339,12 +341,13 @@ describe("salary penalty", () => {
|
||||
|
||||
it("should detect whitespace-only salary as missing", async () => {
|
||||
const { scoreJobSuitability } = await import("./scorer");
|
||||
const { LlmService } = await import("./llm-service");
|
||||
const { LlmService } = await import("./llm/service");
|
||||
|
||||
getEffectiveSettingsMock.mockResolvedValue({
|
||||
penalizeMissingSalary: true,
|
||||
missingSalaryPenalty: 10,
|
||||
});
|
||||
penalizeMissingSalary: { value: true, default: true, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
rxresumeBaseResumeId: "base-resume-123",
|
||||
} as any);
|
||||
|
||||
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
|
||||
success: true,
|
||||
@ -364,12 +367,13 @@ describe("salary penalty", () => {
|
||||
|
||||
it("should NOT penalize jobs with non-empty salary", async () => {
|
||||
const { scoreJobSuitability } = await import("./scorer");
|
||||
const { LlmService } = await import("./llm-service");
|
||||
const { LlmService } = await import("./llm/service");
|
||||
|
||||
getEffectiveSettingsMock.mockResolvedValue({
|
||||
penalizeMissingSalary: true,
|
||||
missingSalaryPenalty: 10,
|
||||
});
|
||||
penalizeMissingSalary: { value: true, default: true, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
rxresumeBaseResumeId: "base-resume-123",
|
||||
} as any);
|
||||
|
||||
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
|
||||
success: true,
|
||||
@ -389,12 +393,13 @@ describe("salary penalty", () => {
|
||||
|
||||
it("should NOT penalize jobs with actual salary value", async () => {
|
||||
const { scoreJobSuitability } = await import("./scorer");
|
||||
const { LlmService } = await import("./llm-service");
|
||||
const { LlmService } = await import("./llm/service");
|
||||
|
||||
getEffectiveSettingsMock.mockResolvedValue({
|
||||
penalizeMissingSalary: true,
|
||||
missingSalaryPenalty: 10,
|
||||
});
|
||||
penalizeMissingSalary: { value: true, default: true, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
rxresumeBaseResumeId: "base-resume-123",
|
||||
} as any);
|
||||
|
||||
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
|
||||
success: true,
|
||||
@ -416,12 +421,13 @@ describe("salary penalty", () => {
|
||||
describe("penalty application", () => {
|
||||
it("should not apply penalty when disabled", async () => {
|
||||
const { scoreJobSuitability } = await import("./scorer");
|
||||
const { LlmService } = await import("./llm-service");
|
||||
const { LlmService } = await import("./llm/service");
|
||||
|
||||
getEffectiveSettingsMock.mockResolvedValue({
|
||||
penalizeMissingSalary: false,
|
||||
missingSalaryPenalty: 10,
|
||||
});
|
||||
penalizeMissingSalary: { value: false, default: false, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
rxresumeBaseResumeId: "base-resume-123",
|
||||
} as any);
|
||||
|
||||
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
|
||||
success: true,
|
||||
@ -441,12 +447,13 @@ describe("salary penalty", () => {
|
||||
|
||||
it("should clamp score to minimum 0 (high penalty on medium score)", async () => {
|
||||
const { scoreJobSuitability } = await import("./scorer");
|
||||
const { LlmService } = await import("./llm-service");
|
||||
const { LlmService } = await import("./llm/service");
|
||||
|
||||
getEffectiveSettingsMock.mockResolvedValue({
|
||||
penalizeMissingSalary: true,
|
||||
missingSalaryPenalty: 100,
|
||||
});
|
||||
penalizeMissingSalary: { value: true, default: true, override: null },
|
||||
missingSalaryPenalty: { value: 100, default: 100, override: null },
|
||||
rxresumeBaseResumeId: "base-resume-123",
|
||||
} as any);
|
||||
|
||||
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
|
||||
success: true,
|
||||
@ -466,12 +473,13 @@ describe("salary penalty", () => {
|
||||
|
||||
it("should clamp score to minimum 0 (low score with penalty)", async () => {
|
||||
const { scoreJobSuitability } = await import("./scorer");
|
||||
const { LlmService } = await import("./llm-service");
|
||||
const { LlmService } = await import("./llm/service");
|
||||
|
||||
getEffectiveSettingsMock.mockResolvedValue({
|
||||
penalizeMissingSalary: true,
|
||||
missingSalaryPenalty: 10,
|
||||
});
|
||||
penalizeMissingSalary: { value: true, default: true, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
rxresumeBaseResumeId: "base-resume-123",
|
||||
} as any);
|
||||
|
||||
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
|
||||
success: true,
|
||||
@ -491,12 +499,13 @@ describe("salary penalty", () => {
|
||||
|
||||
it("should handle penalty of 0", async () => {
|
||||
const { scoreJobSuitability } = await import("./scorer");
|
||||
const { LlmService } = await import("./llm-service");
|
||||
const { LlmService } = await import("./llm/service");
|
||||
|
||||
getEffectiveSettingsMock.mockResolvedValue({
|
||||
penalizeMissingSalary: true,
|
||||
missingSalaryPenalty: 0,
|
||||
});
|
||||
penalizeMissingSalary: { value: true, default: true, override: null },
|
||||
missingSalaryPenalty: { value: 0, default: 0, override: null },
|
||||
rxresumeBaseResumeId: "base-resume-123",
|
||||
} as any);
|
||||
|
||||
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
|
||||
success: true,
|
||||
@ -516,12 +525,13 @@ describe("salary penalty", () => {
|
||||
|
||||
it("should apply penalty with correct amount", async () => {
|
||||
const { scoreJobSuitability } = await import("./scorer");
|
||||
const { LlmService } = await import("./llm-service");
|
||||
const { LlmService } = await import("./llm/service");
|
||||
|
||||
getEffectiveSettingsMock.mockResolvedValue({
|
||||
penalizeMissingSalary: true,
|
||||
missingSalaryPenalty: 25,
|
||||
});
|
||||
penalizeMissingSalary: { value: true, default: true, override: null },
|
||||
missingSalaryPenalty: { value: 25, default: 25, override: null },
|
||||
rxresumeBaseResumeId: "base-resume-123",
|
||||
} as any);
|
||||
|
||||
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
|
||||
success: true,
|
||||
@ -545,12 +555,13 @@ describe("salary penalty", () => {
|
||||
describe("mock scoring with penalty", () => {
|
||||
it("should apply penalty in mock scoring fallback", async () => {
|
||||
const { scoreJobSuitability } = await import("./scorer");
|
||||
const { LlmService } = await import("./llm-service");
|
||||
const { LlmService } = await import("./llm/service");
|
||||
|
||||
getEffectiveSettingsMock.mockResolvedValue({
|
||||
penalizeMissingSalary: true,
|
||||
missingSalaryPenalty: 10,
|
||||
});
|
||||
penalizeMissingSalary: { value: true, default: true, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
rxresumeBaseResumeId: "base-resume-123",
|
||||
} as any);
|
||||
|
||||
// Simulate API key error to trigger mock scoring
|
||||
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
|
||||
@ -573,12 +584,13 @@ describe("salary penalty", () => {
|
||||
|
||||
it("should not apply penalty in mock scoring when disabled", async () => {
|
||||
const { scoreJobSuitability } = await import("./scorer");
|
||||
const { LlmService } = await import("./llm-service");
|
||||
const { LlmService } = await import("./llm/service");
|
||||
|
||||
getEffectiveSettingsMock.mockResolvedValue({
|
||||
penalizeMissingSalary: false,
|
||||
missingSalaryPenalty: 10,
|
||||
});
|
||||
penalizeMissingSalary: { value: false, default: false, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
rxresumeBaseResumeId: "base-resume-123",
|
||||
} as any);
|
||||
|
||||
vi.spyOn(LlmService.prototype, "callJson").mockResolvedValue({
|
||||
success: false,
|
||||
|
||||
@ -5,7 +5,8 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import type { Job } from "@shared/types";
|
||||
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";
|
||||
|
||||
interface SuitabilityResult {
|
||||
@ -113,7 +114,10 @@ export async function scoreJobSuitability(
|
||||
jobId: job.id,
|
||||
error: result.error,
|
||||
});
|
||||
return mockScore(job, settings);
|
||||
return mockScore(job, {
|
||||
penalizeMissingSalary: settings.penalizeMissingSalary.value,
|
||||
missingSalaryPenalty: settings.missingSalaryPenalty.value,
|
||||
});
|
||||
}
|
||||
|
||||
const { score, reason } = result.data;
|
||||
@ -123,7 +127,10 @@ export async function scoreJobSuitability(
|
||||
logger.error("Invalid score in AI response, using mock scoring", {
|
||||
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)));
|
||||
@ -131,8 +138,8 @@ export async function scoreJobSuitability(
|
||||
|
||||
// Apply salary penalty if enabled
|
||||
const penaltyResult = applySalaryPenalty(job, clampedScore, clampedReason, {
|
||||
penalizeMissingSalary: settings.penalizeMissingSalary,
|
||||
missingSalaryPenalty: settings.missingSalaryPenalty,
|
||||
penalizeMissingSalary: settings.penalizeMissingSalary.value,
|
||||
missingSalaryPenalty: settings.missingSalaryPenalty.value,
|
||||
});
|
||||
|
||||
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,
|
||||
SettingUpdateHandler,
|
||||
} from "./registry";
|
||||
export {
|
||||
settingsUpdateRegistry,
|
||||
toJsonOrNull,
|
||||
toNormalizedStringOrNull,
|
||||
toNumberStringOrNull,
|
||||
} from "./registry";
|
||||
export { settingsUpdateRegistry } from "./registry";
|
||||
|
||||
@ -6,10 +6,7 @@ import {
|
||||
extractProjectsFromProfile,
|
||||
normalizeResumeProjectsSettings,
|
||||
} from "@server/services/resumeProjects";
|
||||
import {
|
||||
type SettingsConversionKey,
|
||||
serializeSettingValue,
|
||||
} from "@server/services/settings-conversion";
|
||||
import { settingsRegistry } from "@shared/settings-registry";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||
|
||||
export type DeferredSideEffect = "refreshBackupScheduler";
|
||||
@ -39,22 +36,6 @@ export type SettingsUpdatePlan = {
|
||||
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(
|
||||
args: {
|
||||
actions?: SettingsUpdateAction[];
|
||||
@ -68,7 +49,7 @@ function result(
|
||||
}
|
||||
|
||||
function persistAction(
|
||||
settingKey: Parameters<typeof settingsRepo.setSetting>[0],
|
||||
settingKey: SettingKey,
|
||||
value: string | null,
|
||||
sideEffect?: () => void | Promise<void>,
|
||||
): SettingsUpdateAction {
|
||||
@ -79,270 +60,63 @@ function persistAction(
|
||||
};
|
||||
}
|
||||
|
||||
function singleAction<K extends keyof UpdateSettingsInput>(
|
||||
fn: SettingUpdateHandler<K>,
|
||||
): SettingUpdateHandler<K> {
|
||||
return fn;
|
||||
}
|
||||
|
||||
function metadataPersistAction(
|
||||
key: SettingsConversionKey,
|
||||
value: unknown,
|
||||
): SettingsUpdateAction {
|
||||
return persistAction(key, serializeSettingValue(key, value as never));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const settingsUpdateRegistry: Partial<{
|
||||
[K in keyof UpdateSettingsInput]: SettingUpdateHandler<K>;
|
||||
}> = {
|
||||
model: singleAction(({ value }) =>
|
||||
result({ actions: [persistAction("model", value ?? null)] }),
|
||||
),
|
||||
modelScorer: singleAction(({ value }) =>
|
||||
result({ actions: [persistAction("modelScorer", value ?? null)] }),
|
||||
),
|
||||
modelTailoring: singleAction(({ value }) =>
|
||||
result({ actions: [persistAction("modelTailoring", value ?? null)] }),
|
||||
),
|
||||
modelProjectSelection: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [persistAction("modelProjectSelection", value ?? null)],
|
||||
}),
|
||||
),
|
||||
llmProvider: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("llmProvider", normalized, () => {
|
||||
applyEnvValue("LLM_PROVIDER", normalized);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
llmBaseUrl: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("llmBaseUrl", normalized, () => {
|
||||
applyEnvValue("LLM_BASE_URL", normalized);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
pipelineWebhookUrl: singleAction(({ value }) =>
|
||||
result({ actions: [persistAction("pipelineWebhookUrl", value ?? null)] }),
|
||||
),
|
||||
jobCompleteWebhookUrl: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [persistAction("jobCompleteWebhookUrl", value ?? null)],
|
||||
}),
|
||||
),
|
||||
rxresumeBaseResumeId: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [
|
||||
persistAction("rxresumeBaseResumeId", toNormalizedStringOrNull(value)),
|
||||
],
|
||||
}),
|
||||
),
|
||||
resumeProjects: singleAction(async ({ value }) => {
|
||||
const resumeProjects = value ?? null;
|
||||
if (resumeProjects === null) {
|
||||
return result({ actions: [persistAction("resumeProjects", null)] });
|
||||
}> = {};
|
||||
|
||||
for (const [key, def] of Object.entries(settingsRegistry)) {
|
||||
if (def.kind === "virtual") continue;
|
||||
|
||||
const targetKey =
|
||||
def.kind === "alias" ? (def.target as SettingKey) : (key as SettingKey);
|
||||
const isBackup = key.startsWith("backup");
|
||||
const hasEnvKey = "envKey" in def && !!def.envKey;
|
||||
|
||||
// Special case for resumeProjects
|
||||
if (key === "resumeProjects") {
|
||||
settingsUpdateRegistry.resumeProjects = async ({ value }) => {
|
||||
const resumeProjects = value ?? null;
|
||||
if (resumeProjects === null) {
|
||||
return result({ actions: [persistAction(targetKey, null)] });
|
||||
}
|
||||
|
||||
const profile = await getProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const allowed = new Set(catalog.map((project) => project.id));
|
||||
const normalized = normalizeResumeProjectsSettings(
|
||||
resumeProjects as Parameters<typeof normalizeResumeProjectsSettings>[0],
|
||||
allowed,
|
||||
);
|
||||
|
||||
return result({
|
||||
actions: [persistAction(targetKey, JSON.stringify(normalized))],
|
||||
});
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generic handler for all others
|
||||
settingsUpdateRegistry[key as keyof UpdateSettingsInput] = ({ value }) => {
|
||||
let serialized: string | null;
|
||||
|
||||
if ("serialize" in def) {
|
||||
serialized = def.serialize(value as never);
|
||||
} else {
|
||||
serialized = normalizeEnvInput(value as string);
|
||||
}
|
||||
|
||||
const profile = await getProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const allowed = new Set(catalog.map((project) => project.id));
|
||||
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||
const sideEffect = hasEnvKey
|
||||
? () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: def is constrained by kind
|
||||
applyEnvValue((def as any).envKey, serialized);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return result({
|
||||
actions: [persistAction("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 { settingsRegistry } from "@shared/settings-registry";
|
||||
import type { AppSettings } from "@shared/types";
|
||||
import { getEnvSettingsData } from "./envSettings";
|
||||
import { getProfile } from "./profile";
|
||||
@ -7,7 +8,19 @@ import {
|
||||
resolveResumeProjectsSettings,
|
||||
} from "./resumeProjects";
|
||||
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.
|
||||
@ -47,291 +60,73 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
|
||||
const envSettings = await getEnvSettingsData(overrides);
|
||||
|
||||
const defaultModel = process.env.MODEL || "google/gemini-3-flash-preview";
|
||||
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 {
|
||||
const result: Partial<AppSettings> = {
|
||||
...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 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";
|
||||
const rawModel = overrides.model;
|
||||
const modelDef = settingsRegistry.model;
|
||||
const overrideModel = modelDef.parse(rawModel);
|
||||
const modelValue = overrideModel ?? modelDef.default();
|
||||
|
||||
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 "https://openrouter.ai";
|
||||
|
||||
return result as AppSettings;
|
||||
}
|
||||
|
||||
@ -5,7 +5,8 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import type { ResumeProfile } from "@shared/types";
|
||||
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 {
|
||||
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 { resumeProjectsSchema, settingsRegistry } from "./settings-registry";
|
||||
|
||||
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 { resumeProjectsSchema };
|
||||
|
||||
export const updateSettingsSchema = z
|
||||
.object({
|
||||
model: z.string().trim().max(200).nullable().optional(),
|
||||
modelScorer: z.string().trim().max(200).nullable().optional(),
|
||||
modelTailoring: z.string().trim().max(200).nullable().optional(),
|
||||
modelProjectSelection: z.string().trim().max(200).nullable().optional(),
|
||||
llmProvider: z
|
||||
.preprocess(
|
||||
(value) => (value === "" ? null : value),
|
||||
z
|
||||
.enum(["openrouter", "lmstudio", "ollama", "openai", "gemini"])
|
||||
.nullable(),
|
||||
)
|
||||
.optional(),
|
||||
llmBaseUrl: z
|
||||
.preprocess(
|
||||
(value) => (value === "" ? null : value),
|
||||
z.string().trim().url().max(2000).nullable(),
|
||||
)
|
||||
.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"],
|
||||
});
|
||||
}
|
||||
type RegistryKeys = keyof typeof settingsRegistry;
|
||||
|
||||
type UpdateSchemaShape = {
|
||||
[K in RegistryKeys]: (typeof settingsRegistry)[K] extends {
|
||||
schema: z.ZodType<infer U, infer D, infer I>;
|
||||
}
|
||||
? K extends "enableBasicAuth"
|
||||
? z.ZodOptional<z.ZodType<U, D, I>>
|
||||
: z.ZodOptional<z.ZodNullable<z.ZodType<U, D, I>>>
|
||||
: z.ZodTypeAny;
|
||||
};
|
||||
|
||||
const shape = Object.fromEntries(
|
||||
Object.entries(settingsRegistry).map(([key, def]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: def is dynamic
|
||||
const fieldSchema = (def as any).schema as z.ZodTypeAny;
|
||||
if (key === "enableBasicAuth") {
|
||||
return [key, fieldSchema.optional()];
|
||||
}
|
||||
});
|
||||
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 ResumeProjectsSettingsInput = z.infer<typeof resumeProjectsSchema>;
|
||||
|
||||
@ -125,76 +125,57 @@ export const createResumeProjectCatalogItem = (
|
||||
export const createAppSettings = (
|
||||
overrides: Partial<AppSettings> = {},
|
||||
): AppSettings => ({
|
||||
model: "gpt-4o",
|
||||
defaultModel: "gpt-4o",
|
||||
overrideModel: null,
|
||||
modelScorer: "gpt-4o",
|
||||
overrideModelScorer: null,
|
||||
modelTailoring: "gpt-4o",
|
||||
overrideModelTailoring: null,
|
||||
modelProjectSelection: "gpt-4o",
|
||||
overrideModelProjectSelection: null,
|
||||
llmProvider: "openai",
|
||||
defaultLlmProvider: "openai",
|
||||
overrideLlmProvider: null,
|
||||
llmBaseUrl: "https://api.openai.com/v1",
|
||||
defaultLlmBaseUrl: "https://api.openai.com/v1",
|
||||
overrideLlmBaseUrl: null,
|
||||
pipelineWebhookUrl: "",
|
||||
defaultPipelineWebhookUrl: "",
|
||||
overridePipelineWebhookUrl: null,
|
||||
jobCompleteWebhookUrl: "",
|
||||
defaultJobCompleteWebhookUrl: "",
|
||||
overrideJobCompleteWebhookUrl: null,
|
||||
model: { value: "gpt-4o", default: "gpt-4o", override: null },
|
||||
modelScorer: { value: "gpt-4o", override: null },
|
||||
modelTailoring: { value: "gpt-4o", override: null },
|
||||
modelProjectSelection: { value: "gpt-4o", override: null },
|
||||
llmProvider: { value: "openai", default: "openai", override: null },
|
||||
llmBaseUrl: {
|
||||
value: "https://api.openai.com/v1",
|
||||
default: "https://api.openai.com/v1",
|
||||
override: null,
|
||||
},
|
||||
pipelineWebhookUrl: { value: "", default: "", override: null },
|
||||
jobCompleteWebhookUrl: { value: "", default: "", override: null },
|
||||
profileProjects: [],
|
||||
resumeProjects: {
|
||||
maxProjects: 3,
|
||||
lockedProjectIds: [],
|
||||
aiSelectableProjectIds: [],
|
||||
value: { maxProjects: 3, lockedProjectIds: [], aiSelectableProjectIds: [] },
|
||||
default: {
|
||||
maxProjects: 3,
|
||||
lockedProjectIds: [],
|
||||
aiSelectableProjectIds: [],
|
||||
},
|
||||
override: null,
|
||||
},
|
||||
defaultResumeProjects: {
|
||||
maxProjects: 3,
|
||||
lockedProjectIds: [],
|
||||
aiSelectableProjectIds: [],
|
||||
},
|
||||
overrideResumeProjects: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
ukvisajobsMaxJobs: 50,
|
||||
defaultUkvisajobsMaxJobs: 50,
|
||||
overrideUkvisajobsMaxJobs: null,
|
||||
adzunaMaxJobsPerTerm: 50,
|
||||
defaultAdzunaMaxJobsPerTerm: 50,
|
||||
overrideAdzunaMaxJobsPerTerm: null,
|
||||
gradcrackerMaxJobsPerTerm: 50,
|
||||
defaultGradcrackerMaxJobsPerTerm: 50,
|
||||
overrideGradcrackerMaxJobsPerTerm: null,
|
||||
searchTerms: ["Software Engineer"],
|
||||
defaultSearchTerms: ["Software Engineer"],
|
||||
overrideSearchTerms: null,
|
||||
searchCities: "United Kingdom",
|
||||
defaultSearchCities: "United Kingdom",
|
||||
overrideSearchCities: null,
|
||||
jobspyResultsWanted: 20,
|
||||
defaultJobspyResultsWanted: 20,
|
||||
overrideJobspyResultsWanted: null,
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
defaultJobspyCountryIndeed: "united kingdom",
|
||||
overrideJobspyCountryIndeed: null,
|
||||
showSponsorInfo: true,
|
||||
defaultShowSponsorInfo: true,
|
||||
overrideShowSponsorInfo: null,
|
||||
chatStyleTone: "professional",
|
||||
defaultChatStyleTone: "professional",
|
||||
overrideChatStyleTone: null,
|
||||
chatStyleFormality: "medium",
|
||||
defaultChatStyleFormality: "medium",
|
||||
overrideChatStyleFormality: null,
|
||||
chatStyleConstraints: "",
|
||||
defaultChatStyleConstraints: "",
|
||||
overrideChatStyleConstraints: null,
|
||||
chatStyleDoNotUse: "",
|
||||
defaultChatStyleDoNotUse: "",
|
||||
overrideChatStyleDoNotUse: null,
|
||||
ukvisajobsMaxJobs: { value: 50, default: 50, override: null },
|
||||
adzunaMaxJobsPerTerm: { value: 50, default: 50, override: null },
|
||||
gradcrackerMaxJobsPerTerm: { value: 50, default: 50, override: null },
|
||||
searchTerms: {
|
||||
value: ["Software Engineer"],
|
||||
default: ["Software Engineer"],
|
||||
override: null,
|
||||
},
|
||||
searchCities: {
|
||||
value: "United Kingdom",
|
||||
default: "United Kingdom",
|
||||
override: null,
|
||||
},
|
||||
jobspyResultsWanted: { value: 20, default: 20, override: null },
|
||||
jobspyCountryIndeed: {
|
||||
value: "united kingdom",
|
||||
default: "united kingdom",
|
||||
override: null,
|
||||
},
|
||||
showSponsorInfo: { value: true, default: true, override: null },
|
||||
chatStyleTone: {
|
||||
value: "professional",
|
||||
default: "professional",
|
||||
override: null,
|
||||
},
|
||||
chatStyleFormality: { value: "medium", default: "medium", override: null },
|
||||
chatStyleConstraints: { value: "", default: "", override: null },
|
||||
chatStyleDoNotUse: { value: "", default: "", override: null },
|
||||
llmApiKeyHint: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumePasswordHint: null,
|
||||
@ -206,23 +187,11 @@ export const createAppSettings = (
|
||||
adzunaAppKeyHint: null,
|
||||
webhookSecretHint: null,
|
||||
basicAuthActive: false,
|
||||
backupEnabled: false,
|
||||
defaultBackupEnabled: false,
|
||||
overrideBackupEnabled: null,
|
||||
backupHour: 3,
|
||||
defaultBackupHour: 3,
|
||||
overrideBackupHour: 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,
|
||||
backupEnabled: { value: false, default: false, override: null },
|
||||
backupHour: { value: 3, default: 3, override: null },
|
||||
backupMaxCount: { value: 7, default: 7, override: null },
|
||||
penalizeMissingSalary: { value: false, default: false, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
autoSkipScoreThreshold: { value: null, default: null, override: null },
|
||||
...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